diff --git a/packages/next/errors.json b/packages/next/errors.json index 5f170ead76c40..d18a60098a0e1 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -659,5 +659,11 @@ "658": "Pass `Infinity` instead of `false` if you want to cache on the server forever without checking with the origin.", "659": "SSG should not return an image cache value", "660": "Rspack support is only available in Next.js canary.", - "661": "Build failed because of %s errors" + "661": "Build failed because of %s errors", + "662": "Failed to find Server Action \"%s\". This request might be from an older or newer deployment.", + "663": "Failed to find Server Action \"%s\". This request might be from an older or newer deployment.%s", + "664": "Expected no-js actions to return from handler early.", + "665": "Expected actionId to be defined for a fetch action", + "666": "Action handler not found in action module", + "667": "This function cannot be used in the edge runtime" } diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index ac763262807ad..257b6e7edc8ab 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -48,6 +48,9 @@ import { RedirectStatusCode } from '../../client/components/redirect-status-code import { synchronizeMutableCookies } from '../async-storage/request-store' import type { TemporaryReferenceSet } from 'react-server-dom-webpack/server.edge' import { workUnitAsyncStorage } from '../app-render/work-unit-async-storage.external' +import { InvariantError } from '../../shared/lib/invariant-error' +import type { NodeNextRequest } from '../base-http/node' +import type { Readable } from 'node:stream' function formDataFromSearchQueryString(query: string) { const searchParams = new URLSearchParams(query) @@ -448,6 +451,26 @@ type ServerActionsConfig = { allowedOrigins?: string[] } +type HandleActionResult = + | { + /** We either didn't find the actionId, or we did but it threw notFound(). */ + type: 'not-found' + } + | { + /** The request turned out to not be an action. */ + type: 'not-an-action' + } + | { + /** We decoded a FormState, but haven't rendered anything yet. */ + type: 'needs-render' + formState: any + } + | { + /** A finished result. */ + type: 'done' + result: RenderResult + } + export async function handleAction({ req, res, @@ -468,17 +491,7 @@ export async function handleAction({ requestStore: RequestStore serverActions?: ServerActionsConfig ctx: AppRenderContext -}): Promise< - | undefined - | { - type: 'not-found' - } - | { - type: 'done' - result: RenderResult | undefined - formState?: any - } -> { +}): Promise { const contentType = req.headers['content-type'] const { serverActionsManifest, page } = ctx.renderOpts @@ -487,12 +500,14 @@ export async function handleAction({ isURLEncodedAction, isMultipartAction, isFetchAction, - isServerAction, + isPotentialServerAction, } = getServerActionRequestMetadata(req) - // If it's not a Server Action, skip handling. - if (!isServerAction) { - return + // If it can't be a Server Action, skip handling. + // Note that this can be a false positive -- any multipart/urlencoded POST can get us here, + // But won't know if it's a no-js action or not until we call `decodeAction` below. + if (!isPotentialServerAction) { + return { type: 'not-an-action' } } if (workStore.isStaticGeneration) { @@ -606,13 +621,8 @@ export async function handleAction({ 'no-cache, no-store, max-age=0, must-revalidate' ) - let boundActionArguments: unknown[] = [] - const { actionAsyncStorage } = ComponentMod - let actionResult: RenderResult | undefined - let formState: any | undefined - let actionModId: string | undefined const actionWasForwarded = Boolean(req.headers['x-action-forwarded']) if (actionId) { @@ -639,313 +649,296 @@ export async function handleAction({ } } - try { - await actionAsyncStorage.run({ isAction: true }, async () => { - if ( - // The type check here ensures that `req` is correctly typed, and the - // environment variable check provides dead code elimination. - process.env.NEXT_RUNTIME === 'edge' && - isWebNextRequest(req) - ) { - if (!req.body) { - throw new Error('invariant: Missing request body.') - } + const handleNonFetchAction = async (): Promise => { + // This might be a no-js action, but it might also be a random POST request. - // TODO: add body limit + if (!isMultipartAction) { + // Not a fetch action and not multipart. + // It can't be an action request. + return { type: 'not-an-action' } + } - // Use react-server-dom-webpack/server.edge - const { - createTemporaryReferenceSet, - decodeReply, - decodeAction, - decodeFormState, - } = ComponentMod + if ( + // The type check here ensures that `req` is correctly typed, and the + // environment variable check provides dead code elimination. + process.env.NEXT_RUNTIME === 'edge' && + isWebNextRequest(req) + ) { + // Use react-server-dom-webpack/server.edge + const { decodeAction, decodeFormState } = ComponentMod - temporaryReferences = createTemporaryReferenceSet() + // TODO-APP: Add streaming support + const formData = await req.request.formData() - if (isMultipartAction) { - // TODO-APP: Add streaming support - const formData = await req.request.formData() - if (isFetchAction) { - boundActionArguments = await decodeReply( - formData, - serverModuleMap, - { temporaryReferences } - ) - } else { - const action = await decodeAction(formData, serverModuleMap) - if (typeof action === 'function') { - // Only warn if it's a server action, otherwise skip for other post requests - warnBadServerActionRequest() - - const actionReturnedState = await workUnitAsyncStorage.run( - requestStore, - action - ) + const action = await decodeAction(formData, serverModuleMap) + if (typeof action !== 'function') { + // We couldn't decode an action, so this is a non-action POST request. + return { type: 'not-an-action' } + } - formState = await decodeFormState( - actionReturnedState, - formData, - serverModuleMap - ) + // A no-js action. - requestStore.phase = 'render' - } + // Only warn if it's a server action, otherwise skip for other post requests + warnBadServerActionRequest() - // Skip the fetch path - return - } - } else { - try { - actionModId = getActionModIdOrError(actionId, serverModuleMap) - } catch (err) { - if (actionId !== null) { - console.error(err) - } - return { - type: 'not-found', - } - } + // execute the action and decode the form state. + const actionReturnedState = await workUnitAsyncStorage.run( + requestStore, + action + ) + const formState = await decodeFormState( + actionReturnedState, + formData, + serverModuleMap + ) - const chunks: Buffer[] = [] - const reader = req.body.getReader() - while (true) { - const { done, value } = await reader.read() - if (done) { - break - } + requestStore.phase = 'render' + return { + type: 'needs-render', + formState, + } + } else if ( + // The type check here ensures that `req` is correctly typed, and the + // environment variable check provides dead code elimination. + process.env.NEXT_RUNTIME !== 'edge' && + isNodeNextRequest(req) + ) { + // Use react-server-dom-webpack/server.node + const { decodeAction, decodeFormState } = require( + `./react-server.node` + ) as typeof import('./react-server.node') + + const bodySizeLimit = resolveBodySizeLimitNode(serverActions) + const body = getSizeLimitedRequestBodyNode(req, bodySizeLimit) + + // React doesn't yet publish a busboy version of decodeAction + // so we polyfill the parsing of FormData. + const formData = await parseBodyAsFormDataNode(body, contentType) + const action = await decodeAction(formData, serverModuleMap) + + if (typeof action !== 'function') { + // We couldn't decode an action, so this is a non-action POST request. + return { type: 'not-an-action' } + } - chunks.push(value) - } + // A no-js action. - const actionData = Buffer.concat(chunks).toString('utf-8') + // Only warn if it's a server action, otherwise skip for other post requests + warnBadServerActionRequest() - if (isURLEncodedAction) { - const formData = formDataFromSearchQueryString(actionData) - boundActionArguments = await decodeReply( - formData, - serverModuleMap, - { temporaryReferences } - ) - } else { - boundActionArguments = await decodeReply( - actionData, - serverModuleMap, - { temporaryReferences } - ) - } - } - } else if ( - // The type check here ensures that `req` is correctly typed, and the - // environment variable check provides dead code elimination. - process.env.NEXT_RUNTIME !== 'edge' && - isNodeNextRequest(req) - ) { - // Use react-server-dom-webpack/server.node which supports streaming - const { - createTemporaryReferenceSet, - decodeReply, - decodeReplyFromBusboy, - decodeAction, - decodeFormState, - } = require( - `./react-server.node` - ) as typeof import('./react-server.node') + // execute the action and decode the form state. + const actionReturnedState = await workUnitAsyncStorage.run( + requestStore, + action + ) + const formState = await decodeFormState( + actionReturnedState, + formData, + serverModuleMap + ) - temporaryReferences = createTemporaryReferenceSet() + requestStore.phase = 'render' + return { + type: 'needs-render', + formState, + } + } else { + throw new Error('Invariant: Unknown request type.') + } + } - const { Transform } = - require('node:stream') as typeof import('node:stream') - - const defaultBodySizeLimit = '1 MB' - const bodySizeLimit = - serverActions?.bodySizeLimit ?? defaultBodySizeLimit - const bodySizeLimitBytes = - bodySizeLimit !== defaultBodySizeLimit - ? ( - require('next/dist/compiled/bytes') as typeof import('bytes') - ).parse(bodySizeLimit) - : 1024 * 1024 // 1 MB - - let size = 0 - const body = req.body.pipe( - new Transform({ - transform(chunk, encoding, callback) { - size += Buffer.byteLength(chunk, encoding) - if (size > bodySizeLimitBytes) { - const { ApiError } = require('../api-utils') - - callback( - new ApiError( - 413, - `Body exceeded ${bodySizeLimit} limit. - To configure the body size limit for Server Actions, see: https://nextjs.org/docs/app/api-reference/next-config-js/serverActions#bodysizelimit` - ) - ) - return - } + const parseFetchActionArguments = async ( + // eslint-disable-next-line @typescript-eslint/no-shadow + temporaryReferences: TemporaryReferenceSet + ): Promise => { + if ( + // The type check here ensures that `req` is correctly typed, and the + // environment variable check provides dead code elimination. + process.env.NEXT_RUNTIME === 'edge' && + isWebNextRequest(req) + ) { + if (!req.body) { + throw new Error('invariant: Missing request body.') + } - callback(null, chunk) - }, - }) - ) + // TODO: add body limit - if (isMultipartAction) { - if (isFetchAction) { - const busboy = (require('busboy') as typeof import('busboy'))({ - defParamCharset: 'utf8', - headers: req.headers, - limits: { fieldSize: bodySizeLimitBytes }, - }) + // Use react-server-dom-webpack/server.edge + const { decodeReply } = ComponentMod - body.pipe(busboy) + if (isMultipartAction) { + // TODO-APP: Add streaming support + const formData = await req.request.formData() + // A fetch action with a multipart body. + return await decodeReply(formData, serverModuleMap, { + temporaryReferences, + }) + } else { + // A fetch action with a non-multipart body. + + const chunks: Buffer[] = [] + const reader = req.body.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + chunks.push(value) + } + const actionData = Buffer.concat(chunks).toString('utf-8') - boundActionArguments = await decodeReplyFromBusboy( - busboy, - serverModuleMap, - { temporaryReferences } - ) - } else { - // React doesn't yet publish a busboy version of decodeAction - // so we polyfill the parsing of FormData. - const fakeRequest = new Request('http://localhost', { - method: 'POST', - // @ts-expect-error - headers: { 'Content-Type': contentType }, - body: new ReadableStream({ - start: (controller) => { - body.on('data', (chunk) => { - controller.enqueue(new Uint8Array(chunk)) - }) - body.on('end', () => { - controller.close() - }) - body.on('error', (err) => { - controller.error(err) - }) - }, - }), - duplex: 'half', - }) - const formData = await fakeRequest.formData() - const action = await decodeAction(formData, serverModuleMap) - if (typeof action === 'function') { - // Only warn if it's a server action, otherwise skip for other post requests - warnBadServerActionRequest() - - const actionReturnedState = await workUnitAsyncStorage.run( - requestStore, - action - ) + if (isURLEncodedAction) { + const formData = formDataFromSearchQueryString(actionData) + return await decodeReply(formData, serverModuleMap, { + temporaryReferences, + }) + } else { + return await decodeReply(actionData, serverModuleMap, { + temporaryReferences, + }) + } + } + } else if ( + // The type check here ensures that `req` is correctly typed, and the + // environment variable check provides dead code elimination. + process.env.NEXT_RUNTIME !== 'edge' && + isNodeNextRequest(req) + ) { + // Use react-server-dom-webpack/server.node which supports streaming + const { decodeReply, decodeReplyFromBusboy } = require( + `./react-server.node` + ) as typeof import('./react-server.node') - formState = await decodeFormState( - actionReturnedState, - formData, - serverModuleMap - ) + const bodySizeLimit = resolveBodySizeLimitNode(serverActions) + const body = getSizeLimitedRequestBodyNode(req, bodySizeLimit) - requestStore.phase = 'render' - } + if (isMultipartAction) { + // A fetch action with a multipart body. - // Skip the fetch path - return - } - } else { - try { - actionModId = getActionModIdOrError(actionId, serverModuleMap) - } catch (err) { - if (actionId !== null) { - console.error(err) - } - return { - type: 'not-found', - } - } + const busboy = (require('busboy') as typeof import('busboy'))({ + defParamCharset: 'utf8', + headers: req.headers, + limits: { fieldSize: bodySizeLimit.byteLength }, + }) - const chunks: Buffer[] = [] - for await (const chunk of req.body) { - chunks.push(Buffer.from(chunk)) - } + body.pipe(busboy) - const actionData = Buffer.concat(chunks).toString('utf-8') + return await decodeReplyFromBusboy(busboy, serverModuleMap, { + temporaryReferences, + }) + } else { + // A fetch action with a non-multipart body. - if (isURLEncodedAction) { - const formData = formDataFromSearchQueryString(actionData) - boundActionArguments = await decodeReply( - formData, - serverModuleMap, - { temporaryReferences } - ) - } else { - boundActionArguments = await decodeReply( - actionData, - serverModuleMap, - { temporaryReferences } - ) - } + const chunks: Buffer[] = [] + for await (const chunk of req.body) { + chunks.push(Buffer.from(chunk)) } - } else { - throw new Error('Invariant: Unknown request type.') - } + const actionData = Buffer.concat(chunks).toString('utf-8') - // actions.js - // app/page.js - // action worker1 - // appRender1 + if (isURLEncodedAction) { + const formData = formDataFromSearchQueryString(actionData) + return await decodeReply(formData, serverModuleMap, { + temporaryReferences, + }) + } else { + return await decodeReply(actionData, serverModuleMap, { + temporaryReferences, + }) + } + } + } else { + throw new Error('Invariant: Unknown request type.') + } + } - // app/foo/page.js - // action worker2 - // appRender + try { + return await actionAsyncStorage.run( + { isAction: true }, + async (): Promise => { + if (!isFetchAction) { + return await handleNonFetchAction() + } - // / -> fire action -> POST / -> appRender1 -> modId for the action file - // /foo -> fire action -> POST /foo -> appRender2 -> modId for the action file + // This is likely a fetch action (initiated by the client router). - try { - actionModId = - actionModId ?? getActionModIdOrError(actionId, serverModuleMap) - } catch (err) { - if (actionId !== null) { + // Validate the actionId and get the module it's from. + // We want to do this first to avoid doing unnecessary work for requests we can't handle. + let actionModId: string + try { + actionModId = getActionModIdOrError(actionId, serverModuleMap) + } catch (err) { console.error(err) + return { type: 'not-found' } } - return { - type: 'not-found', + + // The temporary reference set is used for parsing the arguments and in the catch handler, + // so we want to create it before any of the decoding logic has a chance to throw. + const createTemporaryReferenceSet = + process.env.NEXT_RUNTIME === 'edge' + ? // Use react-server-dom-webpack/server.edge + ComponentMod.createTemporaryReferenceSet + : // Use react-server-dom-webpack/server.node + ( + require( + `./react-server.node` + ) as typeof import('./react-server.node') + ).createTemporaryReferenceSet + temporaryReferences = createTemporaryReferenceSet() + + // Parse the action arguments. + const boundActionArguments = + await parseFetchActionArguments(temporaryReferences) + + // Get the action function. + + // actions.js + // app/page.js + // action worker1 + // appRender1 + + // app/foo/page.js + // action worker2 + // appRender + + // / -> fire action -> POST / -> appRender1 -> modId for the action file + // /foo -> fire action -> POST /foo -> appRender2 -> modId for the action file + + const actionMod = (await ComponentMod.__next_app__.require( + actionModId + )) as Record Promise> + const actionHandler = actionMod[actionId] + if (!actionHandler) { + throw new InvariantError('Action handler not found in action module') } - } - const actionMod = (await ComponentMod.__next_app__.require( - actionModId - )) as Record Promise> - const actionHandler = - actionMod[ - // `actionId` must exist if we got here, as otherwise we would have thrown an error above - actionId! - ] - - const returnVal = await workUnitAsyncStorage.run(requestStore, () => - actionHandler.apply(null, boundActionArguments) - ) + // Finally, we have everything, and can execute the action. + const returnVal = await workUnitAsyncStorage.run(requestStore, () => + actionHandler.apply(null, boundActionArguments) + ) - // For form actions, we need to continue rendering the page. - if (isFetchAction) { await addRevalidationHeader(res, { workStore, requestStore, }) - actionResult = await finalizeAndGenerateFlight(req, ctx, requestStore, { - actionResult: Promise.resolve(returnVal), - // if the page was not revalidated, or if the action was forwarded from another worker, we can skip the rendering the flight tree - skipFlight: !workStore.pathWasRevalidated || actionWasForwarded, - temporaryReferences, - }) - } - }) + const actionResult = await finalizeAndGenerateFlight( + req, + ctx, + requestStore, + { + actionResult: Promise.resolve(returnVal), + // if the page was not revalidated, or if the action was forwarded from another worker, we can skip the rendering the flight tree + skipFlight: !workStore.pathWasRevalidated || actionWasForwarded, + temporaryReferences, + } + ) - return { - type: 'done', - result: actionResult, - formState, - } + return { + type: 'done', + result: actionResult, + } + } + ) } catch (err) { if (isRedirectError(err)) { const redirectUrl = getURLFromRedirectError(err) @@ -980,7 +973,9 @@ export async function handleAction({ type: 'done', result: RenderResult.fromStatic(''), } - } else if (isHTTPAccessFallbackError(err)) { + } + + if (isHTTPAccessFallbackError(err)) { res.statusCode = getAccessFallbackHTTPStatus(err) await addRevalidationHeader(res, { @@ -1013,6 +1008,8 @@ export async function handleAction({ } } + // Some other error, thrown from the action handler, or something internal. + if (isFetchAction) { res.statusCode = 500 await Promise.all([ @@ -1045,6 +1042,7 @@ export async function handleAction({ } } + // TODO: why throw for no-js actions? throw err } } @@ -1058,26 +1056,120 @@ function getActionModIdOrError( actionId: string | null, serverModuleMap: ServerModuleMap ): string { + // if we're missing the action ID header, we can't do any further processing + if (!actionId) { + throw new Error("Invariant: Missing 'next-action' header.") + } + + let actionModId: string | undefined try { - // if we're missing the action ID header, we can't do any further processing - if (!actionId) { - throw new Error("Invariant: Missing 'next-action' header.") - } + // `serverModuleMap` is a proxy (see: `createServerModuleMap`) which runs some lookup code, + // so this can throw despite guarding the accesses with `?.` + actionModId = serverModuleMap?.[actionId]?.id + } catch (err) { + throw new Error( + `Failed to find Server Action "${actionId}". This request might be from an older or newer deployment.${ + err instanceof Error ? ` Original error: ${err.message}` : '' + }` + ) + } - const actionModId = serverModuleMap?.[actionId]?.id + if (actionModId === undefined) { + throw new Error( + `Failed to find Server Action "${actionId}". This request might be from an older or newer deployment.` + ) + } - if (!actionModId) { - throw new Error( - "Invariant: Couldn't find action module ID from module map." - ) + return actionModId +} + +async function parseBodyAsFormDataNode( + body: Readable, + contentType: string | undefined +) { + if (process.env.NEXT_RUNTIME === 'edge') { + throw new InvariantError('This function cannot be used in the edge runtime') + } else { + const fakeRequest = new Request('http://localhost', { + method: 'POST', + // @ts-expect-error + headers: { 'Content-Type': contentType }, + body: new ReadableStream({ + start: (controller) => { + body.on('data', (chunk) => { + controller.enqueue(new Uint8Array(chunk)) + }) + body.on('end', () => { + controller.close() + }) + body.on('error', (err) => { + controller.error(err) + }) + }, + }), + duplex: 'half', + }) + return await fakeRequest.formData() + } +} + +type ResolvedBodySizeLimit = { + byteLength: number + humanReadable: SizeLimit +} + +function resolveBodySizeLimitNode( + serverActions: ServerActionsConfig | undefined +): ResolvedBodySizeLimit { + if (process.env.NEXT_RUNTIME === 'edge') { + throw new InvariantError('This function cannot be used in the edge runtime') + } else { + const defaultBodySizeLimit: SizeLimit = '1MB' + const bodySizeLimit = serverActions?.bodySizeLimit ?? defaultBodySizeLimit + const byteLength = + bodySizeLimit !== defaultBodySizeLimit + ? (require('next/dist/compiled/bytes') as typeof import('bytes')).parse( + bodySizeLimit + ) + : 1024 * 1024 // 1 MB + return { + byteLength, + humanReadable: bodySizeLimit, } + } +} - return actionModId - } catch (err) { - throw new Error( - `Failed to find Server Action "${actionId}". This request might be from an older or newer deployment. ${ - err instanceof Error ? `Original error: ${err.message}` : '' - }\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action` +function getSizeLimitedRequestBodyNode( + req: NodeNextRequest, + sizeLimit: ResolvedBodySizeLimit +): Readable { + if (process.env.NEXT_RUNTIME === 'edge') { + throw new InvariantError('This function cannot be used in the edge runtime') + } else { + const { Transform } = require('node:stream') as typeof import('node:stream') + + let size = 0 + const body = req.body.pipe( + new Transform({ + transform(chunk, encoding, callback) { + size += Buffer.byteLength(chunk, encoding) + if (size > sizeLimit.byteLength) { + const { ApiError } = require('../api-utils') + + callback( + new ApiError( + 413, + `Body exceeded ${sizeLimit.humanReadable} limit. + To configure the body size limit for Server Actions, see: https://nextjs.org/docs/app/api-reference/next-config-js/serverActions#bodysizelimit` + ) + ) + return + } + + callback(null, chunk) + }, + }) ) + return body } } diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 3a5ae892c82d7..dd861ed48d71f 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -146,7 +146,7 @@ import { parseRelativeUrl } from '../../shared/lib/router/utils/parse-relative-u import AppRouter from '../../client/components/app-router' import type { ServerComponentsHmrCache } from '../response-cache' import type { RequestErrorContext } from '../instrumentation/types' -import { getServerActionRequestMetadata } from '../lib/server-action-request-meta' +import { getIsPotentialServerAction } from '../lib/server-action-request-meta' import { createInitialRouterState } from '../../client/components/router-reducer/create-initial-router-state' import { createMutableActionQueue } from '../../shared/lib/router/action-queue' import { getRevalidateReason } from '../instrumentation/utils' @@ -1304,7 +1304,7 @@ async function renderToHTMLOrFlightImpl( fallbackRouteParams ) - const isActionRequest = getServerActionRequestMetadata(req).isServerAction + const isPotentialActionRequest = getIsPotentialServerAction(req) const ctx: AppRenderContext = { componentMod: ComponentMod, @@ -1315,7 +1315,7 @@ async function renderToHTMLOrFlightImpl( getDynamicParamFromSegment, query, isPrefetch: isPrefetchRequest, - isAction: isActionRequest, + isAction: isPotentialActionRequest, requestTimestamp, appUsingSizeAdjustment, flightRouterState, @@ -1505,7 +1505,7 @@ async function renderToHTMLOrFlightImpl( ) let formState: null | any = null - if (isActionRequest) { + if (isPotentialActionRequest) { // For action requests, we handle them differently with a special render result. const actionRequestResult = await handleAction({ req, @@ -1519,30 +1519,32 @@ async function renderToHTMLOrFlightImpl( ctx, }) - if (actionRequestResult) { - if (actionRequestResult.type === 'not-found') { - const notFoundLoaderTree = createNotFoundLoaderTree(loaderTree) - res.statusCode = 404 - const stream = await renderToStreamWithTracing( - requestStore, - req, - res, - ctx, - workStore, - notFoundLoaderTree, - formState, - postponedState - ) + if (actionRequestResult.type === 'not-found') { + const notFoundLoaderTree = createNotFoundLoaderTree(loaderTree) + res.statusCode = 404 + const stream = await renderToStreamWithTracing( + requestStore, + req, + res, + ctx, + workStore, + notFoundLoaderTree, + formState, + postponedState + ) - return new RenderResult(stream, { metadata }) - } else if (actionRequestResult.type === 'done') { - if (actionRequestResult.result) { - actionRequestResult.result.assignMetadata(metadata) - return actionRequestResult.result - } else if (actionRequestResult.formState) { - formState = actionRequestResult.formState - } - } + return new RenderResult(stream, { metadata }) + } else if (actionRequestResult.type === 'done') { + // We ran the action, and have a flight result. Nothing more to do here. + actionRequestResult.result.assignMetadata(metadata) + return actionRequestResult.result + } else if (actionRequestResult.type === 'needs-render') { + // We ran the action, but haven't rendered anything yet, so continue below. + formState = actionRequestResult.formState + } else if (actionRequestResult.type === 'not-an-action') { + // This is likely a POST request targetting a page. We partially support this, so continue rendering. + } else { + actionRequestResult satisfies never } } diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 1cbe3f3ee99bc..5857ac870c819 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -152,7 +152,7 @@ import { } from './route-modules/checks' import { PrefetchRSCPathnameNormalizer } from './normalizers/request/prefetch-rsc' import { NextDataPathnameNormalizer } from './normalizers/request/next-data' -import { getIsServerAction } from './lib/server-action-request-meta' +import { getIsPotentialServerAction } from './lib/server-action-request-meta' import { isInterceptionRouteAppPath } from '../shared/lib/router/utils/interception-routes' import { toRoute } from './lib/to-route' import type { DeepReadonly } from '../shared/lib/deep-readonly' @@ -2012,7 +2012,7 @@ export default abstract class Server< const hasServerProps = !!components.getServerSideProps let hasGetStaticPaths = !!components.getStaticPaths - const isServerAction = getIsServerAction(req) + const isPotentialServerAction = getIsPotentialServerAction(req) const hasGetInitialProps = !!components.Component?.getInitialProps let isSSG = !!components.getStaticProps @@ -2235,7 +2235,7 @@ export default abstract class Server< if ( // Server actions can use non-GET/HEAD methods. - !isServerAction && + !isPotentialServerAction && // Resume can use non-GET/HEAD methods. !minimalPostponed && !is404Page && @@ -2375,7 +2375,7 @@ export default abstract class Server< !isPreviewMode && isSSG && !opts.supportsDynamicResponse && - !isServerAction && + !isPotentialServerAction && !minimalPostponed && !isDynamicRSCRequest ) { @@ -2521,7 +2521,7 @@ export default abstract class Server< shouldWaitOnAllReady, isOnDemandRevalidate, isDraftMode: isPreviewMode, - isServerAction, + isServerAction: isPotentialServerAction, postponed, waitUntil: this.getWaitUntil(), onClose: res.onClose.bind(res), @@ -2761,7 +2761,7 @@ export default abstract class Server< this.nextConfig.experimental.dynamicIO && this.renderOpts.dev && !isPrefetchRSCRequest && - !isServerAction + !isPotentialServerAction ) { const warmup = await module.warmup(req, res, context) diff --git a/packages/next/src/server/lib/server-action-request-meta.ts b/packages/next/src/server/lib/server-action-request-meta.ts index 1e7ab49371b64..f525e0eedbf63 100644 --- a/packages/next/src/server/lib/server-action-request-meta.ts +++ b/packages/next/src/server/lib/server-action-request-meta.ts @@ -3,15 +3,25 @@ import type { BaseNextRequest } from '../base-http' import type { NextRequest } from '../web/exports' import { ACTION_HEADER } from '../../client/components/app-router-headers' +export type ServerActionRequestMetadata = + | { + isFetchAction: true + actionId: string + isURLEncodedAction: boolean + isMultipartAction: boolean + isPotentialServerAction: true + } + | { + isFetchAction: false + actionId: null + isURLEncodedAction: boolean + isMultipartAction: boolean + isPotentialServerAction: boolean + } + export function getServerActionRequestMetadata( req: IncomingMessage | BaseNextRequest | NextRequest -): { - actionId: string | null - isURLEncodedAction: boolean - isMultipartAction: boolean - isFetchAction: boolean - isServerAction: boolean -} { +): ServerActionRequestMetadata { let actionId: string | null let contentType: string | null @@ -29,27 +39,32 @@ export function getServerActionRequestMetadata( const isMultipartAction = Boolean( req.method === 'POST' && contentType?.startsWith('multipart/form-data') ) - const isFetchAction = Boolean( - actionId !== undefined && - typeof actionId === 'string' && - req.method === 'POST' - ) + if (actionId !== null && req.method === 'POST') { + return { + isFetchAction: true, + actionId, + isMultipartAction, + isURLEncodedAction, + isPotentialServerAction: true, + } + } - const isServerAction = Boolean( - isFetchAction || isURLEncodedAction || isMultipartAction + const isPotentialServerAction = Boolean( + isURLEncodedAction || isMultipartAction ) return { - actionId, + isFetchAction: false, + // it may technically be non-null, but there's no use for it outside a fetch action. + actionId: null, isURLEncodedAction, isMultipartAction, - isFetchAction, - isServerAction, + isPotentialServerAction, } } -export function getIsServerAction( +export function getIsPotentialServerAction( req: IncomingMessage | BaseNextRequest | NextRequest ): boolean { - return getServerActionRequestMetadata(req).isServerAction + return getServerActionRequestMetadata(req).isPotentialServerAction } diff --git a/packages/next/src/server/route-modules/app-route/module.ts b/packages/next/src/server/route-modules/app-route/module.ts index 14c3f439d48e5..645e2e2285a56 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -51,7 +51,7 @@ import { type ActionStore, } from '../../app-render/action-async-storage.external' import * as sharedModules from './shared-modules' -import { getIsServerAction } from '../../lib/server-action-request-meta' +import { getIsPotentialServerAction } from '../../lib/server-action-request-meta' import { RequestCookies } from 'next/dist/compiled/@edge-runtime/cookies' import { cleanURL } from './helpers/clean-url' import { StaticGenBailoutError } from '../../../client/components/static-generation-bailout' @@ -662,7 +662,7 @@ export class AppRouteRouteModule extends RouteModule< const actionStore: ActionStore = { isAppRoute: true, - isAction: getIsServerAction(req), + isAction: getIsPotentialServerAction(req), } const implicitTags = getImplicitTags( diff --git a/test/e2e/app-dir/actions/app-action.test.ts b/test/e2e/app-dir/actions/app-action.test.ts index 9e507b43c2ba2..269dcaaeb39c1 100644 --- a/test/e2e/app-dir/actions/app-action.test.ts +++ b/test/e2e/app-dir/actions/app-action.test.ts @@ -1760,7 +1760,7 @@ describe('app-dir action handling', () => { }) it.each(['307', '308'])( - `redirects properly when server action handler redirects with a %s status code`, + `redirects properly when route handler redirects with a %s status code`, async (statusCode) => { const postRequests = [] const responseCodes = [] @@ -1791,7 +1791,7 @@ describe('app-dir action handling', () => { }) expect(await browser.elementById('redirect-page')).toBeTruthy() - // since a 307/308 status code follows the redirect, the POST request should be made to both the action handler and the redirect target + // since a 307/308 status code follows the redirect, the POST request should be made to both the route handler and the redirect target expect(postRequests).toEqual([ `/redirects/api-redirect-${statusCode}`, `/redirects?success=true`,