| | import type { IncomingHttpHeaders, OutgoingHttpHeaders } from 'node:http' |
| | import type { SizeLimit } from '../../types' |
| | import type { RequestStore } from '../app-render/work-unit-async-storage.external' |
| | import type { AppRenderContext, GenerateFlight } from './app-render' |
| | import type { AppPageModule } from '../route-modules/app-page/module' |
| | import type { BaseNextRequest, BaseNextResponse } from '../base-http' |
| |
|
| | 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, |
| | type RedirectType, |
| | } from '../../client/components/redirect-error' |
| | import RenderResult, { |
| | type AppPageRenderResultMetadata, |
| | } from '../render-result' |
| | import type { WorkStore } from '../app-render/work-async-storage.external' |
| | 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, |
| | type ServerModuleMap, |
| | 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 type { TemporaryReferenceSet } from 'react-server-dom-webpack/server' |
| | 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' |
| |
|
| | |
| | |
| | |
| | function hasServerActions() { |
| | const serverActionsManifest = getServerActionsManifest() |
| |
|
| | return ( |
| | Object.keys(serverActionsManifest.node).length > 0 || |
| | Object.keys(serverActionsManifest.edge).length > 0 |
| | ) |
| | } |
| |
|
| | function nodeHeadersToRecord( |
| | headers: IncomingHttpHeaders | OutgoingHttpHeaders |
| | ) { |
| | const record: Record<string, string> = {} |
| | for (const [key, value] of Object.entries(headers)) { |
| | if (value !== undefined) { |
| | record[key] = Array.isArray(value) ? value.join(', ') : `${value}` |
| | } |
| | } |
| | return record |
| | } |
| |
|
| | function getForwardedHeaders( |
| | req: BaseNextRequest, |
| | res: BaseNextResponse |
| | ): Headers { |
| | |
| | const requestHeaders = req.headers |
| | const requestCookies = new RequestCookies(HeadersAdapter.from(requestHeaders)) |
| |
|
| | |
| | const responseHeaders = res.getHeaders() |
| | const responseCookies = new ResponseCookies( |
| | fromNodeOutgoingHttpHeaders(responseHeaders) |
| | ) |
| |
|
| | |
| | const mergedHeaders = filterReqHeaders( |
| | { |
| | ...nodeHeadersToRecord(requestHeaders), |
| | ...nodeHeadersToRecord(responseHeaders), |
| | }, |
| | actionsForbiddenHeaders |
| | ) as Record<string, string> |
| |
|
| | |
| | |
| | responseCookies.getAll().forEach((cookie) => { |
| | if (typeof cookie.value === 'undefined') { |
| | requestCookies.delete(cookie.name) |
| | } else { |
| | requestCookies.set(cookie) |
| | } |
| | }) |
| |
|
| | |
| | mergedHeaders['cookie'] = requestCookies.toString() |
| |
|
| | |
| | delete mergedHeaders['transfer-encoding'] |
| |
|
| | return new Headers(mergedHeaders) |
| | } |
| |
|
| | function addRevalidationHeader( |
| | res: BaseNextResponse, |
| | { |
| | workStore, |
| | requestStore, |
| | }: { |
| | workStore: WorkStore |
| | requestStore: RequestStore |
| | } |
| | ) { |
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| | const isTagRevalidated = workStore.pendingRevalidatedTags?.some( |
| | (item) => item.profile === undefined |
| | ) |
| | ? 1 |
| | : 0 |
| | const isCookieRevalidated = getModifiedCookieValues( |
| | requestStore.mutableCookies |
| | ).length |
| | ? 1 |
| | : 0 |
| |
|
| | |
| | if (isTagRevalidated || isCookieRevalidated) { |
| | res.setHeader( |
| | NEXT_ACTION_REVALIDATED_HEADER, |
| | JSON.stringify(ActionDidRevalidateStaticAndDynamic) |
| | ) |
| | } else if ( |
| | |
| | workStore.pathWasRevalidated !== undefined && |
| | workStore.pathWasRevalidated !== ActionDidNotRevalidate |
| | ) { |
| | res.setHeader( |
| | NEXT_ACTION_REVALIDATED_HEADER, |
| | JSON.stringify(workStore.pathWasRevalidated) |
| | ) |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | async function createForwardedActionResponse( |
| | req: BaseNextRequest, |
| | res: BaseNextResponse, |
| | host: Host, |
| | workerPathname: string, |
| | basePath: string |
| | ) { |
| | if (!host) { |
| | throw new Error( |
| | 'Invariant: Missing `host` header from a forwarded Server Actions request.' |
| | ) |
| | } |
| |
|
| | const forwardedHeaders = getForwardedHeaders(req, res) |
| |
|
| | |
| | |
| | |
| | forwardedHeaders.set('x-action-forwarded', '1') |
| |
|
| | const proto = |
| | getRequestMeta(req, 'initProtocol')?.replace(/:+$/, '') || 'https' |
| |
|
| | |
| | |
| | const origin = process.env.__NEXT_PRIVATE_ORIGIN || `${proto}://${host.value}` |
| |
|
| | const fetchUrl = new URL(`${origin}${basePath}${workerPathname}`) |
| |
|
| | try { |
| | let body: BodyInit | ReadableStream<Uint8Array> | undefined |
| | if ( |
| | |
| | |
| | process.env.NEXT_RUNTIME === 'edge' && |
| | isWebNextRequest(req) |
| | ) { |
| | if (!req.body) { |
| | throw new Error('Invariant: missing request body.') |
| | } |
| |
|
| | body = req.body |
| | } else if ( |
| | |
| | |
| | process.env.NEXT_RUNTIME !== 'edge' && |
| | isNodeNextRequest(req) |
| | ) { |
| | body = req.stream() |
| | } else { |
| | throw new Error('Invariant: Unknown request type.') |
| | } |
| |
|
| | |
| | const response = await fetch(fetchUrl, { |
| | method: 'POST', |
| | body, |
| | duplex: 'half', |
| | headers: forwardedHeaders, |
| | redirect: 'manual', |
| | next: { |
| | |
| | internal: 1, |
| | }, |
| | }) |
| |
|
| | if ( |
| | response.headers.get('content-type')?.startsWith(RSC_CONTENT_TYPE_HEADER) |
| | ) { |
| | |
| | for (const [key, value] of response.headers) { |
| | if (!actionsForbiddenHeaders.includes(key)) { |
| | res.setHeader(key, value) |
| | } |
| | } |
| |
|
| | return new FlightRenderResult(response.body!) |
| | } else { |
| | |
| | response.body?.cancel() |
| | } |
| | } catch (err) { |
| | |
| | console.error(`failed to forward action response`, err) |
| | } |
| |
|
| | return RenderResult.fromStatic('{}', JSON_CONTENT_TYPE_HEADER) |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | function getAppRelativeRedirectUrl( |
| | basePath: string, |
| | host: Host, |
| | redirectUrl: string, |
| | currentPathname?: string |
| | ): URL | null { |
| | if (redirectUrl.startsWith('/')) { |
| | |
| | return new URL(`${basePath}${redirectUrl}`, 'http://n') |
| | } else if (redirectUrl.startsWith('.')) { |
| | |
| | let base = currentPathname || '/' |
| | |
| | |
| | |
| | if (!base.endsWith('/')) { |
| | base = base + '/' |
| | } |
| | const resolved = new URL(redirectUrl, `http://n${base}`) |
| | |
| | return new URL( |
| | `${basePath}${resolved.pathname}${resolved.search}${resolved.hash}`, |
| | 'http://n' |
| | ) |
| | } |
| |
|
| | const parsedRedirectUrl = new URL(redirectUrl) |
| |
|
| | if (host?.value !== parsedRedirectUrl.host) { |
| | return null |
| | } |
| |
|
| | |
| | |
| | return parsedRedirectUrl.pathname.startsWith(basePath) |
| | ? parsedRedirectUrl |
| | : null |
| | } |
| |
|
| | async function createRedirectRenderResult( |
| | req: BaseNextRequest, |
| | res: BaseNextResponse, |
| | originalHost: Host, |
| | redirectUrl: string, |
| | redirectType: RedirectType, |
| | basePath: string, |
| | workStore: WorkStore, |
| | currentPathname?: string |
| | ) { |
| | res.setHeader('x-action-redirect', `${redirectUrl};${redirectType}`) |
| |
|
| | |
| | |
| | |
| | |
| | |
| | const appRelativeRedirectUrl = getAppRelativeRedirectUrl( |
| | basePath, |
| | originalHost, |
| | redirectUrl, |
| | currentPathname |
| | ) |
| |
|
| | if (appRelativeRedirectUrl) { |
| | if (!originalHost) { |
| | throw new Error( |
| | 'Invariant: Missing `host` header from a forwarded Server Actions request.' |
| | ) |
| | } |
| |
|
| | const forwardedHeaders = getForwardedHeaders(req, res) |
| | forwardedHeaders.set(RSC_HEADER, '1') |
| |
|
| | const proto = |
| | getRequestMeta(req, 'initProtocol')?.replace(/:+$/, '') || 'https' |
| |
|
| | |
| | |
| | const origin = |
| | process.env.__NEXT_PRIVATE_ORIGIN || `${proto}://${originalHost.value}` |
| |
|
| | const fetchUrl = new URL( |
| | `${origin}${appRelativeRedirectUrl.pathname}${appRelativeRedirectUrl.search}` |
| | ) |
| |
|
| | if (workStore.pendingRevalidatedTags) { |
| | forwardedHeaders.set( |
| | NEXT_CACHE_REVALIDATED_TAGS_HEADER, |
| | workStore.pendingRevalidatedTags.map((item) => item.tag).join(',') |
| | ) |
| | forwardedHeaders.set( |
| | NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER, |
| | workStore.incrementalCache?.prerenderManifest?.preview?.previewModeId || |
| | '' |
| | ) |
| | } |
| |
|
| | |
| | forwardedHeaders.delete(NEXT_ROUTER_STATE_TREE_HEADER) |
| | |
| | |
| | forwardedHeaders.delete(ACTION_HEADER) |
| |
|
| | try { |
| | setCacheBustingSearchParam(fetchUrl, { |
| | [NEXT_ROUTER_PREFETCH_HEADER]: forwardedHeaders.get( |
| | NEXT_ROUTER_PREFETCH_HEADER |
| | ) |
| | ? ('1' as const) |
| | : 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: { |
| | |
| | internal: 1, |
| | }, |
| | }) |
| |
|
| | if ( |
| | response.headers |
| | .get('content-type') |
| | ?.startsWith(RSC_CONTENT_TYPE_HEADER) |
| | ) { |
| | |
| | for (const [key, value] of response.headers) { |
| | if (!actionsForbiddenHeaders.includes(key)) { |
| | res.setHeader(key, value) |
| | } |
| | } |
| |
|
| | return new FlightRenderResult(response.body!) |
| | } else { |
| | |
| | response.body?.cancel() |
| | } |
| | } catch (err) { |
| | |
| | console.error(`failed to get redirect response`, err) |
| | } |
| | } |
| |
|
| | return RenderResult.EMPTY |
| | } |
| |
|
| | |
| | const enum HostType { |
| | XForwardedHost = 'x-forwarded-host', |
| | Host = 'host', |
| | } |
| | type Host = |
| | | { |
| | type: HostType.XForwardedHost |
| | value: string |
| | } |
| | | { |
| | type: HostType.Host |
| | value: string |
| | } |
| | | undefined |
| |
|
| | |
| | |
| | |
| | function limitUntrustedHeaderValueForLogs(value: string) { |
| | return value.length > 100 ? value.slice(0, 100) + '...' : value |
| | } |
| |
|
| | export function parseHostHeader( |
| | headers: IncomingHttpHeaders, |
| | originDomain?: string |
| | ) { |
| | const forwardedHostHeader = headers['x-forwarded-host'] |
| | const forwardedHostHeaderValue = |
| | forwardedHostHeader && Array.isArray(forwardedHostHeader) |
| | ? forwardedHostHeader[0] |
| | : forwardedHostHeader?.split(',')?.[0]?.trim() |
| | const hostHeader = headers['host'] |
| |
|
| | if (originDomain) { |
| | return forwardedHostHeaderValue === originDomain |
| | ? { |
| | type: HostType.XForwardedHost, |
| | value: forwardedHostHeaderValue, |
| | } |
| | : hostHeader === originDomain |
| | ? { |
| | type: HostType.Host, |
| | value: hostHeader, |
| | } |
| | : undefined |
| | } |
| |
|
| | return forwardedHostHeaderValue |
| | ? { |
| | type: HostType.XForwardedHost, |
| | value: forwardedHostHeaderValue, |
| | } |
| | : hostHeader |
| | ? { |
| | type: HostType.Host, |
| | value: hostHeader, |
| | } |
| | : undefined |
| | } |
| |
|
| | type ServerActionsConfig = { |
| | bodySizeLimit?: SizeLimit |
| | allowedOrigins?: string[] |
| | } |
| |
|
| | type HandleActionResult = |
| | | { |
| | |
| | type: 'not-found' |
| | } |
| | | { |
| | type: 'done' |
| | result: RenderResult | undefined |
| | formState?: any |
| | } |
| | |
| | | null |
| |
|
| | export async function handleAction({ |
| | req, |
| | res, |
| | ComponentMod, |
| | generateFlight, |
| | workStore, |
| | requestStore, |
| | serverActions, |
| | ctx, |
| | metadata, |
| | }: { |
| | req: BaseNextRequest |
| | res: BaseNextResponse |
| | ComponentMod: AppPageModule |
| | generateFlight: GenerateFlight |
| | workStore: WorkStore |
| | requestStore: RequestStore |
| | serverActions?: ServerActionsConfig |
| | ctx: AppRenderContext |
| | metadata: AppPageRenderResultMetadata |
| | }): Promise<HandleActionResult> { |
| | const contentType = req.headers['content-type'] |
| | const { page } = ctx.renderOpts |
| | const serverModuleMap = getServerModuleMap() |
| |
|
| | const { |
| | actionId, |
| | isMultipartAction, |
| | isFetchAction, |
| | isURLEncodedAction, |
| | isPossibleServerAction, |
| | } = getServerActionRequestMetadata(req) |
| |
|
| | const handleUnrecognizedFetchAction = (err: unknown): HandleActionResult => { |
| | |
| | |
| | console.warn(err) |
| |
|
| | |
| | |
| | |
| | |
| | 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 (!isPossibleServerAction) { |
| | return null |
| | } |
| |
|
| | |
| | |
| | if (isURLEncodedAction) { |
| | if (isFetchAction) { |
| | return { |
| | type: 'not-found', |
| | } |
| | } else { |
| | |
| | return null |
| | } |
| | } |
| |
|
| | |
| | if (!hasServerActions()) { |
| | return handleUnrecognizedFetchAction(getActionNotFoundError(actionId)) |
| | } |
| |
|
| | if (workStore.isStaticGeneration) { |
| | throw new Error( |
| | "Invariant: server actions can't be handled during static rendering" |
| | ) |
| | } |
| |
|
| | let temporaryReferences: TemporaryReferenceSet | undefined |
| |
|
| | |
| | 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: string | undefined = undefined |
| |
|
| | function warnBadServerActionRequest() { |
| | if (warning) { |
| | warn(warning) |
| | } |
| | } |
| | |
| | |
| | if (!originDomain) { |
| | |
| | |
| | warning = 'Missing `origin` header from a forwarded Server Actions request.' |
| | } else if (!host || originDomain !== host.value) { |
| | |
| | |
| | |
| | if (isCsrfOriginAllowed(originDomain, serverActions?.allowedOrigins)) { |
| | |
| | } else { |
| | if (host) { |
| | |
| | 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 { |
| | |
| | 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 = new Error('Invalid Server Actions request.') |
| |
|
| | if (isFetchAction) { |
| | res.statusCode = 500 |
| | metadata.statusCode = 500 |
| |
|
| | const promise = Promise.reject(error) |
| | try { |
| | |
| | |
| | |
| | |
| | await promise |
| | } catch { |
| | |
| | } |
| |
|
| | return { |
| | type: 'done', |
| | result: await generateFlight(req, ctx, requestStore, { |
| | actionResult: promise, |
| | |
| | |
| | skipPageRendering: true, |
| | temporaryReferences, |
| | }), |
| | } |
| | } |
| |
|
| | throw error |
| | } |
| | } |
| |
|
| | |
| | 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) { |
| | return { |
| | type: 'done', |
| | result: await createForwardedActionResponse( |
| | req, |
| | res, |
| | host, |
| | forwardedWorker, |
| | ctx.renderOpts.basePath |
| | ), |
| | } |
| | } |
| | } |
| |
|
| | try { |
| | return await actionAsyncStorage.run( |
| | { isAction: true }, |
| | async (): Promise<HandleActionResult> => { |
| | |
| | let actionModId: string | number | undefined |
| | let boundActionArguments: unknown[] = [] |
| |
|
| | if ( |
| | |
| | |
| | process.env.NEXT_RUNTIME === 'edge' && |
| | isWebNextRequest(req) |
| | ) { |
| | if (!req.body) { |
| | throw new Error('invariant: Missing request body.') |
| | } |
| |
|
| | |
| |
|
| | |
| | const { |
| | createTemporaryReferenceSet, |
| | decodeReply, |
| | decodeAction, |
| | decodeFormState, |
| | } = ComponentMod |
| |
|
| | temporaryReferences = createTemporaryReferenceSet() |
| |
|
| | if (isMultipartAction) { |
| | |
| | const formData = await req.request.formData() |
| | if (isFetchAction) { |
| | |
| |
|
| | try { |
| | actionModId = getActionModIdOrError(actionId, serverModuleMap) |
| | } catch (err) { |
| | return handleUnrecognizedFetchAction(err) |
| | } |
| |
|
| | boundActionArguments = await decodeReply( |
| | formData, |
| | serverModuleMap, |
| | { temporaryReferences } |
| | ) |
| | } else { |
| | |
| | |
| | if (areAllActionIdsValid(formData, serverModuleMap) === false) { |
| | |
| | |
| | throw 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` |
| | ) |
| | } |
| |
|
| | const action = await decodeAction(formData, serverModuleMap) |
| | if (typeof action === 'function') { |
| | |
| |
|
| | |
| | warnBadServerActionRequest() |
| |
|
| | const { actionResult } = await executeActionAndPrepareForRender( |
| | action as () => Promise<unknown>, |
| | [], |
| | workStore, |
| | requestStore, |
| | actionWasForwarded |
| | ) |
| |
|
| | const formState = await decodeFormState( |
| | actionResult, |
| | formData, |
| | serverModuleMap |
| | ) |
| |
|
| | |
| | |
| | return { |
| | type: 'done', |
| | result: undefined, |
| | formState, |
| | } |
| | } else { |
| | |
| | return null |
| | } |
| | } |
| | } else { |
| | |
| |
|
| | |
| | |
| | if (!isFetchAction) { |
| | return null |
| | } |
| |
|
| | try { |
| | actionModId = getActionModIdOrError(actionId, serverModuleMap) |
| | } catch (err) { |
| | return handleUnrecognizedFetchAction(err) |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | 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 decodeReply( |
| | actionData, |
| | serverModuleMap, |
| | { temporaryReferences } |
| | ) |
| | } |
| | } else if ( |
| | |
| | |
| | process.env.NEXT_RUNTIME !== 'edge' && |
| | isNodeNextRequest(req) |
| | ) { |
| | |
| | const { |
| | createTemporaryReferenceSet, |
| | decodeReply, |
| | decodeReplyFromBusboy, |
| | decodeAction, |
| | decodeFormState, |
| | } = require( |
| | `./react-server.node` |
| | ) as typeof import('./react-server.node') |
| |
|
| | temporaryReferences = createTemporaryReferenceSet() |
| |
|
| | const { PassThrough, Readable, Transform } = |
| | require('node:stream') as typeof import('node:stream') |
| | const { pipeline } = |
| | require('node:stream/promises') as typeof import('node:stream/promises') |
| |
|
| | const defaultBodySizeLimit = '1 MB' |
| | const bodySizeLimit = |
| | serverActions?.bodySizeLimit ?? defaultBodySizeLimit |
| | const bodySizeLimitBytes = |
| | bodySizeLimit !== defaultBodySizeLimit |
| | ? ( |
| | require('next/dist/compiled/bytes') as typeof import('next/dist/compiled/bytes') |
| | ).parse(bodySizeLimit) |
| | : 1024 * 1024 |
| |
|
| | let size = 0 |
| | const sizeLimitTransform = new Transform({ |
| | transform(chunk, encoding, callback) { |
| | size += Buffer.byteLength(chunk, encoding) |
| | if (size > bodySizeLimitBytes) { |
| | const { ApiError } = |
| | require('../api-utils') as typeof import('../api-utils') |
| |
|
| | callback( |
| | 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` |
| | ) |
| | ) |
| | return |
| | } |
| |
|
| | callback(null, chunk) |
| | }, |
| | }) |
| |
|
| | if (isMultipartAction) { |
| | if (isFetchAction) { |
| | |
| |
|
| | try { |
| | actionModId = getActionModIdOrError(actionId, serverModuleMap) |
| | } catch (err) { |
| | return handleUnrecognizedFetchAction(err) |
| | } |
| |
|
| | const busboy = ( |
| | require('next/dist/compiled/busboy') as typeof import('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 { |
| | |
| | |
| |
|
| | const sizeLimitedBody = new PassThrough() |
| |
|
| | |
| | |
| | const fakeRequest = new Request('http://localhost', { |
| | method: 'POST', |
| | |
| | headers: { 'Content-Type': contentType }, |
| | body: Readable.toWeb( |
| | sizeLimitedBody |
| | ) as ReadableStream<Uint8Array>, |
| | duplex: 'half', |
| | }) |
| |
|
| | let formData: 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) { |
| | |
| | |
| | throw 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` |
| | ) |
| | } |
| |
|
| | |
| | |
| | const action = await decodeAction(formData, serverModuleMap) |
| | if (typeof action === 'function') { |
| | |
| |
|
| | |
| | warnBadServerActionRequest() |
| |
|
| | const { actionResult } = await executeActionAndPrepareForRender( |
| | action as () => Promise<unknown>, |
| | [], |
| | workStore, |
| | requestStore, |
| | actionWasForwarded |
| | ) |
| |
|
| | const formState = await decodeFormState( |
| | actionResult, |
| | formData, |
| | serverModuleMap |
| | ) |
| |
|
| | |
| | |
| | return { |
| | type: 'done', |
| | result: undefined, |
| | formState, |
| | } |
| | } else { |
| | |
| | return null |
| | } |
| | } |
| | } else { |
| | |
| |
|
| | |
| | |
| | if (!isFetchAction) { |
| | return null |
| | } |
| |
|
| | try { |
| | actionModId = getActionModIdOrError(actionId, serverModuleMap) |
| | } catch (err) { |
| | return handleUnrecognizedFetchAction(err) |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | const sizeLimitedBody = new PassThrough() |
| |
|
| | const chunks: Buffer[] = [] |
| | 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 new Error('Invariant: Unknown request type.') |
| | } |
| |
|
| | |
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | |
| |
|
| | const actionMod = (await ComponentMod.__next_app__.require( |
| | actionModId |
| | )) as Record<string, (...args: unknown[]) => Promise<unknown>> |
| | const actionHandler = |
| | actionMod[ |
| | |
| | actionId! |
| | ] |
| |
|
| | const { actionResult, skipPageRendering } = |
| | await executeActionAndPrepareForRender( |
| | actionHandler, |
| | boundActionArguments, |
| | workStore, |
| | requestStore, |
| | actionWasForwarded |
| | ).finally(() => { |
| | addRevalidationHeader(res, { workStore, requestStore }) |
| | }) |
| |
|
| | |
| | if (isFetchAction) { |
| | |
| | |
| | |
| | const maybeRevalidatesPromise = skipPageRendering |
| | ? executeRevalidates(workStore) |
| | : false |
| |
|
| | return { |
| | type: 'done', |
| | result: await generateFlight(req, ctx, requestStore, { |
| | actionResult: Promise.resolve(actionResult), |
| | skipPageRendering, |
| | temporaryReferences, |
| | waitUntil: |
| | maybeRevalidatesPromise === false |
| | ? undefined |
| | : maybeRevalidatesPromise, |
| | }), |
| | } |
| | } else { |
| | |
| | |
| | return null |
| | } |
| | } |
| | ) |
| | } catch (err) { |
| | if (isRedirectError(err)) { |
| | const redirectUrl = getURLFromRedirectError(err) |
| | const redirectType = getRedirectTypeFromError(err) |
| |
|
| | |
| | |
| | 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 |
| | ), |
| | } |
| | } |
| |
|
| | |
| | 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 { |
| | |
| | |
| | |
| | |
| | await promise |
| | } catch { |
| | |
| | } |
| | return { |
| | type: 'done', |
| | result: await generateFlight(req, ctx, requestStore, { |
| | skipPageRendering: false, |
| | actionResult: promise, |
| | temporaryReferences, |
| | }), |
| | } |
| | } |
| |
|
| | |
| | return { |
| | type: 'not-found', |
| | } |
| | } |
| |
|
| | |
| | |
| |
|
| | if (isFetchAction) { |
| | |
| | |
| | |
| | res.statusCode = 500 |
| | metadata.statusCode = 500 |
| | const promise = Promise.reject(err) |
| | try { |
| | |
| | |
| | |
| | |
| | await promise |
| | } catch { |
| | |
| | } |
| |
|
| | return { |
| | type: 'done', |
| | result: await generateFlight(req, ctx, requestStore, { |
| | actionResult: promise, |
| | |
| | |
| | skipPageRendering: |
| | workStore.pathWasRevalidated === undefined || |
| | workStore.pathWasRevalidated === ActionDidNotRevalidate || |
| | actionWasForwarded, |
| | temporaryReferences, |
| | }), |
| | } |
| | } |
| |
|
| | |
| | throw err |
| | } |
| | } |
| |
|
| | async function executeActionAndPrepareForRender< |
| | TFn extends (...args: any[]) => Promise<any>, |
| | >( |
| | action: TFn, |
| | args: Parameters<TFn>, |
| | workStore: WorkStore, |
| | requestStore: RequestStore, |
| | actionWasForwarded: boolean |
| | ): Promise<{ |
| | actionResult: Awaited<ReturnType<TFn>> |
| | skipPageRendering: boolean |
| | }> { |
| | requestStore.phase = 'action' |
| | let skipPageRendering = actionWasForwarded |
| |
|
| | try { |
| | const actionResult = await workUnitAsyncStorage.run(requestStore, () => |
| | action.apply(null, args) |
| | ) |
| |
|
| | |
| | |
| | skipPageRendering ||= |
| | workStore.pathWasRevalidated === undefined || |
| | workStore.pathWasRevalidated === ActionDidNotRevalidate |
| |
|
| | return { actionResult, skipPageRendering } |
| | } finally { |
| | if (!skipPageRendering) { |
| | requestStore.phase = 'render' |
| |
|
| | |
| | |
| | |
| | |
| | |
| | synchronizeMutableCookies(requestStore) |
| |
|
| | |
| | |
| | workStore.isDraftMode = requestStore.draftMode.isEnabled |
| |
|
| | |
| | |
| | |
| | await executeRevalidates(workStore) |
| | } |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | function getActionModIdOrError( |
| | actionId: string | null, |
| | serverModuleMap: ServerModuleMap |
| | ): string | number { |
| | |
| | if (!actionId) { |
| | throw new InvariantError("Missing 'next-action' header.") |
| | } |
| |
|
| | const actionModId = serverModuleMap[actionId]?.id |
| |
|
| | if (!actionModId) { |
| | throw getActionNotFoundError(actionId) |
| | } |
| |
|
| | return actionModId |
| | } |
| |
|
| | function getActionNotFoundError(actionId: string | null): Error { |
| | return 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` |
| | ) |
| | } |
| |
|
| | const $ACTION_ = '$ACTION_' |
| | const $ACTION_REF_ = '$ACTION_REF_' |
| | const $ACTION_ID_ = '$ACTION_ID_' |
| | const ACTION_ID_EXPECTED_LENGTH = 42 |
| |
|
| | |
| | |
| | |
| | |
| | |
| | function areAllActionIdsValid( |
| | mpaFormData: FormData, |
| | serverModuleMap: ServerModuleMap |
| | ): boolean { |
| | let hasAtLeastOneAction = false |
| | |
| | |
| | for (let key of mpaFormData.keys()) { |
| | if (!key.startsWith($ACTION_)) { |
| | |
| | continue |
| | } |
| |
|
| | if (key.startsWith($ACTION_ID_)) { |
| | |
| | if (isInvalidActionIdFieldName(key, serverModuleMap)) { |
| | return false |
| | } |
| |
|
| | hasAtLeastOneAction = true |
| | } else if (key.startsWith($ACTION_REF_)) { |
| | |
| | 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: string, |
| | serverModuleMap: ServerModuleMap |
| | ): unknown { |
| | if (actionDescriptor.startsWith(ACTION_DESCRIPTOR_ID_PREFIX) === false) { |
| | return true |
| | } |
| |
|
| | const from = ACTION_DESCRIPTOR_ID_PREFIX.length |
| | const to = from + ACTION_ID_EXPECTED_LENGTH |
| |
|
| | |
| | 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: string, |
| | serverModuleMap: ServerModuleMap |
| | ): boolean { |
| | |
| | |
| | |
| | if ( |
| | actionIdFieldName.length !== |
| | $ACTION_ID_.length + ACTION_ID_EXPECTED_LENGTH |
| | ) { |
| | |
| | return true |
| | } |
| |
|
| | const actionId = actionIdFieldName.slice($ACTION_ID_.length) |
| | const entry = serverModuleMap[actionId] |
| |
|
| | if (entry == null) { |
| | return true |
| | } |
| |
|
| | return false |
| | } |
| |
|