UNPKG

next

Version:

The React Framework

914 lines • 50.3 kB
import { RSC_HEADER, RSC_CONTENT_TYPE_HEADER, NEXT_ROUTER_STATE_TREE_HEADER, ACTION_HEADER, NEXT_ACTION_NOT_FOUND_HEADER, NEXT_ROUTER_PREFETCH_HEADER, NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, NEXT_URL, NEXT_ACTION_REVALIDATED_HEADER } from '../../client/components/app-router-headers'; import { getAccessFallbackHTTPStatus, isHTTPAccessFallbackError } from '../../client/components/http-access-fallback/http-access-fallback'; import { getRedirectTypeFromError, getURLFromRedirectError } from '../../client/components/redirect'; import { isRedirectError } from '../../client/components/redirect-error'; import RenderResult from '../render-result'; import { FlightRenderResult } from './flight-render-result'; import { filterReqHeaders, actionsForbiddenHeaders } from '../lib/server-ipc/utils'; import { getModifiedCookieValues } from '../web/spec-extension/adapters/request-cookies'; import { JSON_CONTENT_TYPE_HEADER, NEXT_CACHE_REVALIDATED_TAGS_HEADER, NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER } from '../../lib/constants'; import { getServerActionRequestMetadata } from '../lib/server-action-request-meta'; import { isCsrfOriginAllowed } from './csrf-protection'; import { warn } from '../../build/output/log'; import { RequestCookies, ResponseCookies } from '../web/spec-extension/cookies'; import { HeadersAdapter } from '../web/spec-extension/adapters/headers'; import { fromNodeOutgoingHttpHeaders } from '../web/utils'; import { selectWorkerForForwarding, getServerActionsManifest, getServerModuleMap } from './manifests-singleton'; import { isNodeNextRequest, isWebNextRequest } from '../base-http/helpers'; import { RedirectStatusCode } from '../../client/components/redirect-status-code'; import { synchronizeMutableCookies } from '../async-storage/request-store'; import { workUnitAsyncStorage } from '../app-render/work-unit-async-storage.external'; import { InvariantError } from '../../shared/lib/invariant-error'; import { executeRevalidates } from '../revalidation-utils'; import { getRequestMeta } from '../request-meta'; import { setCacheBustingSearchParam } from '../../client/components/router-reducer/set-cache-busting-search-param'; import { ActionDidNotRevalidate, ActionDidRevalidateStaticAndDynamic } from '../../shared/lib/action-revalidation-kind'; /** * Checks if the app has any server actions defined in any runtime. */ function hasServerActions() { const serverActionsManifest = getServerActionsManifest(); return Object.keys(serverActionsManifest.node).length > 0 || Object.keys(serverActionsManifest.edge).length > 0; } function nodeHeadersToRecord(headers) { const record = {}; for (const [key, value] of Object.entries(headers)){ if (value !== undefined) { record[key] = Array.isArray(value) ? value.join(', ') : `${value}`; } } return record; } function getForwardedHeaders(req, res) { // Get request headers and cookies const requestHeaders = req.headers; const requestCookies = new RequestCookies(HeadersAdapter.from(requestHeaders)); // Get response headers and cookies const responseHeaders = res.getHeaders(); const responseCookies = new ResponseCookies(fromNodeOutgoingHttpHeaders(responseHeaders)); // Merge request and response headers const mergedHeaders = filterReqHeaders({ ...nodeHeadersToRecord(requestHeaders), ...nodeHeadersToRecord(responseHeaders) }, actionsForbiddenHeaders); // Merge cookies into requestCookies, so responseCookies always take precedence // and overwrite/delete those from requestCookies. responseCookies.getAll().forEach((cookie)=>{ if (typeof cookie.value === 'undefined') { requestCookies.delete(cookie.name); } else { requestCookies.set(cookie); } }); // Update the 'cookie' header with the merged cookies mergedHeaders['cookie'] = requestCookies.toString(); // Remove headers that should not be forwarded delete mergedHeaders['transfer-encoding']; return new Headers(mergedHeaders); } function addRevalidationHeader(res, { workStore, requestStore }) { var _workStore_pendingRevalidatedTags; // If a tag was revalidated, the client router needs to invalidate all the // client router cache as they may be stale. And if a path was revalidated, the // client needs to invalidate all subtrees below that path. // TODO: Currently we don't send the specific tags or paths to the client, // we just send a flag indicating that all the static data on the client // should be invalidated. In the future, this will likely be a Bloom filter // or bitmask of some kind. // TODO-APP: Currently the prefetch cache doesn't have subtree information, // so we need to invalidate the entire cache if a path was revalidated. // TODO-APP: Currently paths are treated as tags, so the second element of the tuple // is always empty. const isTagRevalidated = ((_workStore_pendingRevalidatedTags = workStore.pendingRevalidatedTags) == null ? void 0 : _workStore_pendingRevalidatedTags.length) ? 1 : 0; const isCookieRevalidated = getModifiedCookieValues(requestStore.mutableCookies).length ? 1 : 0; // First check if a tag, cookie, or path was revalidated. if (isTagRevalidated || isCookieRevalidated) { res.setHeader(NEXT_ACTION_REVALIDATED_HEADER, JSON.stringify(ActionDidRevalidateStaticAndDynamic)); } else if (// Check for refresh() actions. This will invalidate only the dynamic data. workStore.pathWasRevalidated !== undefined && workStore.pathWasRevalidated !== ActionDidNotRevalidate) { res.setHeader(NEXT_ACTION_REVALIDATED_HEADER, JSON.stringify(workStore.pathWasRevalidated)); } } /** * Forwards a server action request to a separate worker. Used when the requested action is not available in the current worker. */ async function createForwardedActionResponse(req, res, host, workerPathname, basePath) { var _getRequestMeta; if (!host) { throw Object.defineProperty(new Error('Invariant: Missing `host` header from a forwarded Server Actions request.'), "__NEXT_ERROR_CODE", { value: "E226", enumerable: false, configurable: true }); } const forwardedHeaders = getForwardedHeaders(req, res); // indicate that this action request was forwarded from another worker // we use this to skip rendering the flight tree so that we don't update the UI // with the response from the forwarded worker forwardedHeaders.set('x-action-forwarded', '1'); const proto = ((_getRequestMeta = getRequestMeta(req, 'initProtocol')) == null ? void 0 : _getRequestMeta.replace(/:+$/, '')) || 'https'; // For standalone or the serverful mode, use the internal origin directly // other than the host headers from the request. const origin = process.env.__NEXT_PRIVATE_ORIGIN || `${proto}://${host.value}`; const fetchUrl = new URL(`${origin}${basePath}${workerPathname}`); try { var _response_headers_get; let body; 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 Object.defineProperty(new Error('Invariant: missing request body.'), "__NEXT_ERROR_CODE", { value: "E333", enumerable: false, configurable: true }); } body = req.body; } 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)) { body = req.stream(); } else { throw Object.defineProperty(new Error('Invariant: Unknown request type.'), "__NEXT_ERROR_CODE", { value: "E114", enumerable: false, configurable: true }); } // Forward the request to the new worker const response = await fetch(fetchUrl, { method: 'POST', body, duplex: 'half', headers: forwardedHeaders, redirect: 'manual', next: { // @ts-ignore internal: 1 } }); if ((_response_headers_get = response.headers.get('content-type')) == null ? void 0 : _response_headers_get.startsWith(RSC_CONTENT_TYPE_HEADER)) { // copy the headers from the redirect response to the response we're sending for (const [key, value] of response.headers){ if (!actionsForbiddenHeaders.includes(key)) { res.setHeader(key, value); } } return new FlightRenderResult(response.body); } else { var // Since we aren't consuming the response body, we cancel it to avoid memory leaks _response_body; (_response_body = response.body) == null ? void 0 : _response_body.cancel(); } } catch (err) { // we couldn't stream the forwarded response, so we'll just return an empty response console.error(`failed to forward action response`, err); } return RenderResult.fromStatic('{}', JSON_CONTENT_TYPE_HEADER); } /** * Returns the parsed redirect URL if we deem that it is hosted by us. * * We handle both relative and absolute redirect URLs. * * In case the redirect URL is not relative to the application we return `null`. */ function getAppRelativeRedirectUrl(basePath, host, redirectUrl, currentPathname) { if (redirectUrl.startsWith('/')) { // Absolute path - just add basePath return new URL(`${basePath}${redirectUrl}`, 'http://n'); } else if (redirectUrl.startsWith('.')) { // Relative path - resolve relative to current pathname let base = currentPathname || '/'; // Ensure the base path ends with a slash so relative resolution works correctly // e.g., "./subpage" from "/subdir" should resolve to "/subdir/subpage" // not "/subpage" if (!base.endsWith('/')) { base = base + '/'; } const resolved = new URL(redirectUrl, `http://n${base}`); // Include basePath in the final URL return new URL(`${basePath}${resolved.pathname}${resolved.search}${resolved.hash}`, 'http://n'); } const parsedRedirectUrl = new URL(redirectUrl); if ((host == null ? void 0 : host.value) !== parsedRedirectUrl.host) { return null; } // At this point the hosts are the same, just confirm we // are routing to a path underneath the `basePath` return parsedRedirectUrl.pathname.startsWith(basePath) ? parsedRedirectUrl : null; } async function createRedirectRenderResult(req, res, originalHost, redirectUrl, redirectType, basePath, workStore, currentPathname) { res.setHeader('x-action-redirect', `${redirectUrl};${redirectType}`); // If we're redirecting to another route of this Next.js application, we'll // try to stream the response from the other worker path. When that works, // we can save an extra roundtrip and avoid a full page reload. // When the redirect URL starts with a `/` or is to the same host, under the // `basePath` we treat it as an app-relative redirect; const appRelativeRedirectUrl = getAppRelativeRedirectUrl(basePath, originalHost, redirectUrl, currentPathname); if (appRelativeRedirectUrl) { var _getRequestMeta; if (!originalHost) { throw Object.defineProperty(new Error('Invariant: Missing `host` header from a forwarded Server Actions request.'), "__NEXT_ERROR_CODE", { value: "E226", enumerable: false, configurable: true }); } const forwardedHeaders = getForwardedHeaders(req, res); forwardedHeaders.set(RSC_HEADER, '1'); const proto = ((_getRequestMeta = getRequestMeta(req, 'initProtocol')) == null ? void 0 : _getRequestMeta.replace(/:+$/, '')) || 'https'; // For standalone or the serverful mode, use the internal origin directly // other than the host headers from the request. const origin = process.env.__NEXT_PRIVATE_ORIGIN || `${proto}://${originalHost.value}`; const fetchUrl = new URL(`${origin}${appRelativeRedirectUrl.pathname}${appRelativeRedirectUrl.search}`); if (workStore.pendingRevalidatedTags) { var _workStore_incrementalCache_prerenderManifest_preview, _workStore_incrementalCache_prerenderManifest, _workStore_incrementalCache; forwardedHeaders.set(NEXT_CACHE_REVALIDATED_TAGS_HEADER, workStore.pendingRevalidatedTags.map((item)=>item.tag).join(',')); forwardedHeaders.set(NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER, ((_workStore_incrementalCache = workStore.incrementalCache) == null ? void 0 : (_workStore_incrementalCache_prerenderManifest = _workStore_incrementalCache.prerenderManifest) == null ? void 0 : (_workStore_incrementalCache_prerenderManifest_preview = _workStore_incrementalCache_prerenderManifest.preview) == null ? void 0 : _workStore_incrementalCache_prerenderManifest_preview.previewModeId) || ''); } // Ensures that when the path was revalidated we don't return a partial response on redirects forwardedHeaders.delete(NEXT_ROUTER_STATE_TREE_HEADER); // When an action follows a redirect, it's no longer handling an action: it's just a normal RSC request // to the requested URL. We should remove the `next-action` header so that it's not treated as an action forwardedHeaders.delete(ACTION_HEADER); try { var _response_headers_get; setCacheBustingSearchParam(fetchUrl, { [NEXT_ROUTER_PREFETCH_HEADER]: forwardedHeaders.get(NEXT_ROUTER_PREFETCH_HEADER) ? '1' : undefined, [NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]: forwardedHeaders.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER) ?? undefined, [NEXT_ROUTER_STATE_TREE_HEADER]: forwardedHeaders.get(NEXT_ROUTER_STATE_TREE_HEADER) ?? undefined, [NEXT_URL]: forwardedHeaders.get(NEXT_URL) ?? undefined }); const response = await fetch(fetchUrl, { method: 'GET', headers: forwardedHeaders, next: { // @ts-ignore internal: 1 } }); if ((_response_headers_get = response.headers.get('content-type')) == null ? void 0 : _response_headers_get.startsWith(RSC_CONTENT_TYPE_HEADER)) { // copy the headers from the redirect response to the response we're sending for (const [key, value] of response.headers){ if (!actionsForbiddenHeaders.includes(key)) { res.setHeader(key, value); } } return new FlightRenderResult(response.body); } else { var // Since we aren't consuming the response body, we cancel it to avoid memory leaks _response_body; (_response_body = response.body) == null ? void 0 : _response_body.cancel(); } } catch (err) { // we couldn't stream the redirect response, so we'll just do a normal redirect console.error(`failed to get redirect response`, err); } } return RenderResult.EMPTY; } /** * Ensures the value of the header can't create long logs. */ function limitUntrustedHeaderValueForLogs(value) { return value.length > 100 ? value.slice(0, 100) + '...' : value; } export function parseHostHeader(headers, originDomain) { var _forwardedHostHeader_split_, _forwardedHostHeader_split; const forwardedHostHeader = headers['x-forwarded-host']; const forwardedHostHeaderValue = forwardedHostHeader && Array.isArray(forwardedHostHeader) ? forwardedHostHeader[0] : forwardedHostHeader == null ? void 0 : (_forwardedHostHeader_split = forwardedHostHeader.split(',')) == null ? void 0 : (_forwardedHostHeader_split_ = _forwardedHostHeader_split[0]) == null ? void 0 : _forwardedHostHeader_split_.trim(); const hostHeader = headers['host']; if (originDomain) { return forwardedHostHeaderValue === originDomain ? { type: "x-forwarded-host", value: forwardedHostHeaderValue } : hostHeader === originDomain ? { type: "host", value: hostHeader } : undefined; } return forwardedHostHeaderValue ? { type: "x-forwarded-host", value: forwardedHostHeaderValue } : hostHeader ? { type: "host", value: hostHeader } : undefined; } export async function handleAction({ req, res, ComponentMod, generateFlight, workStore, requestStore, serverActions, ctx, metadata }) { const contentType = req.headers['content-type']; const { page } = ctx.renderOpts; const serverModuleMap = getServerModuleMap(); const { actionId, isMultipartAction, isFetchAction, isURLEncodedAction, isPossibleServerAction } = getServerActionRequestMetadata(req); const handleUnrecognizedFetchAction = (err)=>{ // If the deployment doesn't have skew protection, this is expected to occasionally happen, // so we use a warning instead of an error. console.warn(err); // Return an empty response with a header that the client router will interpret. // We don't need to waste time encoding a flight response, and using a blank body + header // means that unrecognized actions can also be handled at the infra level // (i.e. without needing to invoke a lambda) res.setHeader(NEXT_ACTION_NOT_FOUND_HEADER, '1'); res.setHeader('content-type', 'text/plain'); res.statusCode = 404; return { type: 'done', result: RenderResult.fromStatic('Server action not found.', 'text/plain') }; }; // 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 an MPA action or not until we call `decodeAction` below. if (!isPossibleServerAction) { return null; } // We don't currently support URL encoded actions, so we bail out early. // Depending on if it's a fetch action or an MPA, we return a different response. if (isURLEncodedAction) { if (isFetchAction) { return { type: 'not-found' }; } else { // This is an MPA action, so we return null return null; } } // If the app has no server actions at all, we can 404 early. if (!hasServerActions()) { return handleUnrecognizedFetchAction(getActionNotFoundError(actionId)); } if (workStore.isStaticGeneration) { throw Object.defineProperty(new Error("Invariant: server actions can't be handled during static rendering"), "__NEXT_ERROR_CODE", { value: "E359", enumerable: false, configurable: true }); } let temporaryReferences; // When running actions the default is no-store, you can still `cache: 'force-cache'` workStore.fetchCache = 'default-no-store'; const originHeader = req.headers['origin']; const originDomain = typeof originHeader === 'string' && originHeader !== 'null' ? new URL(originHeader).host : undefined; const host = parseHostHeader(req.headers); let warning = undefined; function warnBadServerActionRequest() { if (warning) { warn(warning); } } // This is to prevent CSRF attacks. If `x-forwarded-host` is set, we need to // ensure that the request is coming from the same host. if (!originDomain) { // This might be an old browser that doesn't send `host` header. We ignore // this case. warning = 'Missing `origin` header from a forwarded Server Actions request.'; } else if (!host || originDomain !== host.value) { // If the customer sets a list of allowed origins, we'll allow the request. // These are considered safe but might be different from forwarded host set // by the infra (i.e. reverse proxies). if (isCsrfOriginAllowed(originDomain, serverActions == null ? void 0 : serverActions.allowedOrigins)) { // Ignore it } else { if (host) { // This seems to be an CSRF attack. We should not proceed the action. console.error(`\`${host.type}\` header with value \`${limitUntrustedHeaderValueForLogs(host.value)}\` does not match \`origin\` header with value \`${limitUntrustedHeaderValueForLogs(originDomain)}\` from a forwarded Server Actions request. Aborting the action.`); } else { // This is an attack. We should not proceed the action. console.error(`\`x-forwarded-host\` or \`host\` headers are not provided. One of these is needed to compare the \`origin\` header from a forwarded Server Actions request. Aborting the action.`); } const error = Object.defineProperty(new Error('Invalid Server Actions request.'), "__NEXT_ERROR_CODE", { value: "E80", enumerable: false, configurable: true }); if (isFetchAction) { res.statusCode = 500; metadata.statusCode = 500; const promise = Promise.reject(error); try { // we need to await the promise to trigger the rejection early // so that it's already handled by the time we call // the RSC runtime. Otherwise, it will throw an unhandled // promise rejection error in the renderer. await promise; } catch { // swallow error, it's gonna be handled on the client } return { type: 'done', result: await generateFlight(req, ctx, requestStore, { actionResult: promise, // We didn't execute an action, so no revalidations could have // occurred. We can skip rendering the page. skipPageRendering: true, temporaryReferences }) }; } throw error; } } // ensure we avoid caching server actions unexpectedly res.setHeader('Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate'); const { actionAsyncStorage } = ComponentMod; const actionWasForwarded = Boolean(req.headers['x-action-forwarded']); if (actionId) { const forwardedWorker = selectWorkerForForwarding(actionId, page); // If forwardedWorker is truthy, it means there isn't a worker for the action // in the current handler, so we forward the request to a worker that has the action. if (forwardedWorker) { return { type: 'done', result: await createForwardedActionResponse(req, res, host, forwardedWorker, ctx.renderOpts.basePath) }; } } try { return await actionAsyncStorage.run({ isAction: true }, async ()=>{ // We only use these for fetch actions -- MPA actions handle them inside `decodeAction`. let actionModId; let boundActionArguments = []; 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 Object.defineProperty(new Error('invariant: Missing request body.'), "__NEXT_ERROR_CODE", { value: "E364", enumerable: false, configurable: true }); } // TODO: add body limit // Use react-server-dom-webpack/server const { createTemporaryReferenceSet, decodeReply, decodeAction, decodeFormState } = ComponentMod; temporaryReferences = createTemporaryReferenceSet(); if (isMultipartAction) { // TODO-APP: Add streaming support const formData = await req.request.formData(); if (isFetchAction) { // A fetch action with a multipart body. try { actionModId = getActionModIdOrError(actionId, serverModuleMap); } catch (err) { return handleUnrecognizedFetchAction(err); } boundActionArguments = await decodeReply(formData, serverModuleMap, { temporaryReferences }); } else { // Multipart POST, but not a fetch action. // Potentially an MPA action, we have to try decoding it to check. if (areAllActionIdsValid(formData, serverModuleMap) === false) { // TODO: This can be from skew or manipulated input. We should handle this case // more gracefully but this preserves the prior behavior where decodeAction would throw instead. throw Object.defineProperty(new Error(`Failed to find Server Action. This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action`), "__NEXT_ERROR_CODE", { value: "E975", enumerable: false, configurable: true }); } const action = await decodeAction(formData, serverModuleMap); if (typeof action === 'function') { // an MPA action. // Only warn if it's a server action, otherwise skip for other post requests warnBadServerActionRequest(); const { actionResult } = await executeActionAndPrepareForRender(action, [], workStore, requestStore, actionWasForwarded); const formState = await decodeFormState(actionResult, formData, serverModuleMap); // Skip the fetch path. // We need to render a full HTML version of the page for the response, we'll handle that in app-render. return { type: 'done', result: undefined, formState }; } else { // We couldn't decode an action, so this POST request turned out not to be a server action request. return null; } } } else { // POST with non-multipart body. // If it's not multipart AND not a fetch action, // then it can't be an action request. if (!isFetchAction) { return null; } try { actionModId = getActionModIdOrError(actionId, serverModuleMap); } catch (err) { return handleUnrecognizedFetchAction(err); } // A fetch action with a non-multipart body. // In practice, this happens if `encodeReply` returned a string instead of FormData, // which can happen for very simple JSON-like values that don't need multiple flight rows. const chunks = []; 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 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`); temporaryReferences = createTemporaryReferenceSet(); const { PassThrough, Readable, Transform } = require('node:stream'); const { pipeline } = require('node:stream/promises'); const defaultBodySizeLimit = '1 MB'; const bodySizeLimit = (serverActions == null ? void 0 : serverActions.bodySizeLimit) ?? defaultBodySizeLimit; const bodySizeLimitBytes = bodySizeLimit !== defaultBodySizeLimit ? require('next/dist/compiled/bytes').parse(bodySizeLimit) : 1024 * 1024 // 1 MB ; let size = 0; const sizeLimitTransform = new Transform({ transform (chunk, encoding, callback) { size += Buffer.byteLength(chunk, encoding); if (size > bodySizeLimitBytes) { const { ApiError } = require('../api-utils'); callback(Object.defineProperty(new ApiError(413, `Body exceeded ${bodySizeLimit} limit.\n` + `To configure the body size limit for Server Actions, see: https://nextjs.org/docs/app/api-reference/next-config-js/serverActions#bodysizelimit`), "__NEXT_ERROR_CODE", { value: "E394", enumerable: false, configurable: true })); return; } callback(null, chunk); } }); if (isMultipartAction) { if (isFetchAction) { // A fetch action with a multipart body. try { actionModId = getActionModIdOrError(actionId, serverModuleMap); } catch (err) { return handleUnrecognizedFetchAction(err); } const busboy = require('next/dist/compiled/busboy')({ defParamCharset: 'utf8', headers: req.headers, limits: { fieldSize: bodySizeLimitBytes } }); const abortController = new AbortController(); try { ; [, boundActionArguments] = await Promise.all([ pipeline(req.body, sizeLimitTransform, busboy, { signal: abortController.signal }), decodeReplyFromBusboy(busboy, serverModuleMap, { temporaryReferences }) ]); } catch (err) { abortController.abort(); throw err; } } else { // Multipart POST, but not a fetch action. // Potentially an MPA action, we have to try decoding it to check. const sizeLimitedBody = new PassThrough(); // 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: Readable.toWeb(sizeLimitedBody), duplex: 'half' }); let formData; const abortController = new AbortController(); try { ; [, formData] = await Promise.all([ pipeline(req.body, sizeLimitTransform, sizeLimitedBody, { signal: abortController.signal }), fakeRequest.formData() ]); } catch (err) { abortController.abort(); throw err; } if (areAllActionIdsValid(formData, serverModuleMap) === false) { // TODO: This can be from skew or manipulated input. We should handle this case // more gracefully but this preserves the prior behavior where decodeAction would throw instead. throw Object.defineProperty(new Error(`Failed to find Server Action. This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action`), "__NEXT_ERROR_CODE", { value: "E975", enumerable: false, configurable: true }); } // TODO: Refactor so it is harder to accidentally decode an action before you have validated that the // action referred to is available. const action = await decodeAction(formData, serverModuleMap); if (typeof action === 'function') { // an MPA action. // Only warn if it's a server action, otherwise skip for other post requests warnBadServerActionRequest(); const { actionResult } = await executeActionAndPrepareForRender(action, [], workStore, requestStore, actionWasForwarded); const formState = await decodeFormState(actionResult, formData, serverModuleMap); // Skip the fetch path. // We need to render a full HTML version of the page for the response, we'll handle that in app-render. return { type: 'done', result: undefined, formState }; } else { // We couldn't decode an action, so this POST request turned out not to be a server action request. return null; } } } else { // POST with non-multipart body. // If it's not multipart AND not a fetch action, // then it can't be an action request. if (!isFetchAction) { return null; } try { actionModId = getActionModIdOrError(actionId, serverModuleMap); } catch (err) { return handleUnrecognizedFetchAction(err); } // A fetch action with a non-multipart body. // In practice, this happens if `encodeReply` returned a string instead of FormData, // which can happen for very simple JSON-like values that don't need multiple flight rows. const sizeLimitedBody = new PassThrough(); const chunks = []; await Promise.all([ pipeline(req.body, sizeLimitTransform, sizeLimitedBody), (async ()=>{ for await (const chunk of sizeLimitedBody){ chunks.push(Buffer.from(chunk)); } })() ]); const actionData = Buffer.concat(chunks).toString('utf-8'); boundActionArguments = await decodeReply(actionData, serverModuleMap, { temporaryReferences }); } } else { throw Object.defineProperty(new Error('Invariant: Unknown request type.'), "__NEXT_ERROR_CODE", { value: "E114", enumerable: false, configurable: true }); } // 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); const actionHandler = actionMod[// `actionId` must exist if we got here, as otherwise we would have thrown an error above actionId]; const { actionResult, skipPageRendering } = await executeActionAndPrepareForRender(actionHandler, boundActionArguments, workStore, requestStore, actionWasForwarded).finally(()=>{ addRevalidationHeader(res, { workStore, requestStore }); }); // For form actions, we need to continue rendering the page. if (isFetchAction) { return { type: 'done', result: await generateFlight(req, ctx, requestStore, { actionResult: Promise.resolve(actionResult), skipPageRendering, temporaryReferences, // If we skip page rendering, we need to ensure pending // revalidates are awaited before closing the response. Otherwise, // this will be done after rendering the page. waitUntil: skipPageRendering ? executeRevalidates(workStore) : undefined }) }; } else { // TODO: this shouldn't be reachable, because all non-fetch codepaths return early. // this will be handled in a follow-up refactor PR. return null; } }); } catch (err) { if (isRedirectError(err)) { const redirectUrl = getURLFromRedirectError(err); const redirectType = getRedirectTypeFromError(err); // if it's a fetch action, we'll set the status code for logging/debugging purposes // but we won't set a Location header, as the redirect will be handled by the client router res.statusCode = RedirectStatusCode.SeeOther; metadata.statusCode = RedirectStatusCode.SeeOther; if (isFetchAction) { return { type: 'done', result: await createRedirectRenderResult(req, res, host, redirectUrl, redirectType, ctx.renderOpts.basePath, workStore, requestStore.url.pathname) }; } // For an MPA action, the redirect doesn't need a body, just a Location header. res.setHeader('Location', redirectUrl); return { type: 'done', result: RenderResult.EMPTY }; } else if (isHTTPAccessFallbackError(err)) { res.statusCode = getAccessFallbackHTTPStatus(err); metadata.statusCode = res.statusCode; if (isFetchAction) { const promise = Promise.reject(err); try { // we need to await the promise to trigger the rejection early // so that it's already handled by the time we call // the RSC runtime. Otherwise, it will throw an unhandled // promise rejection error in the renderer. await promise; } catch { // swallow error, it's gonna be handled on the client } return { type: 'done', result: await generateFlight(req, ctx, requestStore, { skipPageRendering: false, actionResult: promise, temporaryReferences }) }; } // For an MPA action, we need to render a HTML response. We'll handle that in app-render. return { type: 'not-found' }; } // An error that didn't come from `redirect()` or `notFound()`, likely thrown from user code // (but it could also be a bug in our code!) if (isFetchAction) { // TODO: consider checking if the error is an `ApiError` and change status code // so that we can respond with a 413 to requests that break the body size limit // (but if we do that, we also need to make sure that whatever handles the non-fetch error path below does the same) res.statusCode = 500; metadata.statusCode = 500; const promise = Promise.reject(err); try { // we need to await the promise to trigger the rejection early // so that it's already handled by the time we call // the RSC runtime. Otherwise, it will throw an unhandled // promise rejection error in the renderer. await promise; } catch { // swallow error, it's gonna be handled on the client } return { type: 'done', result: await generateFlight(req, ctx, requestStore, { actionResult: promise, // If the page was not revalidated, or if the action was forwarded // from another worker, we can skip rendering the page. skipPageRendering: workStore.pathWasRevalidated === undefined || workStore.pathWasRevalidated === ActionDidNotRevalidate || actionWasForwarded, temporaryReferences }) }; } // For an MPA action, we need to render a HTML response. We'll rethrow the error and let it be handled above. throw err; } } async function executeActionAndPrepareForRender(action, args, workStore, requestStore, actionWasForwarded) { requestStore.phase = 'action'; let skipPageRendering = actionWasForwarded; try { const actionResult = await workUnitAsyncStorage.run(requestStore, ()=>action.apply(null, args)); // If the page was not revalidated, or if the action was forwarded from // another worker, we can skip rendering the page. skipPageRendering ||= workStore.pathWasRevalidated === undefined || workStore.pathWasRevalidated === ActionDidNotRevalidate; return { actionResult, skipPageRendering }; } finally{ if (!skipPageRendering) { requestStore.phase = 'render'; // When we switch to the render phase, cookies() will return // `workUnitStore.cookies` instead of // `workUnitStore.userspaceMutableCookies`. We want the render to see any // cookie writes that we performed during the action, so we need to update // the immutable cookies to reflect the changes. synchronizeMutableCookies(requestStore); // The server action might have toggled draft mode, so we need to reflect // that in the work store to be up-to-date for subsequent rendering. workStore.isDraftMode = requestStore.draftMode.isEnabled; // If the action called revalidateTag/revalidatePath, then that might // affect data used by the subsequent render, so we need to make sure all // revalidations are applied before that. await executeRevalidates(workStore); } } } /** * Attempts to find the module ID for the action from the module map. When this fails, it could be a deployment skew where * the action came from a different deployment. It could also simply be an invalid POST request that is not a server action. * In either case, we'll throw an error to be handled by the caller. */ function getActionModIdOrError(actionId, serverModuleMap) { var _serverModuleMap_actionId; // if we're missing the action ID header, we can't do any further processing if (!actionId) { throw Object.defineProperty(new InvariantError("Missing 'next-action' header."), "__NEXT_ERROR_CODE", { value: "E664", enumerable: false, configurable: true }); } const actionModId = (_serverModuleMap_actionId = serverModuleMap[actionId]) == null ? void 0 : _serverModuleMap_actionId.id; if (!actionModId) { throw getActionNotFoundError(actionId); } return actionModId; } function getActionNotFoundError(actionId) { return Object.defineProperty(new Error(`Failed to find Server Action${actionId ? ` "${actionId}"` : ''}. This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action`), "__NEXT_ERROR_CODE", { value: "E974", enumerable: false, configurable: true }); } const $ACTION_ = '$ACTION_'; const $ACTION_REF_ = '$ACTION_REF_'; const $ACTION_ID_ = '$ACTION_ID_'; const ACTION_ID_EXPECTED_LENGTH = 42; /** * This function mirrors logic inside React's decodeAction and should be kept in sync with that. * It pre-parses the FormData to ensure that any action IDs referred to are actual action IDs for * this Next.js application. */ function areAllActionIdsValid(mpaFormData, serverModuleMap) { let hasAtLeastOneAction = false; // Before we attempt to decode the payload for a possible MPA action, assert that all // action IDs are valid IDs. If not we should disregard the payload for (let key of mpaFormData.keys()){ if (!key.startsWith($ACTION_)) { continue; } if (key.startsWith($ACTION_ID_)) { // No Bound args case if (isInvalidActionIdFieldName(key, serverModuleMap)) { return false; } hasAtLeastOneAction = true; } else if (key.startsWith($ACTION_REF_)) { // Bound args case const actionDescriptorField = $ACTION_ + key.slice($ACTION_REF_.length) + ':0'; const actionFields = mpaFormData.getAll(actionDescriptorField); if (actionFields.length !== 1) { return false; } const actionField = actionFields[0]; if (typeof actionField !== 'string') { return false; } if (isInvalidStringActionDescriptor(actionField, serverModuleMap)) { return false; } hasAtLeastOneAction = true; } } return hasAtLeastOneAction; } const ACTION_DESCRIPTOR_ID_PREFIX = '{"id":"'; function isInvalidStringActionDescriptor(actionDescriptor, serverModuleMap) { if (actionDescriptor.startsWith(ACTION_DESCRIPTOR_ID_PREFIX) === false) { return true; } const from = ACTION_DESCRIPTOR_ID_PREFIX.length; const to = from + ACTION_ID_EXPECTED_LENGTH; // We expect actionDescriptor to be '{"id":"<actionId>",...}' const actionId = actionDescriptor.slice(from, to); if (actionId.length !== ACTION_ID_EXPECTED_LENGTH || actionDescriptor[to] !== '"') { return true; } const entry = serverModuleMap[actionId]; if (entry == null) { return true; } return false; } function isInvalidActionIdFieldName(actionIdFieldName, serverModuleMap) { // The field name must always start with $ACTION_ID_ but since it is // the id is extracted from the key of the field we have already validated // this before entering this function if (actionIdFieldName.length !== $ACTION_ID_.length + ACTION_ID_EXPECTED_LENGTH) { // this fi