mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-10 04:22:12 +00:00
[next] Only rewrite next-action requests to .action handlers (#11504)
This only rewrites requests that contain a `next-action` header (explicitly indicating it's a server action). A side effect is that POST requests to a server action on a static route, without a next-action header, won't be marked as streaming (but will still execute normally). This is fine as only the handled action needs to stream. This relands .action handling behind a feature flag.
This commit is contained in:
5
.changeset/big-turkeys-smoke.md
Normal file
5
.changeset/big-turkeys-smoke.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@vercel/next": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Only rewrite next-action requests to `.action` handlers
|
||||||
@@ -1928,6 +1928,7 @@ export const build: BuildV2 = async ({
|
|||||||
bypassToken: prerenderManifest.bypassToken || '',
|
bypassToken: prerenderManifest.bypassToken || '',
|
||||||
isServerMode,
|
isServerMode,
|
||||||
experimentalPPRRoutes,
|
experimentalPPRRoutes,
|
||||||
|
hasActionOutputSupport: false,
|
||||||
}).then(arr =>
|
}).then(arr =>
|
||||||
localizeDynamicRoutes(
|
localizeDynamicRoutes(
|
||||||
arr,
|
arr,
|
||||||
@@ -1958,6 +1959,7 @@ export const build: BuildV2 = async ({
|
|||||||
bypassToken: prerenderManifest.bypassToken || '',
|
bypassToken: prerenderManifest.bypassToken || '',
|
||||||
isServerMode,
|
isServerMode,
|
||||||
experimentalPPRRoutes,
|
experimentalPPRRoutes,
|
||||||
|
hasActionOutputSupport: false,
|
||||||
}).then(arr =>
|
}).then(arr =>
|
||||||
arr.map(route => {
|
arr.map(route => {
|
||||||
route.src = route.src.replace('^', `^${dynamicPrefix}`);
|
route.src = route.src.replace('^', `^${dynamicPrefix}`);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
normalizePrefetches,
|
normalizePrefetches,
|
||||||
CreateLambdaFromPseudoLayersOptions,
|
CreateLambdaFromPseudoLayersOptions,
|
||||||
getPostponeResumePathname,
|
getPostponeResumePathname,
|
||||||
|
LambdaGroup,
|
||||||
MAX_UNCOMPRESSED_LAMBDA_SIZE,
|
MAX_UNCOMPRESSED_LAMBDA_SIZE,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import {
|
import {
|
||||||
@@ -68,6 +69,7 @@ const CORRECT_NOT_FOUND_ROUTES_VERSION = 'v12.0.1';
|
|||||||
const CORRECT_MIDDLEWARE_ORDER_VERSION = 'v12.1.7-canary.29';
|
const CORRECT_MIDDLEWARE_ORDER_VERSION = 'v12.1.7-canary.29';
|
||||||
const NEXT_DATA_MIDDLEWARE_RESOLVING_VERSION = 'v12.1.7-canary.33';
|
const NEXT_DATA_MIDDLEWARE_RESOLVING_VERSION = 'v12.1.7-canary.33';
|
||||||
const EMPTY_ALLOW_QUERY_FOR_PRERENDERED_VERSION = 'v12.2.0';
|
const EMPTY_ALLOW_QUERY_FOR_PRERENDERED_VERSION = 'v12.2.0';
|
||||||
|
const ACTION_OUTPUT_SUPPORT_VERSION = 'v14.2.2';
|
||||||
const CORRECTED_MANIFESTS_VERSION = 'v12.2.0';
|
const CORRECTED_MANIFESTS_VERSION = 'v12.2.0';
|
||||||
|
|
||||||
// Ideally this should be in a Next.js manifest so we can change it in
|
// Ideally this should be in a Next.js manifest so we can change it in
|
||||||
@@ -199,6 +201,9 @@ export async function serverBuild({
|
|||||||
nextVersion,
|
nextVersion,
|
||||||
EMPTY_ALLOW_QUERY_FOR_PRERENDERED_VERSION
|
EMPTY_ALLOW_QUERY_FOR_PRERENDERED_VERSION
|
||||||
);
|
);
|
||||||
|
const hasActionOutputSupport =
|
||||||
|
semver.gte(nextVersion, ACTION_OUTPUT_SUPPORT_VERSION) &&
|
||||||
|
Boolean(process.env.NEXT_EXPERIMENTAL_STREAMING_ACTIONS);
|
||||||
const projectDir = requiredServerFilesManifest.relativeAppDir
|
const projectDir = requiredServerFilesManifest.relativeAppDir
|
||||||
? path.join(baseDir, requiredServerFilesManifest.relativeAppDir)
|
? path.join(baseDir, requiredServerFilesManifest.relativeAppDir)
|
||||||
: requiredServerFilesManifest.appDir || entryPath;
|
: requiredServerFilesManifest.appDir || entryPath;
|
||||||
@@ -926,11 +931,23 @@ export async function serverBuild({
|
|||||||
inversedAppPathManifest,
|
inversedAppPathManifest,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const appRouterStreamingActionLambdaGroups: LambdaGroup[] = [];
|
||||||
|
|
||||||
for (const group of appRouterLambdaGroups) {
|
for (const group of appRouterLambdaGroups) {
|
||||||
if (!group.isPrerenders || group.isExperimentalPPR) {
|
if (!group.isPrerenders || group.isExperimentalPPR) {
|
||||||
group.isStreaming = true;
|
group.isStreaming = true;
|
||||||
}
|
}
|
||||||
group.isAppRouter = true;
|
group.isAppRouter = true;
|
||||||
|
|
||||||
|
// We create a streaming variant of the Prerender lambda group
|
||||||
|
// to support actions that are part of a Prerender
|
||||||
|
if (hasActionOutputSupport) {
|
||||||
|
appRouterStreamingActionLambdaGroups.push({
|
||||||
|
...group,
|
||||||
|
isActionLambda: true,
|
||||||
|
isStreaming: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const group of appRouteHandlersLambdaGroups) {
|
for (const group of appRouteHandlersLambdaGroups) {
|
||||||
@@ -982,6 +999,13 @@ export async function serverBuild({
|
|||||||
pseudoLayerBytes: group.pseudoLayerBytes,
|
pseudoLayerBytes: group.pseudoLayerBytes,
|
||||||
uncompressedLayerBytes: group.pseudoLayerUncompressedBytes,
|
uncompressedLayerBytes: group.pseudoLayerUncompressedBytes,
|
||||||
})),
|
})),
|
||||||
|
appRouterStreamingPrerenderLambdaGroups:
|
||||||
|
appRouterStreamingActionLambdaGroups.map(group => ({
|
||||||
|
pages: group.pages,
|
||||||
|
isPrerender: group.isPrerenders,
|
||||||
|
pseudoLayerBytes: group.pseudoLayerBytes,
|
||||||
|
uncompressedLayerBytes: group.pseudoLayerUncompressedBytes,
|
||||||
|
})),
|
||||||
appRouteHandlersLambdaGroups: appRouteHandlersLambdaGroups.map(
|
appRouteHandlersLambdaGroups: appRouteHandlersLambdaGroups.map(
|
||||||
group => ({
|
group => ({
|
||||||
pages: group.pages,
|
pages: group.pages,
|
||||||
@@ -999,6 +1023,7 @@ export async function serverBuild({
|
|||||||
const combinedGroups = [
|
const combinedGroups = [
|
||||||
...pageLambdaGroups,
|
...pageLambdaGroups,
|
||||||
...appRouterLambdaGroups,
|
...appRouterLambdaGroups,
|
||||||
|
...appRouterStreamingActionLambdaGroups,
|
||||||
...apiLambdaGroups,
|
...apiLambdaGroups,
|
||||||
...appRouteHandlersLambdaGroups,
|
...appRouteHandlersLambdaGroups,
|
||||||
];
|
];
|
||||||
@@ -1208,6 +1233,11 @@ export async function serverBuild({
|
|||||||
|
|
||||||
let outputName = path.posix.join(entryDirectory, pageName);
|
let outputName = path.posix.join(entryDirectory, pageName);
|
||||||
|
|
||||||
|
if (group.isActionLambda) {
|
||||||
|
// give the streaming prerenders a .action suffix
|
||||||
|
outputName = `${outputName}.action`;
|
||||||
|
}
|
||||||
|
|
||||||
// If this is a PPR page, then we should prefix the output name.
|
// If this is a PPR page, then we should prefix the output name.
|
||||||
if (isPPR) {
|
if (isPPR) {
|
||||||
if (!revalidate) {
|
if (!revalidate) {
|
||||||
@@ -1378,6 +1408,7 @@ export async function serverBuild({
|
|||||||
isServerMode: true,
|
isServerMode: true,
|
||||||
dynamicMiddlewareRouteMap: middleware.dynamicRouteMap,
|
dynamicMiddlewareRouteMap: middleware.dynamicRouteMap,
|
||||||
experimentalPPRRoutes,
|
experimentalPPRRoutes,
|
||||||
|
hasActionOutputSupport,
|
||||||
}).then(arr =>
|
}).then(arr =>
|
||||||
localizeDynamicRoutes(
|
localizeDynamicRoutes(
|
||||||
arr,
|
arr,
|
||||||
@@ -1905,7 +1936,42 @@ export async function serverBuild({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(hasActionOutputSupport
|
||||||
|
? [
|
||||||
|
// Create rewrites for streaming prerenders (.action routes)
|
||||||
|
// This contains separate rewrites for each possible "has" (action header, or content-type)
|
||||||
|
// Also includes separate handling for index routes which should match to /index.action.
|
||||||
|
// This follows the same pattern as the rewrites for .rsc files.
|
||||||
|
{
|
||||||
|
src: `^${path.posix.join('/', entryDirectory, '/')}`,
|
||||||
|
dest: path.posix.join('/', entryDirectory, '/index.action'),
|
||||||
|
has: [
|
||||||
|
{
|
||||||
|
type: 'header',
|
||||||
|
key: 'next-action',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
continue: true,
|
||||||
|
override: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: `^${path.posix.join(
|
||||||
|
'/',
|
||||||
|
entryDirectory,
|
||||||
|
'/((?!.+\\.action).+?)(?:/)?$'
|
||||||
|
)}`,
|
||||||
|
dest: path.posix.join('/', entryDirectory, '/$1.action'),
|
||||||
|
has: [
|
||||||
|
{
|
||||||
|
type: 'header',
|
||||||
|
key: 'next-action',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
continue: true,
|
||||||
|
override: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
src: `^${path.posix.join('/', entryDirectory, '/')}`,
|
src: `^${path.posix.join('/', entryDirectory, '/')}`,
|
||||||
has: [
|
has: [
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ export async function getDynamicRoutes({
|
|||||||
isServerMode,
|
isServerMode,
|
||||||
dynamicMiddlewareRouteMap,
|
dynamicMiddlewareRouteMap,
|
||||||
experimentalPPRRoutes,
|
experimentalPPRRoutes,
|
||||||
|
hasActionOutputSupport,
|
||||||
}: {
|
}: {
|
||||||
entryPath: string;
|
entryPath: string;
|
||||||
entryDirectory: string;
|
entryDirectory: string;
|
||||||
@@ -333,6 +334,7 @@ export async function getDynamicRoutes({
|
|||||||
isServerMode?: boolean;
|
isServerMode?: boolean;
|
||||||
dynamicMiddlewareRouteMap?: ReadonlyMap<string, RouteWithSrc>;
|
dynamicMiddlewareRouteMap?: ReadonlyMap<string, RouteWithSrc>;
|
||||||
experimentalPPRRoutes: ReadonlySet<string>;
|
experimentalPPRRoutes: ReadonlySet<string>;
|
||||||
|
hasActionOutputSupport: boolean;
|
||||||
}): Promise<RouteWithSrc[]> {
|
}): Promise<RouteWithSrc[]> {
|
||||||
if (routesManifest) {
|
if (routesManifest) {
|
||||||
switch (routesManifest.version) {
|
switch (routesManifest.version) {
|
||||||
@@ -423,14 +425,25 @@ export async function getDynamicRoutes({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
routes.push({
|
if (hasActionOutputSupport) {
|
||||||
...route,
|
routes.push({
|
||||||
src: route.src.replace(
|
...route,
|
||||||
new RegExp(escapeStringRegexp('(?:/)?$')),
|
src: route.src.replace(
|
||||||
'(?:\\.rsc)(?:/)?$'
|
new RegExp(escapeStringRegexp('(?:/)?$')),
|
||||||
),
|
'(?<nxtsuffix>(?:\\.action|\\.rsc))(?:/)?$'
|
||||||
dest: route.dest?.replace(/($|\?)/, '.rsc$1'),
|
),
|
||||||
});
|
dest: route.dest?.replace(/($|\?)/, '$nxtsuffix$1'),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
routes.push({
|
||||||
|
...route,
|
||||||
|
src: route.src.replace(
|
||||||
|
new RegExp(escapeStringRegexp('(?:/)?$')),
|
||||||
|
'(?:\\.rsc)(?:/)?$'
|
||||||
|
),
|
||||||
|
dest: route.dest?.replace(/($|\?)/, '.rsc$1'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
routes.push(route);
|
routes.push(route);
|
||||||
}
|
}
|
||||||
@@ -1487,6 +1500,7 @@ export type LambdaGroup = {
|
|||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
isPrerenders?: boolean;
|
isPrerenders?: boolean;
|
||||||
isExperimentalPPR?: boolean;
|
isExperimentalPPR?: boolean;
|
||||||
|
isActionLambda?: boolean;
|
||||||
isPages?: boolean;
|
isPages?: boolean;
|
||||||
isApiLambda: boolean;
|
isApiLambda: boolean;
|
||||||
pseudoLayer: PseudoLayer;
|
pseudoLayer: PseudoLayer;
|
||||||
|
|||||||
6
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/app/actions.js
vendored
Normal file
6
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/app/actions.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
export async function increment(value) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return value + 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { increment } from '../../../actions';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
async function updateCount() {
|
||||||
|
const newCount = await increment(count);
|
||||||
|
setCount(newCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={updateCount}>
|
||||||
|
<div id="count">{count}</div>
|
||||||
|
<button>Submit</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { increment } from '../../../actions';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
async function updateCount() {
|
||||||
|
const newCount = await increment(count);
|
||||||
|
setCount(newCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={updateCount}>
|
||||||
|
<div id="count">{count}</div>
|
||||||
|
<button>Submit</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
19
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/app/client/static/page.js
vendored
Normal file
19
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/app/client/static/page.js
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { increment } from '../../actions';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
async function updateCount() {
|
||||||
|
const newCount = await increment(count);
|
||||||
|
setCount(newCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={updateCount}>
|
||||||
|
<div id="count">{count}</div>
|
||||||
|
<button>Submit</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/app/layout.js
vendored
Normal file
10
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/app/layout.js
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default function Root({ children }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Hello World</title>
|
||||||
|
</head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/app/other/page.js
vendored
Normal file
45
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/app/other/page.js
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
function request(method) {
|
||||||
|
return fetch('/api/test', {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'multipart/form-data;.*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [result, setResult] = useState('Press submit');
|
||||||
|
const onClick = useCallback(async method => {
|
||||||
|
const res = await request(method);
|
||||||
|
const text = await res.text();
|
||||||
|
|
||||||
|
setResult(res.ok ? `${method} ${text}` : 'Error: ' + res.status);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center justify-center h-screen">
|
||||||
|
<div className="flex flex-row space-x-2 items-center justify-center">
|
||||||
|
<button
|
||||||
|
className="border border-white rounded-sm p-4 mb-4"
|
||||||
|
onClick={() => onClick('GET')}
|
||||||
|
>
|
||||||
|
Submit GET
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="border border-white rounded-sm p-4 mb-4"
|
||||||
|
onClick={() => onClick('POST')}
|
||||||
|
>
|
||||||
|
Submit POST
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-white">{result}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
15
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/app/rsc/dynamic/page.js
vendored
Normal file
15
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/app/rsc/dynamic/page.js
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
async function serverAction() {
|
||||||
|
'use server';
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
revalidatePath('/dynamic');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={serverAction}>
|
||||||
|
<button>Submit</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
async function serverAction() {
|
||||||
|
'use server';
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
revalidatePath('/dynamic');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={serverAction}>
|
||||||
|
<button>Submit</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
async function serverAction() {
|
||||||
|
'use server';
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
revalidatePath('/dynamic');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={serverAction}>
|
||||||
|
<button>Submit</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return [{ slug: 'pre-generated' }];
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
15
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/app/rsc/static/page.js
vendored
Normal file
15
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/app/rsc/static/page.js
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
async function serverAction() {
|
||||||
|
'use server';
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
revalidatePath('/dynamic');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={serverAction}>
|
||||||
|
<button>Submit</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/index.test.js
vendored
Normal file
236
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/index.test.js
vendored
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/* eslint-env jest */
|
||||||
|
const path = require('path');
|
||||||
|
const { deployAndTest } = require('../../utils');
|
||||||
|
const fetch = require('../../../../../test/lib/deployment/fetch-retry');
|
||||||
|
|
||||||
|
const ctx = {};
|
||||||
|
|
||||||
|
function findActionId(page) {
|
||||||
|
page = `app${page}/page`; // add /app prefix and /page suffix
|
||||||
|
|
||||||
|
for (const [actionId, details] of Object.entries(ctx.actionManifest.node)) {
|
||||||
|
if (details.workers[page]) {
|
||||||
|
return actionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFormDataPayload(actionId) {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
body: `------WebKitFormBoundaryHcVuFa30AN0QV3uZ\r\nContent-Disposition: form-data; name=\"1_$ACTION_ID_${actionId}\"\r\n\r\n\r\n------WebKitFormBoundaryHcVuFa30AN0QV3uZ\r\nContent-Disposition: form-data; name=\"0\"\r\n\r\n[\"$K1\"]\r\n------WebKitFormBoundaryHcVuFa30AN0QV3uZ--\r\n`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type':
|
||||||
|
'multipart/form-data; boundary=----WebKitFormBoundaryHcVuFa30AN0QV3uZ',
|
||||||
|
'Next-Action': actionId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(`${__dirname.split(path.sep).pop()}`, () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const info = await deployAndTest(__dirname);
|
||||||
|
|
||||||
|
const actionManifest = await fetch(
|
||||||
|
`${info.deploymentUrl}/server-reference-manifest.json`
|
||||||
|
).then(res => res.json());
|
||||||
|
|
||||||
|
ctx.actionManifest = actionManifest;
|
||||||
|
|
||||||
|
Object.assign(ctx, info);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('client component', () => {
|
||||||
|
it('should bypass the static cache for a server action', async () => {
|
||||||
|
const path = '/client/static';
|
||||||
|
const actionId = findActionId(path);
|
||||||
|
|
||||||
|
const res = await fetch(`${ctx.deploymentUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify([1337]),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain;charset=UTF-8',
|
||||||
|
'Next-Action': actionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain('1338');
|
||||||
|
expect(res.headers.get('x-matched-path')).toBe(path + '.action');
|
||||||
|
expect(res.headers.get('x-vercel-cache')).toBe('MISS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should bypass the static cache for a server action on a page with dynamic params', async () => {
|
||||||
|
const path = '/client/static/[dynamic-static]';
|
||||||
|
const actionId = findActionId(path);
|
||||||
|
|
||||||
|
const res = await fetch(`${ctx.deploymentUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify([1337]),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain;charset=UTF-8',
|
||||||
|
'Next-Action': actionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain('1338');
|
||||||
|
expect(res.headers.get('x-matched-path')).toBe(path + '.action');
|
||||||
|
expect(res.headers.get('x-vercel-cache')).toBe('MISS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should bypass the static cache for a multipart request (no action header)', async () => {
|
||||||
|
const path = '/client/static';
|
||||||
|
const actionId = findActionId(path);
|
||||||
|
|
||||||
|
const res = await fetch(`${ctx.deploymentUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: `------WebKitFormBoundaryHcVuFa30AN0QV3uZ\r\nContent-Disposition: form-data; name=\"1_$ACTION_ID_${actionId}\"\r\n\r\n\r\n------WebKitFormBoundaryHcVuFa30AN0QV3uZ\r\nContent-Disposition: form-data; name=\"0\"\r\n\r\n[\"$K1\"]\r\n------WebKitFormBoundaryHcVuFa30AN0QV3uZ--\r\n`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type':
|
||||||
|
'multipart/form-data; boundary=----WebKitFormBoundaryHcVuFa30AN0QV3uZ',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8');
|
||||||
|
// This is a "BYPASS" because no action ID was provided, so it'll fall back to
|
||||||
|
// `experimentalBypassFor` handling.
|
||||||
|
expect(res.headers.get('x-vercel-cache')).toBe('BYPASS');
|
||||||
|
expect(res.headers.get('x-matched-path')).toBe(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should properly invoke the action on a dynamic page', async () => {
|
||||||
|
const path = '/client/dynamic/[id]';
|
||||||
|
const actionId = findActionId(path);
|
||||||
|
|
||||||
|
const res = await fetch(`${ctx.deploymentUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify([1337]),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain;charset=UTF-8',
|
||||||
|
'Next-Action': actionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain('1338');
|
||||||
|
expect(res.headers.get('x-matched-path')).toBe(path + '.action');
|
||||||
|
expect(res.headers.get('x-vercel-cache')).toBe('MISS');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('server component', () => {
|
||||||
|
it('should bypass the static cache for a server action', async () => {
|
||||||
|
const path = '/rsc/static';
|
||||||
|
const actionId = findActionId(path);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${ctx.deploymentUrl}${path}`,
|
||||||
|
generateFormDataPayload(actionId)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.headers.get('x-matched-path')).toBe(path + '.action');
|
||||||
|
expect(res.headers.get('content-type')).toBe('text/x-component');
|
||||||
|
expect(res.headers.get('x-vercel-cache')).toBe('MISS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should bypass the static cache for a server action on a page with dynamic params', async () => {
|
||||||
|
const path = '/rsc/static/[dynamic-static]';
|
||||||
|
const actionId = findActionId(path);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${ctx.deploymentUrl}${path}`,
|
||||||
|
generateFormDataPayload(actionId)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.headers.get('x-matched-path')).toBe(path + '.action');
|
||||||
|
expect(res.headers.get('content-type')).toBe('text/x-component');
|
||||||
|
expect(res.headers.get('x-vercel-cache')).toBe('MISS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should properly invoke the action on a dynamic page', async () => {
|
||||||
|
const path = '/rsc/dynamic';
|
||||||
|
const actionId = findActionId(path);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${ctx.deploymentUrl}${path}`,
|
||||||
|
generateFormDataPayload(actionId)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.headers.get('x-matched-path')).toBe(path + '.action');
|
||||||
|
expect(res.headers.get('content-type')).toBe('text/x-component');
|
||||||
|
expect(res.headers.get('x-vercel-cache')).toBe('MISS');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateStaticParams', () => {
|
||||||
|
it('should bypass the static cache for a server action when pre-generated', async () => {
|
||||||
|
const path = '/rsc/static/generate-static-params/pre-generated';
|
||||||
|
const actionId = findActionId(
|
||||||
|
'/rsc/static/generate-static-params/[slug]'
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${ctx.deploymentUrl}${path}`,
|
||||||
|
generateFormDataPayload(actionId)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.headers.get('x-matched-path')).toBe(
|
||||||
|
'/rsc/static/generate-static-params/[slug].action'
|
||||||
|
);
|
||||||
|
expect(res.headers.get('content-type')).toBe('text/x-component');
|
||||||
|
expect(res.headers.get('x-vercel-cache')).toBe('MISS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should bypass the static cache for a server action when not pre-generated', async () => {
|
||||||
|
const page = '/rsc/static/generate-static-params/[slug]';
|
||||||
|
const actionId = findActionId(page);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${ctx.deploymentUrl}/rsc/static/generate-static-params/not-pre-generated`,
|
||||||
|
generateFormDataPayload(actionId)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.headers.get('x-matched-path')).toBe(page + '.action');
|
||||||
|
expect(res.headers.get('content-type')).toBe('text/x-component');
|
||||||
|
expect(res.headers.get('x-vercel-cache')).toBe('MISS');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pages', () => {
|
||||||
|
it('should not attempt to rewrite the action path for a server action (POST)', async () => {
|
||||||
|
const res = await fetch(`${ctx.deploymentUrl}/api/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type':
|
||||||
|
'multipart/form-data; boundary=----WebKitFormBoundaryHcVuFa30AN0QV3uZ',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.headers.get('x-matched-path')).toBe('/api/test');
|
||||||
|
expect(res.headers.get('x-vercel-cache')).toBe('MISS');
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ message: 'Hello from Next.js!' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not attempt to rewrite the action path for a server action (GET)', async () => {
|
||||||
|
const res = await fetch(`${ctx.deploymentUrl}/api/test`);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.headers.get('x-matched-path')).toBe('/api/test');
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ message: 'Hello from Next.js!' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/next.config.js
vendored
Normal file
1
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/next.config.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
module.exports = {};
|
||||||
9
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/package.json
vendored
Normal file
9
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/package.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"next": "canary"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build && cp .next/server/server-reference-manifest.json public/"
|
||||||
|
},
|
||||||
|
"ignoreNextjsUpdates": true
|
||||||
|
}
|
||||||
3
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/pages/api/test.js
vendored
Normal file
3
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/pages/api/test.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function handler(req, res) {
|
||||||
|
res.status(200).json({ message: 'Hello from Next.js!' });
|
||||||
|
}
|
||||||
0
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/public/.keep
vendored
Normal file
0
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/public/.keep
vendored
Normal file
14
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/vercel.json
vendored
Normal file
14
packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/vercel.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"builds": [
|
||||||
|
{
|
||||||
|
"src": "package.json",
|
||||||
|
"use": "@vercel/next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"env": {
|
||||||
|
"NEXT_EXPERIMENTAL_STREAMING_ACTIONS": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"probes": []
|
||||||
|
}
|
||||||
@@ -7,4 +7,4 @@ export default function Root({ children }) {
|
|||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
45
packages/next/test/fixtures/00-app-dir-actions/app/other/page.js
vendored
Normal file
45
packages/next/test/fixtures/00-app-dir-actions/app/other/page.js
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
function request(method) {
|
||||||
|
return fetch('/api/test', {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'multipart/form-data;.*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [result, setResult] = useState('Press submit');
|
||||||
|
const onClick = useCallback(async method => {
|
||||||
|
const res = await request(method);
|
||||||
|
const text = await res.text();
|
||||||
|
|
||||||
|
setResult(res.ok ? `${method} ${text}` : 'Error: ' + res.status);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center justify-center h-screen">
|
||||||
|
<div className="flex flex-row space-x-2 items-center justify-center">
|
||||||
|
<button
|
||||||
|
className="border border-white rounded-sm p-4 mb-4"
|
||||||
|
onClick={() => onClick('GET')}
|
||||||
|
>
|
||||||
|
Submit GET
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="border border-white rounded-sm p-4 mb-4"
|
||||||
|
onClick={() => onClick('POST')}
|
||||||
|
>
|
||||||
|
Submit POST
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-white">{result}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ describe(`${__dirname.split(path.sep).pop()}`, () => {
|
|||||||
).then(res => res.json());
|
).then(res => res.json());
|
||||||
|
|
||||||
ctx.actionManifest = actionManifest;
|
ctx.actionManifest = actionManifest;
|
||||||
|
|
||||||
Object.assign(ctx, info);
|
Object.assign(ctx, info);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +82,25 @@ describe(`${__dirname.split(path.sep).pop()}`, () => {
|
|||||||
expect(res.headers.get('x-vercel-cache')).toBe('BYPASS');
|
expect(res.headers.get('x-vercel-cache')).toBe('BYPASS');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should bypass the static cache for a multipart request (no action header)', async () => {
|
||||||
|
const path = '/client/static';
|
||||||
|
const actionId = findActionId(path);
|
||||||
|
|
||||||
|
const res = await fetch(`${ctx.deploymentUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: `------WebKitFormBoundaryHcVuFa30AN0QV3uZ\r\nContent-Disposition: form-data; name=\"1_$ACTION_ID_${actionId}\"\r\n\r\n\r\n------WebKitFormBoundaryHcVuFa30AN0QV3uZ\r\nContent-Disposition: form-data; name=\"0\"\r\n\r\n[\"$K1\"]\r\n------WebKitFormBoundaryHcVuFa30AN0QV3uZ--\r\n`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type':
|
||||||
|
'multipart/form-data; boundary=----WebKitFormBoundaryHcVuFa30AN0QV3uZ',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8');
|
||||||
|
expect(res.headers.get('x-vercel-cache')).toBe('BYPASS');
|
||||||
|
expect(res.headers.get('x-matched-path')).toBe(path);
|
||||||
|
});
|
||||||
|
|
||||||
it('should properly invoke the action on a dynamic page', async () => {
|
it('should properly invoke the action on a dynamic page', async () => {
|
||||||
const path = '/client/dynamic/[id]';
|
const path = '/client/dynamic/[id]';
|
||||||
const actionId = findActionId(path);
|
const actionId = findActionId(path);
|
||||||
@@ -98,6 +118,7 @@ describe(`${__dirname.split(path.sep).pop()}`, () => {
|
|||||||
const body = await res.text();
|
const body = await res.text();
|
||||||
expect(body).toContain('1338');
|
expect(body).toContain('1338');
|
||||||
expect(res.headers.get('x-matched-path')).toBe(path);
|
expect(res.headers.get('x-matched-path')).toBe(path);
|
||||||
|
// This isn't a "BYPASS" because the action wasn't part of a static prerender
|
||||||
expect(res.headers.get('x-vercel-cache')).toBe('MISS');
|
expect(res.headers.get('x-vercel-cache')).toBe('MISS');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -145,6 +166,7 @@ describe(`${__dirname.split(path.sep).pop()}`, () => {
|
|||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(res.headers.get('x-matched-path')).toBe(path);
|
expect(res.headers.get('x-matched-path')).toBe(path);
|
||||||
expect(res.headers.get('content-type')).toBe('text/x-component');
|
expect(res.headers.get('content-type')).toBe('text/x-component');
|
||||||
|
// This isn't a "BYPASS" because the action wasn't part of a static prerender
|
||||||
expect(res.headers.get('x-vercel-cache')).toBe('MISS');
|
expect(res.headers.get('x-vercel-cache')).toBe('MISS');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -161,7 +183,9 @@ describe(`${__dirname.split(path.sep).pop()}`, () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(res.headers.get('x-matched-path')).toBe(path);
|
expect(res.headers.get('x-matched-path')).toBe(
|
||||||
|
'/rsc/static/generate-static-params/pre-generated'
|
||||||
|
);
|
||||||
expect(res.headers.get('content-type')).toBe('text/x-component');
|
expect(res.headers.get('content-type')).toBe('text/x-component');
|
||||||
expect(res.headers.get('x-vercel-cache')).toBe('BYPASS');
|
expect(res.headers.get('x-vercel-cache')).toBe('BYPASS');
|
||||||
});
|
});
|
||||||
@@ -178,8 +202,36 @@ describe(`${__dirname.split(path.sep).pop()}`, () => {
|
|||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(res.headers.get('x-matched-path')).toBe(page);
|
expect(res.headers.get('x-matched-path')).toBe(page);
|
||||||
expect(res.headers.get('content-type')).toBe('text/x-component');
|
expect(res.headers.get('content-type')).toBe('text/x-component');
|
||||||
|
// This isn't a "BYPASS" because the action wasn't part of a static prerender
|
||||||
expect(res.headers.get('x-vercel-cache')).toBe('BYPASS');
|
expect(res.headers.get('x-vercel-cache')).toBe('BYPASS');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('pages', () => {
|
||||||
|
it('should not attempt to rewrite the action path for a server action (POST)', async () => {
|
||||||
|
const res = await fetch(`${ctx.deploymentUrl}/api/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type':
|
||||||
|
'multipart/form-data; boundary=----WebKitFormBoundaryHcVuFa30AN0QV3uZ',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.headers.get('x-matched-path')).toBe('/api/test');
|
||||||
|
expect(res.headers.get('x-vercel-cache')).toBe('MISS');
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ message: 'Hello from Next.js!' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not attempt to rewrite the action path for a server action (GET)', async () => {
|
||||||
|
const res = await fetch(`${ctx.deploymentUrl}/api/test`);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.headers.get('x-matched-path')).toBe('/api/test');
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ message: 'Hello from Next.js!' });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
1
packages/next/test/fixtures/00-app-dir-actions/next.config.js
vendored
Normal file
1
packages/next/test/fixtures/00-app-dir-actions/next.config.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
module.exports = {};
|
||||||
3
packages/next/test/fixtures/00-app-dir-actions/pages/api/test.js
vendored
Normal file
3
packages/next/test/fixtures/00-app-dir-actions/pages/api/test.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function handler(req, res) {
|
||||||
|
res.status(200).json({ message: 'Hello from Next.js!' });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user