diff --git a/knip.ts b/knip.ts index c1536aa90..89d271e0e 100644 --- a/knip.ts +++ b/knip.ts @@ -92,7 +92,10 @@ export default { "src/server/app-page-element-builder.ts", "src/server/app-hook-warning-suppression.ts", "src/server/app-post-middleware-context.ts", + "src/server/app-request-stage-context.ts", + "src/server/app-request-stage-independent-entry.ts", "src/server/app-request-context.ts", + "src/server/app-route-handler-middleware-context.ts", "src/server/app-rsc-error-handler.ts", "src/server/isr-cache.ts", "src/server/rsc-stream-hints.ts", diff --git a/packages/cloudflare/src/cache/cdn-adapter.runtime.ts b/packages/cloudflare/src/cache/cdn-adapter.runtime.ts index 821740b82..a19bc32c0 100644 --- a/packages/cloudflare/src/cache/cdn-adapter.runtime.ts +++ b/packages/cloudflare/src/cache/cdn-adapter.runtime.ts @@ -183,6 +183,10 @@ function formatCacheTag(tags: readonly string[]): string | null { export class CloudflareCdnCacheAdapter implements CdnCacheAdapter { readonly requiresCompletedResponseAdmission = true; + readonly responsePolicyHeaderNames = [ + "CDN-Cache-Control", + "Cloudflare-CDN-Cache-Control", + ] as const; readonly responseVary = "verbatim" as const; constructor( diff --git a/packages/cloudflare/src/cache/cdn-adapter.ts b/packages/cloudflare/src/cache/cdn-adapter.ts index e867edf11..672f5c0b8 100644 --- a/packages/cloudflare/src/cache/cdn-adapter.ts +++ b/packages/cloudflare/src/cache/cdn-adapter.ts @@ -50,6 +50,7 @@ export function cdnAdapter(options?: CdnAdapterOptions) { options, capabilities: { buildIdentity: "response-header" as const, + responsePolicyHeaderNames: ["CDN-Cache-Control", "Cloudflare-CDN-Cache-Control"] as const, responseVary: "verbatim" as const, routeCacheability: "probe-manifest" as const, }, diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 45a274542..34b1bf2c7 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -35,6 +35,7 @@ import { findVinextPrerenderConfigInPlugins, findVinextRouteRootConfigInPlugins, formatVinextPrerenderLabel, + getConfiguredCdnResponsePolicyHeaderNames, hasBuildIdentityResponseHeader, hasVerbatimResponseVary, requiresRouteCacheabilityProbeManifest, @@ -1921,6 +1922,9 @@ export async function deploy(options: DeployOptions): Promise { nextConfig, buildIdentity: hasBuildIdentityHeader ? "response-header" : undefined, responseVary: hasStrictResponseVary ? "verbatim" : undefined, + responsePolicyHeaderNames: getConfiguredCdnResponsePolicyHeaderNames( + viteConfigMetadata.cacheConfig, + ), routeRootConfig: viteConfigMetadata.routeRootConfig, }); } @@ -1993,6 +1997,9 @@ export async function deploy(options: DeployOptions): Promise { nextConfig, buildIdentity: hasBuildIdentityHeader ? "response-header" : undefined, responseVary: hasStrictResponseVary ? "verbatim" : undefined, + responsePolicyHeaderNames: getConfiguredCdnResponsePolicyHeaderNames( + viteConfigMetadata.cacheConfig, + ), routeRootConfig: viteConfigMetadata.routeRootConfig, pathDiscoveryTarget: { baseUrl: targetUrl, diff --git a/packages/vinext/package.json b/packages/vinext/package.json index 21764aed0..b6b108e43 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -68,6 +68,18 @@ "types": "./dist/server/fetch-handler.d.ts", "import": "./dist/server/fetch-handler.js" }, + "./server/multi-stage": { + "types": "./dist/server/multi-stage.d.ts", + "import": "./dist/server/multi-stage.js" + }, + "./server/request-stage": { + "types": "./dist/server/request-stage.d.ts", + "import": "./dist/server/request-stage.js" + }, + "./server/response-stage": { + "types": "./dist/server/response-stage.d.ts", + "import": "./dist/server/response-stage.js" + }, "./server/app-router-entry": { "types": "./dist/server/app-router-entry.d.ts", "import": "./dist/server/app-router-entry.js" diff --git a/packages/vinext/src/build/client-build-config.ts b/packages/vinext/src/build/client-build-config.ts index 4d90db255..2d2f1c501 100644 --- a/packages/vinext/src/build/client-build-config.ts +++ b/packages/vinext/src/build/client-build-config.ts @@ -236,6 +236,23 @@ export function isRscFrameworkModule(id: string): boolean { return pkg !== null && (FRAMEWORK_PACKAGES as readonly string[]).includes(pkg); } +/** + * Keep virtual entry ids out of emitted RSC chunk filenames. + * + * Rolldown's entries-aware chunk names can contain the `\\0` virtual-id marker, + * which is not a portable module-specifier or filesystem name. Preserve every + * other character and remove both the printable and actual-NUL forms. + */ +export function sanitizeRscChunkFileName(name: string): string { + const withoutVirtualMarkers = name.replaceAll("\\0", ""); + const invalid = new Set(["<", ">", ":", '"', "/", "\\", "|", "?", "*"]); + let sanitized = ""; + for (const character of withoutVirtualMarkers) { + sanitized += character.charCodeAt(0) <= 31 || invalid.has(character) ? "_" : character; + } + return sanitized; +} + /** * Output config that isolates React (and the RSC flight runtime) into a * dedicated "framework" chunk in the RSC server build. See @@ -245,6 +262,7 @@ export function isRscFrameworkModule(id: string): boolean { */ export function createRscFrameworkChunkOutputConfig() { return { + sanitizeFileName: sanitizeRscChunkFileName, codeSplitting: { groups: [ { @@ -287,3 +305,67 @@ export function withBuildBundlerOptions( ): Partial { return { rolldownOptions: bundlerOptions }; } + +type VinextBuildOutput = Exclude< + NonNullable, + readonly unknown[] +>; +type VinextCodeSplittingConfig = Exclude, boolean>; +type ChunkFileNames = NonNullable; +type ChunkFileNameFunction = Exclude; + +/** + * Keep vinext modules partitioned by the stage entries that actually use them. + * Without an entry-aware catch-all, Rolldown may merge a small helper shared by + * request/response entries into a response-heavy chunk; importing that helper + * then evaluates React and renderer code on a request-stage cache hit. + */ +export function createMultiStageCodeSplittingConfig( + existing: VinextBuildOutput["codeSplitting"], +): VinextCodeSplittingConfig & { groups: NonNullable } { + const base = existing && typeof existing === "object" ? existing : {}; + return { + ...base, + groups: [ + { + name: "vinext-stage-runtime", + test: /(?:^|[/\\])(?:packages[/\\]vinext[/\\]src|(?:packages[/\\]vinext|node_modules[/\\](?:\.pnpm[/\\][^/\\]+[/\\]node_modules[/\\])?vinext)[/\\]dist)[/\\]/, + entriesAware: true, + }, + ...(base.groups ?? []), + ], + }; +} + +/** + * Keep router stage chunks beside the server entry so their generated + * `./vinext-client-assets.js` external continues to resolve. Other chunks keep + * the host's existing output pattern (or vinext's server-assets default). + */ +export function createMultiStageChunkFileNames( + assetsDir: string, + existing: VinextBuildOutput["chunkFileNames"], +): ChunkFileNameFunction { + return (chunk) => { + const name = sanitizeRscChunkFileName(chunk.name); + if ( + chunk.moduleIds?.some((id) => + /\/server\/app-ssr-entry\.[cm]?[jt]sx?$/.test(toSlash(id.split("?", 1)[0] ?? "")), + ) || + [ + "app-router-entry", + "pages-router-entry", + "app-response-stage-entry", + "pages-request-stage-entry", + "pages-response-stage-entry", + "virtual_vinext-rsc-entry", + "virtual_vinext-response-stage", + ].some((entryName) => name.includes(entryName)) + ) { + return `${name}-[hash].js`; + } + if (typeof existing === "function") return existing({ ...chunk, name }); + const pattern = existing ?? joinAssetFileNamePattern(assetsDir, "[name]-[hash].js"); + return pattern.replaceAll("[name]", name); + }; +} diff --git a/packages/vinext/src/build/inject-pregenerated-paths.ts b/packages/vinext/src/build/inject-pregenerated-paths.ts index d10b31bc6..c2b298d79 100644 --- a/packages/vinext/src/build/inject-pregenerated-paths.ts +++ b/packages/vinext/src/build/inject-pregenerated-paths.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "pathslash"; import { readPrerenderManifest } from "../server/prerender-manifest.js"; +import { PREGENERATED_CONCRETE_PATHS_MODULE } from "../server/pregenerated-concrete-paths.js"; import { escapeRegExp } from "../utils/regex.js"; declare global { @@ -15,14 +16,33 @@ const VINEXT_PREGEN_RE = new RegExp( ); export function injectPregeneratedConcretePaths(root: string): void { - const workerEntry = path.resolve(root, "dist", "server", "index.js"); - if (!fs.existsSync(workerEntry)) return; + const serverDir = path.resolve(root, "dist", "server"); + const workerEntry = path.join(serverDir, "index.js"); + const manifest = readPrerenderManifest(path.join(serverDir, "vinext-prerender.json")); + const table = manifest?.pregeneratedConcretePaths ?? []; - let code = fs.readFileSync(workerEntry, "utf-8").replace(VINEXT_PREGEN_RE, ""); - const manifest = readPrerenderManifest( - path.join(root, "dist", "server", "vinext-prerender.json"), + // Response-stage entries can be deployed independently of index.js. Keep the + // table in a stable side-effect module imported by every generated App + // response graph so those deployments retain the same PPR fallback guard. + // The file is emitted during the server build; writing it after prerendering + // updates the artifact without rebuilding or coupling core to a host + // transport. + const runtimeModule = path.join(serverDir, PREGENERATED_CONCRETE_PATHS_MODULE); + fs.mkdirSync(serverDir, { recursive: true }); + fs.writeFileSync( + runtimeModule, + table.length > 0 + ? `globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = ${JSON.stringify(table)};\n` + : "delete globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS;\n", ); - const table = manifest?.pregeneratedConcretePaths ?? []; + + if (!fs.existsSync(workerEntry)) { + if (table.length > 0) globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = table; + else delete globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS; + return; + } + + let code = fs.readFileSync(workerEntry, "utf-8").replace(VINEXT_PREGEN_RE, ""); if (table.length > 0) { globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = table; diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index c27cadb37..c307eeade 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -4,6 +4,7 @@ import type { Server as HttpServer } from "node:http"; import { loadNextConfig, resolveNextConfig, + type NextRewrite, type ResolvedNextConfig, } from "../config/next-config.js"; import { @@ -33,13 +34,14 @@ import { VINEXT_PRERENDER_SECRET_HEADER } from "../server/headers.js"; import type { VinextRouteRootConfig } from "../config/prerender.js"; import { enterPrerenderPhase } from "./prerender-phase.js"; import type { CdnCacheAdapterCapabilities } from "../cache/cache-adapters-virtual.js"; -import { matchHeaders, matchesRewriteSource } from "../config/config-matchers.js"; +import { isExternalUrl, matchHeaders, matchesRewriteSource } from "../config/config-matchers.js"; import { pagesRouteHasPriorityOverAppRoute } from "../server/hybrid-route-priority.js"; import { resolveAppPageDynamicConfig } from "../server/app-segment-config.js"; import { extractLocaleFromUrl, normalizeDefaultLocalePathname } from "../server/pages-i18n.js"; import { normalizePathTrailingSlash } from "vinext/shims/url-utils"; import { buildPagesDataHref } from "vinext/shims/internal/pages-data-url"; import { CACHEABILITY_POLICY_HEADERS } from "vinext/shims/cacheability-classification"; +import { resolveBuiltRscEntryPath } from "./server-entry.js"; export type PrerenderRoutePattern = { kind: "app-page" | "app-route" | "pages-page"; @@ -49,9 +51,12 @@ export type PrerenderRoutePattern = { canPrunePattern: boolean; /** HTML pathname shared by alternate representations of this route. */ concretePathname?: string; + /** The uncached request stage may resolve this public path to another route. */ + routeMayResolve?: boolean; + /** A request representation may terminate before reaching the response stage. */ + requestStageMayTerminate?: boolean; }; }; - export type PrerenderPathManifest = { /** App Page HTML paths after hybrid route ownership has been resolved. */ appPaths?: string[]; @@ -125,6 +130,8 @@ type EmitPrerenderPathManifestOptions = { rscBundlePath?: string; buildIdentity?: CdnCacheAdapterCapabilities["buildIdentity"]; responseVary?: CdnCacheAdapterCapabilities["responseVary"]; + requestRouting?: CdnCacheAdapterCapabilities["requestRouting"]; + responsePolicyHeaderNames?: CdnCacheAdapterCapabilities["responsePolicyHeaderNames"]; /** Execute dynamic path hooks against an already-uploaded Worker. */ pathDiscoveryTarget?: { baseUrl: string; @@ -504,6 +511,7 @@ async function collectPagesPaths(options: { }): Promise<{ dataPaths: string[]; fallbackRoutePatterns: PrerenderRoutePattern[]; + nonDynamicPaths: string[]; paths: string[]; }> { const [pageRoutes, apiRoutes] = await Promise.all([ @@ -516,6 +524,8 @@ async function collectPagesPaths(options: { const dataPaths: string[] = []; const seenDataPaths = new Set(); const fallbackRoutePatterns: PrerenderRoutePattern[] = []; + const nonDynamicPaths: string[] = []; + const seenNonDynamicPaths = new Set(); for (const route of pageRoutes) { if (apiPatterns.has(route.pattern)) continue; @@ -532,10 +542,12 @@ async function collectPagesPaths(options: { for (const locale of options.i18n.locales) { const pathname = localizePagesPath(route.pattern, locale, options.i18n); addPath(paths, seen, pathname); + addPath(nonDynamicPaths, seenNonDynamicPaths, pathname); if (hasStaticProps || hasServerSideProps) addPath(dataPaths, seenDataPaths, pathname); } } else { addPath(paths, seen, route.pattern); + addPath(nonDynamicPaths, seenNonDynamicPaths, route.pattern); if (hasStaticProps || hasServerSideProps) { addPath(dataPaths, seenDataPaths, route.pattern); } @@ -609,7 +621,7 @@ async function collectPagesPaths(options: { } } - return { dataPaths, fallbackRoutePatterns, paths }; + return { dataPaths, fallbackRoutePatterns, nonDynamicPaths, paths }; } async function excludePagesApiWarmPaths(options: { @@ -697,6 +709,7 @@ async function collectAppPaths(options: { }): Promise<{ fallbackRoutePatterns: PrerenderRoutePattern[]; loadingShellPaths: string[]; + nonDynamicPaths: string[]; paths: string[]; routeHandlerPaths: string[]; }> { @@ -708,6 +721,8 @@ async function collectAppPaths(options: { const routeHandlerPaths: string[] = []; const seenRouteHandlerPaths = new Set(); const fallbackRoutePatterns: PrerenderRoutePattern[] = []; + const nonDynamicPaths: string[] = []; + const seenNonDynamicPaths = new Set(); const staticParamsCache = new Map[] | null>>(); let requireNonEmptyStaticParams = false; const staticParamsMap = new Proxy({} as StaticParamsMap, { @@ -790,6 +805,7 @@ async function collectAppPaths(options: { if (!route.isDynamic) { addDiscoveredPath(route.pattern); + addPath(nonDynamicPaths, seenNonDynamicPaths, route.pattern); continue; } @@ -887,7 +903,13 @@ async function collectAppPaths(options: { } } - return { fallbackRoutePatterns, loadingShellPaths, paths, routeHandlerPaths }; + return { + fallbackRoutePatterns, + loadingShellPaths, + nonDynamicPaths, + paths, + routeHandlerPaths, + }; } async function resolveAppWarmPaths(options: { @@ -995,7 +1017,6 @@ async function resolveAppWarmPaths(options: { }; } -const CACHEABILITY_POLICY_HEADER_NAMES = new Set(CACHEABILITY_POLICY_HEADERS); function cachePolicyRuleMatchesWarmPath( pathname: string, rule: ResolvedNextConfig["headers"][number], @@ -1077,9 +1098,16 @@ function routePatternCouldIntersectCachePolicyRule( function annotateCacheabilityProbeSafety( routePatterns: Record, config: Pick, + routeMayResolve: ReadonlySet, + requestStageMayTerminate: ReadonlySet, + responsePolicyHeaderNames: readonly string[], ): Record { + const cacheabilityPolicyHeaderNames = new Set([ + ...CACHEABILITY_POLICY_HEADERS, + ...responsePolicyHeaderNames.map((name) => name.trim().toLowerCase()).filter(Boolean), + ]); const cachePolicyRules = config.headers.filter((rule) => - rule.headers.some((header) => CACHEABILITY_POLICY_HEADER_NAMES.has(header.key.toLowerCase())), + rule.headers.some((header) => cacheabilityPolicyHeaderNames.has(header.key.toLowerCase())), ); const matchingPolicyRules = new Map( Object.keys(routePatterns).map((pathname) => [ @@ -1123,19 +1151,21 @@ function annotateCacheabilityProbeSafety( pathname, { ...route, - cacheabilityProbe: { canPrunePattern }, + cacheabilityProbe: { + canPrunePattern, + ...(routeMayResolve.has(pathname) ? { routeMayResolve: true } : {}), + ...(requestStageMayTerminate.has(pathname) ? { requestStageMayTerminate: true } : {}), + }, }, ]; }), ); } -function configuredRouteAffectsWarmPath( +function configuredRulesAffectWarmPath( pathname: string, - config: Pick< - ResolvedNextConfig, - "basePath" | "i18n" | "redirects" | "rewrites" | "trailingSlash" - >, + rules: ReadonlyArray, + config: Pick, ): boolean { const canonicalPathname = normalizePathTrailingSlash(pathname, config.trailingSlash); const hostnames = [undefined, ...(config.i18n?.domains?.map((domain) => domain.domain) ?? [])]; @@ -1144,12 +1174,7 @@ function configuredRouteAffectsWarmPath( normalizeDefaultLocalePathname(canonicalPathname, config.i18n, { hostname }), ), ); - const rewrites = [ - ...config.rewrites.beforeFiles, - ...config.rewrites.afterFiles, - ...config.rewrites.fallback, - ]; - return [...rewrites, ...config.redirects].some((rule) => + return rules.some((rule) => Array.from(matchPathnames).some((matchPathname) => matchesRewriteSource(matchPathname, rule, { basePath: config.basePath, @@ -1159,6 +1184,41 @@ function configuredRouteAffectsWarmPath( ); } +/** + * Whether a configured rewrite can replace a route-owned warm response. + * Non-dynamic filesystem routes win before `afterFiles`, while every concrete + * path discovered here wins dynamic matching before `fallback`. + */ +function configuredRewritesCanReplaceWarmPath( + pathname: string, + rewrites: ResolvedNextConfig["rewrites"], + hasNonDynamicRoute: boolean, + config: Pick, + include: (rewrite: NextRewrite) => boolean, +): boolean { + const applicableRewrites = [ + ...rewrites.beforeFiles.filter(include), + ...(hasNonDynamicRoute ? [] : rewrites.afterFiles.filter(include)), + ]; + return configuredRulesAffectWarmPath(pathname, applicableRewrites, config); +} + +function hasMiddlewareConventionFile( + root: string, + appDir: string | null, + pagesDir: string | null, + pageExtensions: readonly string[], +): boolean { + const routeDir = appDir ?? pagesDir; + const routeRoot = routeDir ? path.dirname(routeDir) : root; + const conventionDir = routeRoot === path.join(root, "src") ? routeRoot : root; + return ["proxy", "middleware"].some((name) => + pageExtensions.some((extension) => + fs.existsSync(path.join(conventionDir, `${name}.${extension}`)), + ), + ); +} + async function startPathDiscoveryServer(options: { serverDir: string; pagesBundlePath?: string; @@ -1187,9 +1247,10 @@ export async function emitPrerenderPathManifest( if (!appDir && !pagesDir) return null; - const defaultRscBundlePath = options.routeRootConfig?.rscOutDir - ? path.join(path.resolve(root, options.routeRootConfig.rscOutDir), "index.js") - : path.join(root, "dist", "server", "index.js"); + const rscServerDir = options.routeRootConfig?.rscOutDir + ? path.resolve(root, options.routeRootConfig.rscOutDir) + : path.join(root, "dist", "server"); + const defaultRscBundlePath = resolveBuiltRscEntryPath(rscServerDir); const rscBundlePath = options.rscBundlePath ?? defaultRscBundlePath; const pagesBundlePath = options.pagesBundlePath ?? path.join(root, "dist", "server", "entry.js"); const bundleServerDir = fs.existsSync(rscBundlePath) @@ -1217,6 +1278,7 @@ export async function emitPrerenderPathManifest( const seenRouteHandlerPaths = new Set(); const discoveredLoadingShellPaths: string[] = []; const seenLoadingShellPaths = new Set(); + const discoveredNonDynamicPathSet = new Set(); const fallbackRoutePatterns: PrerenderRoutePattern[] = []; await withPrerenderEndpoints(async () => { let prodServer: { server: HttpServer; port: number } | null = null; @@ -1293,6 +1355,9 @@ export async function emitPrerenderPathManifest( for (const pathname of appPathResult.routeHandlerPaths) { addPath(discoveredRouteHandlerPaths, seenRouteHandlerPaths, pathname); } + for (const pathname of appPathResult.nonDynamicPaths) { + discoveredNonDynamicPathSet.add(pathname); + } fallbackRoutePatterns.push(...appPathResult.fallbackRoutePatterns); } @@ -1312,6 +1377,9 @@ export async function emitPrerenderPathManifest( for (const pathname of pagesPathResult.dataPaths) { addPath(discoveredPagesDataPaths, seenPagesDataPaths, pathname); } + for (const pathname of pagesPathResult.nonDynamicPaths) { + discoveredNonDynamicPathSet.add(pathname); + } fallbackRoutePatterns.push(...pagesPathResult.fallbackRoutePatterns); } } finally { @@ -1321,10 +1389,54 @@ export async function emitPrerenderPathManifest( } }); + const hasStagedRequestRouting = options.requestRouting === "uncached-stage"; + const middlewareMayRouteWarmPaths = + hasStagedRequestRouting && + hasMiddlewareConventionFile(root, appDir, pagesDir, config.pageExtensions); + const routedWarmPaths = [...paths, ...discoveredRouteHandlerPaths]; + const routeMayResolveWarmPathSet = new Set( + hasStagedRequestRouting + ? routedWarmPaths.filter( + (pathname) => + middlewareMayRouteWarmPaths || + configuredRewritesCanReplaceWarmPath( + pathname, + config.rewrites, + discoveredNonDynamicPathSet.has(pathname), + config, + (rewrite) => !isExternalUrl(rewrite.destination), + ), + ) + : [], + ); + const requestStageMayTerminateWarmPathSet = new Set( + hasStagedRequestRouting + ? routedWarmPaths.filter( + (pathname) => + middlewareMayRouteWarmPaths || + configuredRulesAffectWarmPath(pathname, config.redirects, config) || + configuredRewritesCanReplaceWarmPath( + pathname, + config.rewrites, + discoveredNonDynamicPathSet.has(pathname), + config, + (rewrite) => isExternalUrl(rewrite.destination), + ), + ) + : [], + ); const excludedWarmPathSet = new Set( - options.responseVary - ? [...paths, ...discoveredRouteHandlerPaths].filter((pathname) => - configuredRouteAffectsWarmPath(pathname, config), + options.responseVary && !hasStagedRequestRouting + ? routedWarmPaths.filter( + (pathname) => + configuredRulesAffectWarmPath(pathname, config.redirects, config) || + configuredRewritesCanReplaceWarmPath( + pathname, + config.rewrites, + discoveredNonDynamicPathSet.has(pathname), + config, + () => true, + ), ) : [], ); @@ -1382,7 +1494,13 @@ export async function emitPrerenderPathManifest( "", ), ); - const routePatterns = annotateCacheabilityProbeSafety(appOwnedWarmPaths.routePatterns, config); + const routePatterns = annotateCacheabilityProbeSafety( + appOwnedWarmPaths.routePatterns, + config, + routeMayResolveWarmPathSet, + requestStageMayTerminateWarmPathSet, + options.responsePolicyHeaderNames ?? [], + ); for (let index = 0; index < resolvedPagesDataWarmPaths.length; index++) { const route = routePatterns[resolvedPagesDataWarmPaths[index]]; if (route) { diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 6a849fe7a..27f958986 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -162,6 +162,183 @@ export function hasExportedName(code: string, name: string): boolean { return false; } +function astNodeField(value: unknown, field: string): unknown { + return value !== null && typeof value === "object" ? Reflect.get(value, field) : undefined; +} + +function astPropertyName(value: unknown): string | null { + const type = astNodeField(value, "type"); + if (type === "Identifier") { + const name = astNodeField(value, "name"); + return typeof name === "string" ? name : null; + } + if (type === "Literal") { + const literal = astNodeField(value, "value"); + return typeof literal === "string" ? literal : null; + } + return null; +} + +function classDefinesStaticMember(value: unknown, memberName: string): boolean { + const type = astNodeField(value, "type"); + if (type !== "ClassDeclaration" && type !== "ClassExpression") return false; + const elements = astNodeField(astNodeField(value, "body"), "body"); + return ( + Array.isArray(elements) && + elements.some((element) => { + const elementType = astNodeField(element, "type"); + return ( + (elementType === "MethodDefinition" || elementType === "PropertyDefinition") && + astNodeField(element, "static") === true && + astPropertyName(astNodeField(element, "key")) === memberName + ); + }) + ); +} + +function classHasSuperclass(value: unknown): boolean { + const type = astNodeField(value, "type"); + return ( + (type === "ClassDeclaration" || type === "ClassExpression") && + astNodeField(value, "superClass") !== null + ); +} + +function memberExpressionMatches(value: unknown, objectName: string, memberName: string): boolean { + return ( + astNodeField(value, "type") === "MemberExpression" && + astPropertyName(astNodeField(value, "object")) === objectName && + astPropertyName(astNodeField(value, "property")) === memberName + ); +} + +/** + * Whether a module's default export defines a named runtime member. + * + * Pages `_document` uses this to keep a custom `getInitialProps` implementation + * out of request-independent response caches. Direct class members, assignments, + * `Object.assign`, and `Object.defineProperty` are recognized. Imported or + * re-exported defaults and wrapped variable initializers are treated + * conservatively because their implementation is outside the source form being + * classified. + */ +export function defaultExportMayHaveRuntimeMember(code: string, memberName: string): boolean { + const program = parseRouteModule(code); + if (!program) return code.includes(memberName); + + let defaultBinding: string | null = null; + let unresolvedDefault = false; + for (const node of program.body) { + if (node.type === "ExportDefaultDeclaration") { + const declaration = node.declaration; + if (classDefinesStaticMember(declaration, memberName) || classHasSuperclass(declaration)) { + return true; + } + if ( + declaration.type === "Identifier" || + declaration.type === "ClassDeclaration" || + declaration.type === "FunctionDeclaration" + ) { + defaultBinding = + declaration.type === "Identifier" ? declaration.name : (declaration.id?.name ?? null); + } else { + unresolvedDefault = true; + } + continue; + } + if (node.type !== "ExportNamedDeclaration" || node.exportKind === "type") continue; + for (const specifier of node.specifiers) { + if (specifier.exportKind === "type") continue; + if (moduleExportNameValue(specifier.exported ?? specifier.local) !== "default") continue; + if (node.source) return true; + defaultBinding = moduleExportNameValue(specifier.local); + } + } + + if (!defaultBinding) return unresolvedDefault; + + let foundLocalDeclaration = false; + for (const node of program.body) { + if (node.type === "ImportDeclaration") { + if ( + node.specifiers.some( + (specifier) => + specifier.local.type === "Identifier" && specifier.local.name === defaultBinding, + ) + ) { + return true; + } + continue; + } + + const declaration = + node.type === "ExportDefaultDeclaration" || node.type === "ExportNamedDeclaration" + ? node.declaration + : node; + if (declaration?.type === "ClassDeclaration" && declaration.id?.name === defaultBinding) { + foundLocalDeclaration = true; + if (classDefinesStaticMember(declaration, memberName) || classHasSuperclass(declaration)) { + return true; + } + continue; + } + if (declaration?.type === "FunctionDeclaration" && declaration.id?.name === defaultBinding) { + foundLocalDeclaration = true; + continue; + } + if (declaration?.type === "VariableDeclaration") { + for (const variable of declaration.declarations) { + if (bindingName(variable.id) !== defaultBinding) continue; + foundLocalDeclaration = true; + if ( + classDefinesStaticMember(variable.init, memberName) || + classHasSuperclass(variable.init) + ) { + return true; + } + const initializerType = astNodeField(variable.init, "type"); + if ( + initializerType !== "ArrowFunctionExpression" && + initializerType !== "FunctionExpression" && + initializerType !== "ClassExpression" + ) { + return true; + } + } + continue; + } + if (node.type !== "ExpressionStatement") continue; + + const expression = node.expression; + if ( + expression.type === "AssignmentExpression" && + memberExpressionMatches(expression.left, defaultBinding, memberName) + ) { + return true; + } + if (expression.type !== "CallExpression") continue; + const callee = expression.callee; + const isObjectAssign = memberExpressionMatches(callee, "Object", "assign"); + const isObjectDefineProperty = memberExpressionMatches(callee, "Object", "defineProperty"); + if (!isObjectAssign && !isObjectDefineProperty) continue; + if (astPropertyName(expression.arguments[0]) !== defaultBinding) continue; + if (isObjectDefineProperty && astPropertyName(expression.arguments[1]) === memberName) { + return true; + } + if (isObjectAssign) { + const properties = astNodeField(expression.arguments[1], "properties"); + if ( + Array.isArray(properties) && + properties.some((property) => astPropertyName(astNodeField(property, "key")) === memberName) + ) { + return true; + } + } + } + + return !foundLocalDeclaration; +} + function hasNamedExportInProgram(program: Program, name: string): boolean { for (const node of program.body) { if (node.type !== "ExportNamedDeclaration") continue; diff --git a/packages/vinext/src/build/run-prerender.ts b/packages/vinext/src/build/run-prerender.ts index 371f91eaf..3eadf4d27 100644 --- a/packages/vinext/src/build/run-prerender.ts +++ b/packages/vinext/src/build/run-prerender.ts @@ -37,6 +37,7 @@ import { injectPregeneratedConcretePaths } from "./inject-pregenerated-paths.js" import { rememberCurrentServerEntryImportMtime, startProdServer } from "../server/prod-server.js"; import { enterPrerenderPhase } from "./prerender-phase.js"; import { PHASE_PRODUCTION_BUILD } from "vinext/shims/constants"; +import { resolveBuiltRscEntryPath } from "./server-entry.js"; // ─── Progress UI ────────────────────────────────────────────────────────────── @@ -101,7 +102,8 @@ type RunPrerenderOptions = { pagesBundlePath?: string; /** * Override the path to the App Router RSC bundle. - * Defaults to `/dist/server/index.js`. + * Defaults to the App handler recorded in the server build manifest, or + * `/dist/server/index.js` for builds without a manifest. * Intended for tests that build to a custom outDir. */ rscBundlePath?: string; @@ -121,9 +123,8 @@ type RunPrerenderOptions = { * to stderr/stdout. Returns the full PrerenderResult so callers can pass it to * printBuildReport. * - * Works for both plain Node and Cloudflare Workers builds — the CF Workers - * bundle outputs `dist/server/index.js` which is a standard Node server entry, - * so no wrangler/miniflare is needed. + * Works for both plain Node and hosted builds by resolving the generated App + * handler independently of any deployment facade that owns `index.js`. * * Hybrid projects (both `app/` and `pages/` present) run both prerender * phases sharing a single prod server instance. The merged results are written @@ -167,7 +168,8 @@ export async function runPrerender(options: RunPrerenderOptions): Promise = Record> = { @@ -57,6 +76,15 @@ export type CacheAdapterDescriptor = Record name.trim().toLowerCase()) + .filter(Boolean), + ]), + ]; +} + /** * The `cache` option of the vinext() plugin: declaratively register cache * handlers instead of calling `setDataCacheHandler()` / `setCdnCacheAdapter()` @@ -87,6 +133,8 @@ export type VinextCacheConfig = { /** Public virtual module id imported by the server entries. */ export const VIRTUAL_CACHE_ADAPTERS = "virtual:vinext-cache-adapters"; +/** Request-stage module that cannot retain the data-cache adapter graph. */ +export const VIRTUAL_CDN_CACHE_ADAPTER = "virtual:vinext-cdn-cache-adapter"; // Custom metadata key attached to vinext's config plugin so deploy commands can // inspect the normalized cache descriptors after loading the user's Vite config. @@ -166,11 +214,11 @@ export function generateCacheAdaptersModule(cache?: VinextCacheConfig): string { if (data?.adapter) { lines.push(`import __vinextDataAdapterFactory from ${JSON.stringify(data.adapter)};`); - lines.push(`import { setDataCacheHandler } from "vinext/shims/cache-handler";`); + lines.push(`import { registerDataCacheHandler } from "vinext/shims/cache-handler";`); } if (cdn?.adapter) { lines.push(`import __vinextCdnAdapterFactory from ${JSON.stringify(cdn.adapter)};`); - lines.push(`import { setCdnCacheAdapter } from "vinext/shims/cdn-cache";`); + lines.push(`import { registerCdnCacheAdapter } from "vinext/shims/cdn-cache-state";`); } lines.push( @@ -196,7 +244,7 @@ export function generateCacheAdaptersModule(cache?: VinextCacheConfig): string { if (data?.adapter) { lines.push( " try {", - ` setDataCacheHandler(__vinextDataAdapterFactory({ env, options: ${inlineOptions( + ` registerDataCacheHandler(() => __vinextDataAdapterFactory({ env, options: ${inlineOptions( data.adapter, data.options, )} }));`, @@ -209,7 +257,7 @@ export function generateCacheAdaptersModule(cache?: VinextCacheConfig): string { if (cdn?.adapter) { lines.push( " try {", - ` setCdnCacheAdapter(__vinextCdnAdapterFactory({ env, options: ${inlineOptions( + ` registerCdnCacheAdapter(() => __vinextCdnAdapterFactory({ env, options: ${inlineOptions( cdn.adapter, cdn.options, )} }));`, @@ -223,3 +271,8 @@ export function generateCacheAdaptersModule(cache?: VinextCacheConfig): string { return lines.join("\n"); } + +/** Generate request-stage registration without importing a configured data adapter. */ +export function generateCdnCacheAdapterModule(cache?: VinextCacheConfig): string { + return generateCacheAdaptersModule(cache?.cdn ? { cdn: cache.cdn } : undefined); +} diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 68eef7021..1a2246da1 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -64,7 +64,9 @@ import { } from "./config/prerender.js"; import { findVinextCacheConfigInPlugins, + getConfiguredCdnResponsePolicyHeaderNames, hasBuildIdentityResponseHeader, + hasUncachedRequestRouting, hasVerbatimResponseVary, type VinextCacheConfig, } from "./cache/cache-adapters-virtual.js"; @@ -744,6 +746,12 @@ async function buildApp() { responseVary: hasVerbatimResponseVary(buildConfigMetadata.cacheConfig) ? "verbatim" : undefined, + requestRouting: hasUncachedRequestRouting(buildConfigMetadata.cacheConfig) + ? "uncached-stage" + : undefined, + responsePolicyHeaderNames: getConfiguredCdnResponsePolicyHeaderNames( + buildConfigMetadata.cacheConfig, + ), routeRootConfig: buildConfigMetadata.routeRootConfig, }); } diff --git a/packages/vinext/src/config/prerender.ts b/packages/vinext/src/config/prerender.ts index c8f9d2347..0bc7f965b 100644 --- a/packages/vinext/src/config/prerender.ts +++ b/packages/vinext/src/config/prerender.ts @@ -3,7 +3,9 @@ import { flattenPluginOptions } from "../utils/plugin-options.js"; import { isUnknownRecord } from "../utils/record.js"; export { findVinextCacheConfigInPlugins, + getConfiguredCdnResponsePolicyHeaderNames, hasBuildIdentityResponseHeader, + hasUncachedRequestRouting, hasVerbatimResponseVary, requiresRouteCacheabilityProbeManifest, loadVinextCacheConfigFromViteConfig, diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 6ff0f9178..bc5c9d64c 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -20,6 +20,7 @@ import type { } from "../config/next-config.js"; import type { ImageConfig } from "../server/image-optimization.js"; import type { AppRoute } from "../routing/app-router.js"; +import { routePatternParts } from "../routing/route-pattern.js"; import { generateDevOriginCheckCode } from "../server/dev-origin-check.js"; import { safeJsonStringify } from "../server/html.js"; import type { MetadataFileRoute } from "../server/metadata-routes.js"; @@ -50,6 +51,10 @@ const appRouteHandlerResponsePath = resolveEntryPath( "../server/app-route-handler-response.js", import.meta.url, ); +const appRouteHandlerMiddlewareContextPath = resolveEntryPath( + "../server/app-route-handler-middleware-context.js", + import.meta.url, +); const appServerActionExecutionPath = resolveEntryPath( "../server/app-server-action-execution.js", import.meta.url, @@ -87,6 +92,14 @@ const appRscRouteMatchingPath = resolveEntryPath( "../server/app-rsc-route-matching.js", import.meta.url, ); +const appRscResponseStagePath = resolveEntryPath( + "../server/app-rsc-response-stage.js", + import.meta.url, +); +const appRscCombinedHandlerPath = resolveEntryPath( + "../server/app-rsc-combined-handler.js", + import.meta.url, +); const rscStreamHintsPath = resolveEntryPath("../server/rsc-stream-hints.js", import.meta.url); const isrCachePath = resolveEntryPath("../server/isr-cache.js", import.meta.url); const thenableParamsShimPath = resolveEntryPath("../shims/thenable-params.js", import.meta.url); @@ -103,6 +116,14 @@ const appRscErrorHandlerPath = resolveEntryPath( import.meta.url, ); const appRequestContextPath = resolveEntryPath("../server/app-request-context.js", import.meta.url); +const appRequestStageContextPath = resolveEntryPath( + "../server/app-request-stage-context.js", + import.meta.url, +); +const appRequestStageDispatchPath = resolveEntryPath( + "../server/app-request-stage-dispatch.js", + import.meta.url, +); const appRouteModuleLoaderPath = resolveEntryPath( "../server/app-route-module-loader.js", import.meta.url, @@ -122,6 +143,7 @@ const appHookWarningSuppressionPath = resolveEntryPath( ); const serverGlobalsPath = resolveEntryPath("../server/server-globals.js", import.meta.url); const appPagesBridgePath = resolveEntryPath("../server/app-pages-bridge.js", import.meta.url); +const routePatternPath = resolveEntryPath("../routing/route-pattern.js", import.meta.url); /** * Resolved config options relevant to App Router request handling. @@ -209,6 +231,298 @@ type AppRouterConfig = { prerenderSecret?: string; }; +function buildAppRequestRouteMetadata(routes: AppRoute[]): unknown[] { + return routes.map((route) => ({ + ids: route.ids ?? null, + pattern: route.pattern, + patternParts: route.patternParts, + isDynamic: route.isDynamic, + params: route.params, + rootParamNames: route.rootParamNames ?? [], + page: route.pagePath ? true : null, + routeHandler: route.routePath ? true : null, + routeSegments: route.routeSegments, + layouts: [], + layoutTreePositions: [], + slots: Object.fromEntries( + route.parallelSlots.map((slot) => [ + slot.key, + { + id: slot.id ?? null, + name: slot.name, + intercepts: slot.interceptingRoutes.map((intercept) => ({ + id: intercept.id ?? null, + targetPattern: intercept.targetPattern, + sourceMatchPattern: intercept.sourceMatchPattern, + sourcePageSegments: intercept.sourcePageSegments, + interceptLayouts: [], + interceptLayoutSegments: intercept.layoutSegments ?? [], + interceptBranchSegments: intercept.branchSegments ?? [], + interceptLoadings: [], + interceptLoadingTreePositions: intercept.loadingTreePositions ?? [], + interceptNotFoundBranchSegments: + intercept.notFoundBranchSegments ?? intercept.branchSegments ?? [], + page: null, + notFound: null, + notFoundTreePosition: intercept.notFoundTreePosition ?? null, + params: intercept.params, + })), + }, + ]), + ), + siblingIntercepts: route.siblingIntercepts.map((intercept) => ({ + id: intercept.id ?? null, + targetPattern: intercept.targetPattern, + sourceMatchPattern: intercept.sourceMatchPattern, + sourcePageSegments: intercept.sourcePageSegments, + slotId: intercept.slotId ?? null, + interceptLayouts: [], + interceptLayoutSegments: intercept.layoutSegments ?? [], + interceptBranchSegments: intercept.branchSegments ?? [], + interceptLoadings: [], + interceptLoadingTreePositions: intercept.loadingTreePositions ?? [], + interceptNotFoundBranchSegments: + intercept.notFoundBranchSegments ?? intercept.branchSegments ?? [], + page: null, + notFound: null, + notFoundTreePosition: intercept.notFoundTreePosition ?? null, + params: intercept.params, + })), + })); +} + +/** Generate the module-free App request stage used by multi-stage Worker outputs. */ +export function generateAppRequestRscEntry( + appDir: string, + routes: AppRoute[], + middlewarePath?: string | null, + metadataRoutes?: MetadataFileRoute[], + _globalErrorPath?: string | null, + basePath?: string, + trailingSlash?: boolean, + config?: AppRouterConfig, + instrumentationPath?: string | null, +): string { + void appDir; + const bp = basePath ?? ""; + const ts = trailingSlash ?? false; + const hasPagesDir = config?.hasPagesDir ?? false; + const requestRoutes = buildAppRequestRouteMetadata(routes); + const metadataRouteMatchers = (metadataRoutes ?? []).map((route) => ({ + isDynamic: route.isDynamic, + patternParts: + route.patternParts ?? + (route.servedUrl.includes("[") ? routePatternParts(route.servedUrl) : null), + servedUrl: route.servedUrl, + type: route.type, + })); + + return ` +import ${JSON.stringify(serverGlobalsPath)}; +import { createAppRscRequestHandler } from "vinext/server/app-rsc-handler"; +import { createAppRscRouteMatcher as __createAppRscRouteMatcher } from ${JSON.stringify(appRscRouteMatchingPath)}; +import { dispatchAppRequestStage as __dispatchAppRequestStage } from ${JSON.stringify(appRequestStageDispatchPath)}; +import { registerConfiguredCacheAdapters as __registerConfiguredCacheAdapters } from "virtual:vinext-cdn-cache-adapter"; +import { clearAppRequestStageContext as __clearRequestContext, setAppRequestStageNavigationContext as setNavigationContext } from ${JSON.stringify(appRequestStageContextPath)}; +import { matchRoutePattern as __matchRoutePattern } from ${JSON.stringify(routePatternPath)}; +${ + middlewarePath + ? `import * as middlewareModule from ${JSON.stringify(toSlash(middlewarePath))}; +import { applyAppMiddleware as __applyAppMiddleware } from ${JSON.stringify(appMiddlewarePath)};` + : "" +} +${ + instrumentationPath + ? `import * as _instrumentation from ${JSON.stringify(toSlash(instrumentationPath))}; +import { ensureInstrumentationRegistered as __ensureInstrumentationRegistered } from ${JSON.stringify(instrumentationRuntimePath)};` + : "" +} +${ + hasPagesDir + ? `import { getDraftModeCookieHeader } from "next/headers"; +import * as __pagesRequestEntry from "virtual:vinext-pages-request-entry"; +import { renderPagesFallback as __renderPagesFallback } from ${JSON.stringify(appPagesBridgePath)}; +import { buildRequestHeadersFromMiddlewareResponse as __buildRequestHeadersFromMiddlewareResponse } from ${JSON.stringify(middlewareRequestHeadersPath)}; +import { decodePathParams as __decodePathParams } from ${JSON.stringify(normalizePathModulePath)}; +import { applyRouteHandlerMiddlewareContext as __applyRouteHandlerMiddlewareContext } from ${JSON.stringify(appRouteHandlerMiddlewareContextPath)};` + : "" +} + +const __basePath = ${JSON.stringify(bp)}; +const __trailingSlash = ${JSON.stringify(ts)}; +const __draftModeSecret = ${JSON.stringify(config?.draftModeSecret ?? "")}; +export const __prerenderSecret = ${JSON.stringify(config?.prerenderSecret ?? "")}; +export const __assetPrefix = ${JSON.stringify(config?.assetPrefix ?? "")}; +export { __basePath }; +export const __imageAllowedWidths = ${JSON.stringify([ + ...(config?.imageConfig?.deviceSizes ?? DEFAULT_DEVICE_SIZES), + ...(config?.imageConfig?.imageSizes ?? DEFAULT_IMAGE_SIZES), + ])}; +export const __imageConfig = ${JSON.stringify({ + qualities: config?.imageConfig?.qualities, + dangerouslyAllowSVG: config?.imageConfig?.dangerouslyAllowSVG, + dangerouslyAllowLocalIP: config?.imageConfig?.dangerouslyAllowLocalIP, + contentDispositionType: config?.imageConfig?.contentDispositionType, + contentSecurityPolicy: config?.imageConfig?.contentSecurityPolicy, + })}; +const __routes = ${JSON.stringify(requestRoutes)}; +const __routeMatcher = __createAppRscRouteMatcher(__routes); +const __metadataRouteMatchers = ${JSON.stringify(metadataRouteMatchers)}; + +function matchRoute(pathname) { return __routeMatcher.matchRoute(pathname); } +function matchRequestRoute(pathname) { return __routeMatcher.matchRequestRoute(pathname); } +function hasInterceptionId(interceptionId) { return __routeMatcher.hasInterceptionId(interceptionId); } +function __isMetadataPath(pathname) { + const parts = pathname.split("/").filter(Boolean); + return __metadataRouteMatchers.some((route) => { + const matchesBase = route.patternParts + ? __matchRoutePattern(parts, route.patternParts) !== null + : pathname === route.servedUrl; + if (matchesBase) return true; + if (!route.isDynamic) return false; + if (route.type === "sitemap") { + const prefix = route.servedUrl.slice(0, -4); + const id = pathname.startsWith(prefix + "/") && pathname.endsWith(".xml") + ? pathname.slice(prefix.length + 1, -4) + : ""; + return id !== "" && !id.includes("/"); + } + if ( + route.type === "icon" || + route.type === "apple-icon" || + route.type === "opengraph-image" || + route.type === "twitter-image" + ) { + return route.patternParts + ? parts.length > 0 && __matchRoutePattern(parts.slice(0, -1), route.patternParts) !== null + : pathname.startsWith(route.servedUrl + "/") && + !pathname.slice(route.servedUrl.length + 1).includes("/"); + } + return false; + }); +} +${generateDevOriginCheckCode(config?.allowedDevOrigins)} + +const __requestHandler = createAppRscRequestHandler({ + basePath: __basePath, + buildId: process.env.__VINEXT_BUILD_ID ?? null, + clearRequestContext: __clearRequestContext, + configHeaders: ${JSON.stringify(config?.headers ?? [])}, + configRedirects: ${JSON.stringify(config?.redirects ?? [])}, + configRewrites: ${JSON.stringify(config?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] })}, + draftModeSecret: __draftModeSecret, + dispatchMatchedPage() { throw new Error("App request stage attempted to render a page inline"); }, + dispatchMatchedRouteHandler() { throw new Error("App request stage attempted to render a route handler inline"); }, + ${ + instrumentationPath + ? `ensureInstrumentation() { return __ensureInstrumentationRegistered(_instrumentation); },` + : "" + } + i18nConfig: ${JSON.stringify(config?.i18n ?? null)}, + imageConfig: ${JSON.stringify(config?.imageConfig)}, + isMetadataRoute: __isMetadataPath, + isDev: process.env.NODE_ENV !== "production", + hasInterceptionId, + matchRoute, + matchRequestRoute, + matchInterceptRoute(pathname, sourcePathname, interceptionId) { + const intercept = __routeMatcher.findIntercept(pathname, sourcePathname, interceptionId); + if (!intercept) return null; + const route = __routes[intercept.sourceRouteIndex]; + if (!route) return null; + const params = Object.create(null); + for (const name of route.params) { + if (Object.prototype.hasOwnProperty.call(intercept.sourceMatchedParams, name)) { + params[name] = intercept.sourceMatchedParams[name]; + } + } + return { + interceptionSourceIsConcrete: intercept.sourceRouteIsConcrete, + route, + params, + }; + }, + ${ + middlewarePath + ? `runMiddleware({ cleanPathname, context, externalRewriteRequest, hadBasePath, isDataRequest, middlewareRequest, request, validateExternalRewriteRequest }) { + return __applyAppMiddleware({ + basePath: __basePath, + cleanPathname, + context, + externalRewriteRequest, + hadBasePath, + filePath: ${JSON.stringify(toSlash(middlewarePath))}, + i18nConfig: ${JSON.stringify(config?.i18n ?? null)}, + isDataRequest, + isProxy: ${JSON.stringify(isProxyFile(middlewarePath))}, + middlewareRequest, + module: middlewareModule, + request, + trailingSlash: __trailingSlash, + validateExternalRewriteRequest, + }); + },` + : "" + } + publicFiles: new Set(${JSON.stringify(config?.publicFiles ?? [])}), + registerCacheAdapters: __registerConfiguredCacheAdapters, + renderNotFound: async () => null, + ${ + hasPagesDir + ? `async renderPagesFallback({ allowRscDocumentFallback, appRouteMatch, dispatchPagesResponseStage, initialResponseHeaders, isDataRequest, isRscRequest, matchKind, middlewareContext, pathname, pagesDataRequest, request, url }) { + return __renderPagesFallback( + { allowRscDocumentFallback, appRouteMatch, initialResponseHeaders, isDataRequest, isRscRequest, matchKind, middlewareContext, pathname, pagesDataRequest, request, url }, + { + async loadPagesEntry() { + if (!dispatchPagesResponseStage) { + throw new Error("App request stage requires a Pages response-stage dispatcher"); + } + return { + ...__pagesRequestEntry, + handleApiRoute(stageRequest) { return dispatchPagesResponseStage(stageRequest, "api"); }, + renderPage(stageRequest, pagesUrl) { + const dataKind = __pagesRequestEntry.matchPageRoute?.(pagesUrl, stageRequest)?.route.dataKind; + return dispatchPagesResponseStage(stageRequest, "page", dataKind, __pagesRequestEntry.hasRequestAwareDocument); + }, + }; + }, + buildRequestHeaders: __buildRequestHeadersFromMiddlewareResponse, + decodePathParams: __decodePathParams, + applyRouteHandlerMiddlewareContext: __applyRouteHandlerMiddlewareContext, + getDraftModeCookieHeader, + } + ); + },` + : "" + } + rootParamNamesByPattern: {}, + setNavigationContext, + staticParamsMap: {}, + trailingSlash: __trailingSlash, + validateDevRequestOrigin: __validateDevRequestOrigin, +}); + +export default async function handleAppRequestStage( + request, + ctx, + dispatchResponseStage, + probeMode = null, + prerenderDiscovery = false, + trustedPrerenderState = null, +) { + return __dispatchAppRequestStage(request, ctx, dispatchResponseStage, { + basePath: __basePath, + buildId: process.env.__VINEXT_BUILD_ID ?? null, + draftModeSecret: __draftModeSecret, + handleRequest: __requestHandler, + prerenderDiscovery, + probeMode, + trustedPrerenderState, + }); +} +`; +} + /** * Generate the virtual RSC entry module. * @@ -226,6 +540,7 @@ export function generateRscEntry( trailingSlash?: boolean, config?: AppRouterConfig, instrumentationPath?: string | null, + responseStageOnly = false, ): string { const bp = basePath ?? ""; const ts = trailingSlash ?? false; @@ -343,7 +658,11 @@ ${ import { ensureInstrumentationRegistered as __ensureInstrumentationRegistered } from ${JSON.stringify(instrumentationRuntimePath)};` : "" } -import { createAppRscHandler } from "vinext/server/app-rsc-handler"; +${ + responseStageOnly + ? `import { renderAppWorkerResponseStage as __renderAppWorkerResponseStage } from ${JSON.stringify(appRscResponseStagePath)};` + : `import { createAppRscHandler } from ${JSON.stringify(appRscCombinedHandlerPath)};` +} import { registerConfiguredCacheAdapters as __registerConfiguredCacheAdapters } from "virtual:vinext-cache-adapters"; import __pagesClientAssets from "virtual:vinext-pages-client-assets"; ${ @@ -463,6 +782,7 @@ import { getRenderedConcreteUrlPathsForRoute as __getRenderedConcreteUrlPathsForRoute, initPregeneratedPathsFromGlobals as __initPregeneratedPathsFromGlobals, } from ${JSON.stringify(pregeneratedConcretePathsPath)}; +import "virtual:vinext-pregenerated-concrete-paths"; const __draftModeSecret = ${JSON.stringify(draftModeSecret)}; @@ -777,7 +1097,7 @@ ${rootParamNameEntries.join("\n")} __setPagesClientAssets(__pagesClientAssets); function __VINEXT_ACTION_OWNERS() { return ${actionOwners === undefined ? "__vinextActionOwners" : safeJsonStringify(actionOwners)}; } -const __appRscHandler = createAppRscHandler({ +${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandler = createAppRscHandler({"} basePath: __basePath, buildId: process.env.__VINEXT_BUILD_ID ?? null, ensureRouteLoaded: __ensureRouteLoaded, @@ -1445,12 +1765,33 @@ const __appRscHandler = createAppRscHandler({ }, ${ hasPagesDir - ? `async renderPagesFallback({ allowRscDocumentFallback, appRouteMatch, isDataRequest, isRscRequest, matchKind, middlewareContext, pathname, pagesDataRequest, request, url }) { + ? `async renderPagesFallback({ allowRscDocumentFallback, appRouteMatch, dispatchPagesResponseStage, initialResponseHeaders, isDataRequest, isRscRequest, matchKind, middlewareContext, pathname, pagesDataRequest, request, url }) { return __renderPagesFallback( - { allowRscDocumentFallback, appRouteMatch, isDataRequest, isRscRequest, matchKind, middlewareContext, pathname, pagesDataRequest, request, url }, + { allowRscDocumentFallback, appRouteMatch, initialResponseHeaders, isDataRequest, isRscRequest, matchKind, middlewareContext, pathname, pagesDataRequest, request, url }, { - loadPagesEntry() { - return import.meta.viteRsc.loadModule("ssr", "index"); + async loadPagesEntry() { + const __pagesEntry = await import.meta.viteRsc.loadModule("ssr", "index"); + if (!dispatchPagesResponseStage) { + return __pagesEntry; + } + return { + ...__pagesEntry, + ...(typeof __pagesEntry.handleApiRoute === "function" + ? { + handleApiRoute(stageRequest) { + return dispatchPagesResponseStage(stageRequest, "api"); + }, + } + : {}), + ...(typeof __pagesEntry.renderPage === "function" + ? { + renderPage(stageRequest, pagesUrl) { + const dataKind = __pagesEntry.matchPageRoute?.(pagesUrl, stageRequest)?.route.dataKind; + return dispatchPagesResponseStage(stageRequest, "page", dataKind, __pagesEntry.hasRequestAwareDocument); + }, + } + : {}), + }; }, buildRequestHeaders: __buildRequestHeadersFromMiddlewareResponse, decodePathParams: __decodePathParams, @@ -1466,12 +1807,46 @@ const __appRscHandler = createAppRscHandler({ staticParamsMap: generateStaticParamsMap, trailingSlash: __trailingSlash, validateDevRequestOrigin: __validateDevRequestOrigin, -}); - -export default __appRscHandler; +${ + responseStageOnly + ? `}; +export default { + handleResponseStage(request, ctx, props, options) { + return __renderAppWorkerResponseStage(__responseStageOptions, request, ctx, props, options); + }, +};` + : `}); +export default __appRscHandler;` +} if (import.meta.hot) { import.meta.hot.accept(); } `; } + +/** Generate the response-only App RSC graph used by the named cache stage. */ +export function generateAppResponseRscEntry( + appDir: string, + routes: AppRoute[], + _middlewarePath?: string | null, + metadataRoutes?: MetadataFileRoute[], + globalErrorPath?: string | null, + basePath?: string, + trailingSlash?: boolean, + config?: AppRouterConfig, + instrumentationPath?: string | null, +): string { + return generateRscEntry( + appDir, + routes, + null, + metadataRoutes, + globalErrorPath, + basePath, + trailingSlash, + config, + instrumentationPath, + true, + ); +} diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 083aa2c68..ae5799067 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -14,7 +14,7 @@ import { createValidFileMatcher } from "../routing/file-matcher.js"; import { type ResolvedNextConfig } from "../config/next-config.js"; import { isProxyFile } from "../server/middleware.js"; import { findFileWithExts } from "./pages-entry-helpers.js"; -import { hasExportedName } from "../build/report.js"; +import { defaultExportMayHaveRuntimeMember, hasExportedName } from "../build/report.js"; const _requestContextShimPath = resolveEntryPath("../shims/request-context.js", import.meta.url); const _middlewareRuntimePath = resolveEntryPath("../server/middleware-runtime.js", import.meta.url); @@ -26,7 +26,19 @@ const _pagesApiRoutePath = resolveEntryPath("../server/pages-api-route.js", impo const _serverGlobalsPath = resolveEntryPath("../server/server-globals.js", import.meta.url); const _queryUtilsPath = resolveEntryPath("../utils/query.js", import.meta.url); const _pagesPageHandlerPath = resolveEntryPath("../server/pages-page-handler.js", import.meta.url); +const _pagesRouteDataKindPath = resolveEntryPath( + "../server/pages-route-data-kind.js", + import.meta.url, +); const _isrCachePath = resolveEntryPath("../server/isr-cache.js", import.meta.url); +const _revalidationRequestPath = resolveEntryPath( + "../server/revalidation-request.js", + import.meta.url, +); +const _instrumentationRuntimePath = resolveEntryPath( + "../server/instrumentation-runtime.js", + import.meta.url, +); async function getPagesDataKind(filePath: string): Promise<"static" | "server" | "none"> { const source = await readFile(filePath, "utf8"); @@ -35,10 +47,202 @@ async function getPagesDataKind(filePath: string): Promise<"static" | "server" | return "none"; } +type GeneratePagesServerEntryOptions = { + includeMiddlewareRuntime?: boolean; + prerenderSecret?: string; +}; + +/** + * Generate the request-only Pages Worker entry used by a multi-stage output. + * It intentionally contains no page/API module imports or render runtime. + */ +export async function generatePagesRequestEntry( + pagesDir: string, + nextConfig: ResolvedNextConfig, + fileMatcher: ReturnType, + middlewarePath: string | null, + instrumentationPath: string | null, + publicFiles: string[] = [], + prerenderSecret?: string, +): Promise { + const pageRoutes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); + const apiRoutes = await apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); + const documentPath = findFileWithExts(pagesDir, "_document", fileMatcher); + const hasRequestAwareDocument = + documentPath !== null && + defaultExportMayHaveRuntimeMember(await readFile(documentPath, "utf8"), "getInitialProps"); + const pageRouteEntries = await Promise.all( + pageRoutes.map(async (route: Route) => { + const dataKind = await getPagesDataKind(route.filePath); + return ` { pattern: ${JSON.stringify(route.pattern)}, patternParts: ${JSON.stringify(route.patternParts)}, isDynamic: ${route.isDynamic}, params: ${JSON.stringify(route.params)}, dataKind: ${JSON.stringify(dataKind)} }`; + }), + ); + const apiRouteEntries = apiRoutes.map( + (route: Route) => + ` { pattern: ${JSON.stringify(route.pattern)}, patternParts: ${JSON.stringify(route.patternParts)}, isDynamic: ${route.isDynamic}, params: ${JSON.stringify(route.params)} }`, + ); + const i18nConfigJson = nextConfig?.i18n + ? JSON.stringify({ + locales: nextConfig.i18n.locales, + defaultLocale: nextConfig.i18n.defaultLocale, + localeDetection: nextConfig.i18n.localeDetection, + domains: nextConfig.i18n.domains, + }) + : "null"; + const vinextConfigJson = JSON.stringify({ + basePath: nextConfig?.basePath ?? "", + assetPrefix: nextConfig?.assetPrefix ?? "", + trailingSlash: nextConfig?.trailingSlash ?? false, + skipProxyUrlNormalize: nextConfig?.skipProxyUrlNormalize ?? false, + redirects: nextConfig?.redirects ?? [], + rewrites: nextConfig?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] }, + headers: nextConfig?.headers ?? [], + expireTime: nextConfig?.expireTime, + allowedRevalidateHeaderKeys: nextConfig?.allowedRevalidateHeaderKeys ?? [], + cacheMaxMemorySize: nextConfig?.cacheMaxMemorySize, + htmlLimitedBots: nextConfig?.htmlLimitedBots, + i18n: nextConfig?.i18n ?? null, + disableOptimizedLoading: nextConfig?.disableOptimizedLoading === true, + crossOrigin: nextConfig?.crossOrigin, + clientTraceMetadata: nextConfig?.clientTraceMetadata, + images: { + deviceSizes: nextConfig?.images?.deviceSizes, + imageSizes: nextConfig?.images?.imageSizes, + qualities: nextConfig?.images?.qualities, + dangerouslyAllowSVG: nextConfig?.images?.dangerouslyAllowSVG, + dangerouslyAllowLocalIP: nextConfig?.images?.dangerouslyAllowLocalIP, + contentDispositionType: nextConfig?.images?.contentDispositionType, + contentSecurityPolicy: nextConfig?.images?.contentSecurityPolicy, + }, + }); + const instrumentationImportCode = instrumentationPath + ? `import * as _instrumentation from ${JSON.stringify(instrumentationPath)}; +import { ensureInstrumentationRegistered as __ensureInstrumentationRegistered } from ${JSON.stringify(_instrumentationRuntimePath)};` + : ""; + const instrumentationInitCode = instrumentationPath + ? `await __ensureInstrumentationRegistered(_instrumentation);` + : ""; + const middlewareImportCode = middlewarePath + ? `import * as middlewareModule from ${JSON.stringify(middlewarePath)};` + : ""; + const middlewareExportCode = middlewarePath + ? `export async function runMiddleware(request, ctx, options) { + return __runGeneratedMiddleware({ + basePath: vinextConfig.basePath, + ctx, + filePath: ${JSON.stringify(middlewarePath)}, + i18nConfig, + isDataRequest: options?.isDataRequest === true, + isProxy: ${JSON.stringify(isProxyFile(middlewarePath))}, + module: middlewareModule, + request, + trailingSlash: vinextConfig.trailingSlash, + }); +}` + : `export async function runMiddleware() { + return { continue: true }; +}`; + + return ` +import ${JSON.stringify(_serverGlobalsPath)}; +import { runGeneratedMiddleware as __runGeneratedMiddleware } from ${JSON.stringify(_middlewareRuntimePath)}; +import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from ${JSON.stringify(_routeTriePath)}; +import { resolvePagesI18nRequest } from ${JSON.stringify(_pagesI18nPath)}; +import { normalizePagesDataRequest as __normalizePagesDataRequest, shouldAddTrailingSlashToPagesDataPath as __shouldAddTrailingSlashToPagesDataPath } from ${JSON.stringify(_pagesDataRoutePath)}; +import { isOnDemandRevalidateRequest as __isOnDemandRevalidateRequest } from ${JSON.stringify(_revalidationRequestPath)}; +${instrumentationImportCode} +${middlewareImportCode} + +${instrumentationInitCode} + +export const authorizeOnDemandRevalidate = __isOnDemandRevalidateRequest; +export const buildId = ${JSON.stringify(nextConfig?.buildId ?? null)}; +export const prerenderSecret = ${JSON.stringify(prerenderSecret ?? null)}; +const i18nConfig = ${i18nConfigJson}; +export const hasMiddleware = ${JSON.stringify(Boolean(middlewarePath))}; +export const hasRequestAwareDocument = ${JSON.stringify(hasRequestAwareDocument)}; +export const vinextConfig = ${vinextConfigJson}; +export const publicFiles = new Set(${JSON.stringify(publicFiles)}); + +export function normalizeDataRequest(request) { + return __normalizePagesDataRequest( + request, + buildId, + vinextConfig.basePath, + __shouldAddTrailingSlashToPagesDataPath( + hasMiddleware, + vinextConfig.trailingSlash, + vinextConfig.skipProxyUrlNormalize, + ), + ); +} + +const pageRoutes = [ +${pageRouteEntries.join(",\n")} +]; +const pageRouteTrie = _buildRouteTrie(pageRoutes); +const apiRoutes = [ +${apiRouteEntries.join(",\n")} +]; +const apiRouteTrie = _buildRouteTrie(apiRoutes); + +function matchRoute(url, trie) { + const pathname = url.split("?")[0]; + const normalizedUrl = pathname === "/" ? "/" : pathname.replace(/\\/$/, ""); + return _trieMatch(trie, normalizedUrl.split("/").filter(Boolean)); +} + +function resolveI18nRouteUrl(url, request) { + return i18nConfig && request + ? resolvePagesI18nRequest( + url, + i18nConfig, + request.headers, + new URL(request.url).hostname, + vinextConfig.basePath, + vinextConfig.trailingSlash, + ).url + : url; +} + +export function matchPageRoute(url, request) { + return matchRoute(resolveI18nRouteUrl(url, request), pageRouteTrie); +} + +export function matchApiRoute(url, request) { + return matchRoute(resolveI18nRouteUrl(url, request), apiRouteTrie); +} + +${middlewareExportCode} +`; +} + /** * Generate the virtual SSR server entry module. * This is the entry point for `vite build --ssr`. */ +export function generatePagesResponseEntry( + pagesDir: string, + nextConfig: ResolvedNextConfig, + fileMatcher: ReturnType, + middlewarePath: string | null, + instrumentationPath: string | null, + prerenderSecret?: string, +): Promise { + return generateServerEntry( + pagesDir, + nextConfig, + fileMatcher, + middlewarePath, + instrumentationPath, + [], + { + includeMiddlewareRuntime: false, + prerenderSecret, + }, + ); +} + export async function generateServerEntry( pagesDir: string, nextConfig: ResolvedNextConfig, @@ -46,8 +250,10 @@ export async function generateServerEntry( middlewarePath: string | null, instrumentationPath: string | null, publicFiles: string[] = [], - prerenderSecret?: string, + options: GeneratePagesServerEntryOptions = {}, ): Promise { + const includeMiddlewareRuntime = options.includeMiddlewareRuntime !== false; + const prerenderSecret = options.prerenderSecret; const pageRoutes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); const apiRoutes = await apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); @@ -156,34 +362,32 @@ export async function generateServerEntry( // The onRequestError handler is stored on globalThis so it is visible across // all code within the Worker (same global scope). const instrumentationImportCode = instrumentationPath - ? `import * as _instrumentation from ${JSON.stringify(instrumentationPath)};` + ? `import * as _instrumentation from ${JSON.stringify(instrumentationPath)}; +import { ensureInstrumentationRegistered as __ensureInstrumentationRegistered } from ${JSON.stringify(_instrumentationRuntimePath)};` : ""; const instrumentationInitCode = instrumentationPath - ? `// Run instrumentation register() once at module evaluation time — before any -// requests are handled. Matches Next.js semantics: register() is called once -// on startup in the process that handles requests. -if (typeof _instrumentation.register === "function") { - await _instrumentation.register(); -} -// Store the onRequestError handler on globalThis so it is visible to all -// code within the Worker (same global scope). -if (typeof _instrumentation.onRequestError === "function") { - globalThis.__VINEXT_onRequestErrorHandler__ = _instrumentation.onRequestError; -}` + ? `// Both halves of a multi-stage output share this idempotent initializer, +// so instrumentation still registers exactly once per runtime. +await __ensureInstrumentationRegistered(_instrumentation);` : ""; // Generate middleware code if middleware.ts exists - const middlewareImportCode = middlewarePath - ? `import * as middlewareModule from ${JSON.stringify(middlewarePath)};` + const middlewareImportCode = + includeMiddlewareRuntime && middlewarePath + ? `import * as middlewareModule from ${JSON.stringify(middlewarePath)};` + : ""; + const middlewareRuntimeImportCode = includeMiddlewareRuntime + ? `import { runGeneratedMiddleware as __runGeneratedMiddleware } from ${JSON.stringify(_middlewareRuntimePath)};` : ""; // The matcher config is read from the middleware module at request time. // The generated entry only wires the user module into the shared runtime // helper; matcher, execution, waitUntil, and result shaping live in normal // TypeScript modules so dev/prod paths cannot drift. - const middlewareExportCode = middlewarePath - ? ` + const middlewareExportCode = includeMiddlewareRuntime + ? middlewarePath + ? ` export async function runMiddleware(request, ctx, options) { return __runGeneratedMiddleware({ basePath: vinextConfig.basePath, @@ -198,11 +402,12 @@ export async function runMiddleware(request, ctx, options) { }); } ` - : ` + : ` export async function runMiddleware(request) { return { continue: true }; } -`; +` + : ""; // The server entry is a self-contained module that uses Web-standard APIs // (Request/Response, renderToReadableStream) so it runs on Cloudflare Workers. @@ -231,7 +436,7 @@ import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontSty import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; import { sanitizeDestination as sanitizeDestinationLocal } from ${JSON.stringify(resolveEntryPath("../config/config-matchers.js", import.meta.url))}; import { runWithExecutionContext as _runWithExecutionContext } from ${JSON.stringify(_requestContextShimPath)}; -import { runGeneratedMiddleware as __runGeneratedMiddleware } from ${JSON.stringify(_middlewareRuntimePath)}; +${middlewareRuntimeImportCode} import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from ${JSON.stringify(_routeTriePath)}; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { resolvePagesI18nRequest } from ${JSON.stringify(_pagesI18nPath)}; @@ -239,6 +444,7 @@ import { handlePagesApiRoute as __handlePagesApiRoute } from ${JSON.stringify(_p import { normalizePagesDataRequest as __normalizePagesDataRequest, shouldAddTrailingSlashToPagesDataPath as __shouldAddTrailingSlashToPagesDataPath, buildNextDataNotFoundResponse as __buildNextDataNotFoundResponse } from ${JSON.stringify(_pagesDataRoutePath)}; import { buildDefaultPagesNotFoundResponse as __buildDefaultPagesNotFoundResponse } from ${JSON.stringify(_pagesDefault404Path)}; import { createPagesPageHandler as __createPagesPageHandler } from ${JSON.stringify(_pagesPageHandlerPath)}; +import { getRuntimePagesDataKind as __getRuntimePagesDataKind } from ${JSON.stringify(_pagesRouteDataKindPath)}; import { isOnDemandRevalidateRequest as __isOnDemandRevalidateRequest } from ${JSON.stringify(_isrCachePath)}; ${instrumentationImportCode} ${middlewareImportCode} @@ -369,6 +575,12 @@ export function matchPageRoute(url, request) { return matchRoute(routeUrl, pageRoutes); } +export function getRuntimePageDataKind(url, request) { + const match = matchPageRoute(url, request); + if (!match) return "none"; + return __getRuntimePagesDataKind(match.route.module, AppComponent); +} + export function matchApiRoute(url, request) { const routeUrl = i18nConfig && request ? resolvePagesI18nRequest( @@ -482,20 +694,21 @@ const _renderPage = __createPagesPageHandler({ DocumentComponent, }); -export async function renderPage(request, url, manifest, ctx, middlewareHeaders, options) { +export async function renderPage(request, url, manifest, ctx, middlewareHeaders, options, initialResponseHeaders) { __registerConfiguredCacheAdapters(); - if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest, middlewareHeaders, options)); - return _renderPage(request, url, manifest, middlewareHeaders, options); + if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest, middlewareHeaders, options, initialResponseHeaders)); + return _renderPage(request, url, manifest, middlewareHeaders, options, initialResponseHeaders); } -export async function handleApiRoute(request, url, ctx, trustedRevalidateOrigin, edgeRuntime = "worker") { +export async function handleApiRoute(request, url, ctx, trustedRevalidateOrigin, edgeRuntime = "worker", initialResponseHeaders) { __registerConfiguredCacheAdapters(); const match = matchRoute(url, apiRoutes); return __handlePagesApiRoute({ ctx, edgeRuntime, + initialResponseHeaders, match, nextConfig: vinextConfig, request, diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index 4fd94918a..9576a5604 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -510,6 +510,10 @@ declare module "virtual:vinext-cache-adapters" { export function registerConfiguredCacheAdapters(env?: Record): void; } +declare module "virtual:vinext-cdn-cache-adapter" { + export function registerConfiguredCacheAdapters(env?: Record): void; +} + declare module "virtual:vinext-pages-client-assets" { import type { PagesClientAssets } from "vinext/server/pages-client-assets"; const assets: PagesClientAssets; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 656adbad4..eb4edab90 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -19,7 +19,11 @@ import { invalidateRouteCache, matchRoute, } from "./routing/pages-router.js"; -import { generateServerEntry as _generateServerEntry } from "./entries/pages-server-entry.js"; +import { + generatePagesRequestEntry as _generatePagesRequestEntry, + generatePagesResponseEntry as _generatePagesResponseEntry, + generateServerEntry as _generateServerEntry, +} from "./entries/pages-server-entry.js"; import { generateClientEntry as _generateClientEntry } from "./entries/pages-client-entry.js"; import { appRouteGraph, @@ -43,19 +47,28 @@ import { resolveDevImageRedirect, } from "./server/image-optimization.js"; import { CACHEABILITY_MANIFEST_MODULE } from "./server/cacheability-manifest.js"; +import { PREGENERATED_CONCRETE_PATHS_MODULE } from "./server/pregenerated-concrete-paths.js"; import { installSocketErrorBackstop } from "./server/socket-error-backstop.js"; import { shouldInvalidateAppRouteFile } from "./server/dev-route-files.js"; import { createDirectRunner } from "./server/dev-module-runner.js"; -import { generateRscEntry } from "./entries/app-rsc-entry.js"; +import { + generateAppRequestRscEntry, + generateAppResponseRscEntry, + generateRscEntry, +} from "./entries/app-rsc-entry.js"; import { generateSsrEntry } from "./entries/app-ssr-entry.js"; +import { resolveRuntimeEntryModule } from "./entries/runtime-entry-module.js"; import { + VIRTUAL_CDN_CACHE_ADAPTER, VIRTUAL_CACHE_ADAPTERS, + generateCdnCacheAdapterModule, generateCacheAdaptersModule, hasVerbatimResponseVary, VINEXT_CACHE_CONFIG_PLUGIN_PROPERTY, type VinextCacheConfig, } from "./cache/cache-adapters-virtual.js"; +import type { VinextMultiStageOutput } from "./server/multi-stage.js"; import { VIRTUAL_IMAGE_ADAPTERS, generateImageAdaptersModule, @@ -235,6 +248,8 @@ import { createClientManualChunks, createClientCodeSplittingConfig, createClientAssetFileNames, + createMultiStageCodeSplittingConfig, + createMultiStageChunkFileNames, createRscFrameworkChunkOutputConfig, getClientTreeshakeConfig, getBuildBundlerOptions, @@ -1091,8 +1106,16 @@ function suppressAliasCustomResolverDeprecationWarning(logger: Logger): Logger { // Virtual module IDs for Pages Router production build const VIRTUAL_WORKER_ENTRY = "virtual:vinext-worker-entry"; const RESOLVED_WORKER_ENTRY = VIRTUAL_PREFIX + VIRTUAL_WORKER_ENTRY; +const VIRTUAL_REQUEST_STAGE = "virtual:vinext-request-stage"; +const RESOLVED_REQUEST_STAGE = VIRTUAL_PREFIX + VIRTUAL_REQUEST_STAGE; +const VIRTUAL_RESPONSE_STAGE = "virtual:vinext-response-stage"; +const RESOLVED_RESPONSE_STAGE = VIRTUAL_PREFIX + VIRTUAL_RESPONSE_STAGE; const VIRTUAL_SERVER_ENTRY = "virtual:vinext-server-entry"; const RESOLVED_SERVER_ENTRY = VIRTUAL_PREFIX + VIRTUAL_SERVER_ENTRY; +const VIRTUAL_PAGES_REQUEST_ENTRY = "virtual:vinext-pages-request-entry"; +const RESOLVED_PAGES_REQUEST_ENTRY = VIRTUAL_PREFIX + VIRTUAL_PAGES_REQUEST_ENTRY; +const VIRTUAL_PAGES_RESPONSE_ENTRY = "virtual:vinext-pages-response-entry"; +const RESOLVED_PAGES_RESPONSE_ENTRY = VIRTUAL_PREFIX + VIRTUAL_PAGES_RESPONSE_ENTRY; const VIRTUAL_CLIENT_ENTRY = "virtual:vinext-client-entry"; const RESOLVED_CLIENT_ENTRY = VIRTUAL_PREFIX + VIRTUAL_CLIENT_ENTRY; const VIRTUAL_PAGES_CLIENT_ASSETS = "virtual:vinext-pages-client-assets"; @@ -1103,6 +1126,12 @@ const VIRTUAL_RSC_ENTRY = "virtual:vinext-rsc-entry"; const RESOLVED_RSC_ENTRY = VIRTUAL_PREFIX + VIRTUAL_RSC_ENTRY; const VIRTUAL_CACHEABILITY_MANIFEST = "virtual:vinext-cacheability-manifest"; const RESOLVED_CACHEABILITY_MANIFEST = VIRTUAL_PREFIX + VIRTUAL_CACHEABILITY_MANIFEST; +const VIRTUAL_PREGENERATED_CONCRETE_PATHS = "virtual:vinext-pregenerated-concrete-paths"; +const RESOLVED_PREGENERATED_CONCRETE_PATHS = VIRTUAL_PREFIX + VIRTUAL_PREGENERATED_CONCRETE_PATHS; +const VIRTUAL_APP_REQUEST_ENTRY = "virtual:vinext-app-request-entry"; +const RESOLVED_APP_REQUEST_ENTRY = VIRTUAL_PREFIX + VIRTUAL_APP_REQUEST_ENTRY; +const VIRTUAL_APP_RESPONSE_ENTRY = "virtual:vinext-app-response-entry"; +const RESOLVED_APP_RESPONSE_ENTRY = VIRTUAL_PREFIX + VIRTUAL_APP_RESPONSE_ENTRY; const VIRTUAL_APP_SSR_ENTRY = "virtual:vinext-app-ssr-entry"; const RESOLVED_APP_SSR_ENTRY = VIRTUAL_PREFIX + VIRTUAL_APP_SSR_ENTRY; const VIRTUAL_APP_BROWSER_ENTRY = "virtual:vinext-app-browser-entry"; @@ -1111,8 +1140,14 @@ const VIRTUAL_APP_CAPABILITIES = "virtual:vinext-app-capabilities"; const RESOLVED_APP_CAPABILITIES = VIRTUAL_PREFIX + VIRTUAL_APP_CAPABILITIES; const VIRTUAL_ROOT_PARAMS = "virtual:vinext-root-params"; const RESOLVED_ROOT_PARAMS = VIRTUAL_PREFIX + VIRTUAL_ROOT_PARAMS; +const APP_REQUEST_STAGE_ENTRY = resolveRuntimeEntryModule("app-request-stage-independent-entry"); +const APP_RESPONSE_STAGE_ENTRY = resolveRuntimeEntryModule("app-response-stage-entry"); +const PAGES_REQUEST_STAGE_ENTRY = resolveRuntimeEntryModule("pages-request-stage-entry"); +const PAGES_RESPONSE_STAGE_ENTRY = resolveRuntimeEntryModule("pages-response-stage-entry"); /** Virtual module that registers config-driven cache adapters (see VinextOptions.cache). */ const RESOLVED_CACHE_ADAPTERS = VIRTUAL_PREFIX + VIRTUAL_CACHE_ADAPTERS; +/** CDN-only registrar kept out of the data-cache response graph. */ +const RESOLVED_CDN_CACHE_ADAPTER = VIRTUAL_PREFIX + VIRTUAL_CDN_CACHE_ADAPTER; /** Virtual module that registers the config-driven image optimizer (see VinextOptions.images). */ const RESOLVED_IMAGE_ADAPTERS = VIRTUAL_PREFIX + VIRTUAL_IMAGE_ADAPTERS; /** Virtual module for composed instrumentation-client bootstrap. */ @@ -1480,6 +1515,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // initializer guards any unexpected hook ordering. let clientAssetsInlineLimit: NonNullable["assetsInlineLimit"] = 0; let hasCloudflarePlugin = false; + let selectedMultiStageOutput: VinextMultiStageOutput | undefined; let warnedInlineNextConfigOverride = false; let hasNitroPlugin = false; let resolvedServerExternalPackages: string[] = []; @@ -1521,10 +1557,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // one process never preprocess `composes` deps with another build's config. const sassComposesLoader = createSassAwareFileSystemLoader(); - // Build-time layout classification manifest, captured in the RSC virtual - // module's load hook and consumed in renderChunk to patch the generated - // `__VINEXT_CLASS` stub with a real dispatch table. - let rscClassificationManifest: RouteClassificationManifest | null = null; + // Build-time layout classification manifests, captured for each generated + // RSC virtual module and consumed in renderChunk to patch that module's + // `__VINEXT_CLASS` stub with a real dispatch table. Multi-stage outputs emit + // both the ordinary RSC graph and a response-only graph in the same build, + // so one mutable manifest would be consumed by whichever chunk rendered + // first and leave the other graph's classifier as the null stub. + const rscClassificationManifests = new Map(); let rscActionOwnerRoutes: Awaited> | null = null; let rscActionOwnerSharedRoots: string[] = []; @@ -1577,6 +1616,22 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ? [...devPublicFileRoutes].sort() : scanPublicFileRoutes(root, configuredPublicDir === "" ? false : configuredPublicDir); return _generateServerEntry( + pagesDir, + nextConfig, + fileMatcher, + middlewarePath, + instrumentationPath, + publicFiles, + { prerenderSecret }, + ); + } + + async function generatePagesRequestEntry(configuredPublicDir: string | false): Promise { + const publicFiles = + isServeCommand && devPublicFileRoutes + ? [...devPublicFileRoutes].sort() + : scanPublicFileRoutes(root, configuredPublicDir === "" ? false : configuredPublicDir); + return _generatePagesRequestEntry( pagesDir, nextConfig, fileMatcher, @@ -1587,6 +1642,17 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ); } + function generatePagesResponseEntry(): Promise { + return _generatePagesResponseEntry( + pagesDir, + nextConfig, + fileMatcher, + middlewarePath, + instrumentationPath, + prerenderSecret, + ); + } + /** * Generate the virtual client hydration entry module. * This is the entry point for `vite build` (client bundle). @@ -2711,6 +2777,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { typeof p.name === "string" && (p.name === "vite-plugin-cloudflare" || p.name.startsWith("vite-plugin-cloudflare:")), ); + const configuredMultiStageOutput = options.cache?.cdn?.output; + selectedMultiStageOutput = + configuredMultiStageOutput && + (configuredMultiStageOutput.matchesBuild?.({ + plugins: pluginsFlat as { name?: string }[], + }) ?? + true) + ? configuredMultiStageOutput + : undefined; hasNitroPlugin = pluginsFlat.some( (p: unknown) => p && @@ -3902,7 +3977,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // direct @vercel/og imports in metadata routes, and \0-prefixed // re-imports from @vitejs/plugin-rsc. filter: { - id: /(?:next\/|vinext\/(?:shims\/|server\/app-rsc-handler)|virtual:vinext-|@vercel\/og(?:\.js)?$)/, + id: /(?:next\/|vinext\/(?:shims\/|server\/(?:app-rsc-handler|app-router-entry|pages-router-entry))|virtual:vinext-|@vercel\/og(?:\.js)?$)/, }, handler(id, importer) { // Strip \0 prefix if present — @vitejs/plugin-rsc's generated @@ -3942,16 +4017,39 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Router-selected Cloudflare Worker entry facade if (cleanId === VIRTUAL_WORKER_ENTRY) return RESOLVED_WORKER_ENTRY; + if ( + selectedMultiStageOutput && + (cleanId === "vinext/server/app-router-entry" || + cleanId === "vinext/server/pages-router-entry") + ) { + return RESOLVED_WORKER_ENTRY; + } if (cleanId.endsWith("/" + VIRTUAL_WORKER_ENTRY)) { return RESOLVED_WORKER_ENTRY; } + if (cleanId === VIRTUAL_REQUEST_STAGE) return RESOLVED_REQUEST_STAGE; + if (cleanId.endsWith("/" + VIRTUAL_REQUEST_STAGE)) { + return RESOLVED_REQUEST_STAGE; + } + if (cleanId === VIRTUAL_RESPONSE_STAGE) return RESOLVED_RESPONSE_STAGE; + if (cleanId.endsWith("/" + VIRTUAL_RESPONSE_STAGE)) { + return RESOLVED_RESPONSE_STAGE; + } // Pages Router virtual modules if (cleanId === VIRTUAL_SERVER_ENTRY) return RESOLVED_SERVER_ENTRY; + if (cleanId === VIRTUAL_PAGES_REQUEST_ENTRY) return RESOLVED_PAGES_REQUEST_ENTRY; + if (cleanId === VIRTUAL_PAGES_RESPONSE_ENTRY) return RESOLVED_PAGES_RESPONSE_ENTRY; if (cleanId === VIRTUAL_CLIENT_ENTRY) return RESOLVED_CLIENT_ENTRY; if (cleanId.endsWith("/" + VIRTUAL_SERVER_ENTRY)) { return RESOLVED_SERVER_ENTRY; } + if (cleanId.endsWith("/" + VIRTUAL_PAGES_REQUEST_ENTRY)) { + return RESOLVED_PAGES_REQUEST_ENTRY; + } + if (cleanId.endsWith("/" + VIRTUAL_PAGES_RESPONSE_ENTRY)) { + return RESOLVED_PAGES_RESPONSE_ENTRY; + } if (cleanId.endsWith("/" + VIRTUAL_CLIENT_ENTRY)) { return RESOLVED_CLIENT_ENTRY; } @@ -3966,6 +4064,17 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } return RESOLVED_CACHEABILITY_MANIFEST; } + if (cleanId === VIRTUAL_PREGENERATED_CONCRETE_PATHS) { + const isWorkerBuildEnvironment = hasAppDir + ? this.environment?.name === "rsc" + : this.environment !== undefined && isServerEnvironment(this.environment); + if (isWorkerBuildEnvironment && this.environment.config?.command === "build") { + return { id: `./${PREGENERATED_CONCRETE_PATHS_MODULE}`, external: true }; + } + return RESOLVED_PREGENERATED_CONCRETE_PATHS; + } + if (cleanId === VIRTUAL_APP_REQUEST_ENTRY) return RESOLVED_APP_REQUEST_ENTRY; + if (cleanId === VIRTUAL_APP_RESPONSE_ENTRY) return RESOLVED_APP_RESPONSE_ENTRY; if (cleanId === VIRTUAL_APP_SSR_ENTRY) return RESOLVED_APP_SSR_ENTRY; if (cleanId === VIRTUAL_APP_BROWSER_ENTRY) return RESOLVED_APP_BROWSER_ENTRY; if (cleanId === VIRTUAL_APP_CAPABILITIES) return RESOLVED_APP_CAPABILITIES; @@ -3978,6 +4087,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ) { return RESOLVED_CACHE_ADAPTERS; } + if ( + cleanId === VIRTUAL_CDN_CACHE_ADAPTER || + cleanId.endsWith("/" + VIRTUAL_CDN_CACHE_ADAPTER) + ) { + return RESOLVED_CDN_CACHE_ADAPTER; + } if ( cleanId === VIRTUAL_IMAGE_ADAPTERS || cleanId.endsWith("/" + VIRTUAL_IMAGE_ADAPTERS) @@ -3990,6 +4105,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (cleanId.endsWith("/" + VIRTUAL_RSC_ENTRY)) { return RESOLVED_RSC_ENTRY; } + if (cleanId.endsWith("/" + VIRTUAL_APP_REQUEST_ENTRY)) { + return RESOLVED_APP_REQUEST_ENTRY; + } + if (cleanId.endsWith("/" + VIRTUAL_APP_RESPONSE_ENTRY)) { + return RESOLVED_APP_RESPONSE_ENTRY; + } if (cleanId.endsWith("/" + VIRTUAL_APP_SSR_ENTRY)) { return RESOLVED_APP_SSR_ENTRY; } @@ -4023,17 +4144,40 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { filter: { id: /virtual:vinext-/ }, async handler(id) { if (id === RESOLVED_WORKER_ENTRY) { + if (selectedMultiStageOutput?.type === "multi-stage") { + return [ + `export { default } from ${JSON.stringify(selectedMultiStageOutput.entry)};`, + `export * from ${JSON.stringify(selectedMultiStageOutput.entry)};`, + "", + ].join("\n"); + } const entry = hasAppDir ? "vinext/server/app-router-entry" : "vinext/server/pages-router-entry"; return `export { default } from ${JSON.stringify(entry)};`; } + if (id === RESOLVED_REQUEST_STAGE) { + const entry = hasAppDir ? APP_REQUEST_STAGE_ENTRY : PAGES_REQUEST_STAGE_ENTRY; + return `export { handleRequestStage } from ${JSON.stringify(entry)};\n`; + } + if (id === RESOLVED_RESPONSE_STAGE) { + const entry = hasAppDir ? APP_RESPONSE_STAGE_ENTRY : PAGES_RESPONSE_STAGE_ENTRY; + return `export { handleResponseStage } from ${JSON.stringify(entry)};\n`; + } // Pages Router virtual modules if (id === RESOLVED_SERVER_ENTRY) { return await generateServerEntry( this.environment.config.publicDir === "" ? false : this.environment.config.publicDir, ); } + if (id === RESOLVED_PAGES_REQUEST_ENTRY) { + return await generatePagesRequestEntry( + this.environment.config.publicDir === "" ? false : this.environment.config.publicDir, + ); + } + if (id === RESOLVED_PAGES_RESPONSE_ENTRY) { + return await generatePagesResponseEntry(); + } if (id === RESOLVED_CLIENT_ENTRY) { return await generateClientEntry(); } @@ -4073,7 +4217,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (id === RESOLVED_CACHEABILITY_MANIFEST) { return "export default null;"; } - if (id === RESOLVED_RSC_ENTRY && hasAppDir) { + if (id === RESOLVED_PREGENERATED_CONCRETE_PATHS) { + return "export {};"; + } + if ( + (id === RESOLVED_RSC_ENTRY || + id === RESOLVED_APP_REQUEST_ENTRY || + id === RESOLVED_APP_RESPONSE_ENTRY) && + hasAppDir + ) { const routes = await appRouter(appDir, nextConfig?.pageExtensions, fileMatcher); const metaRoutes = scanMetadataFiles(appDir); const hasServerActions = await resolveHasServerActions(this.environment.config); @@ -4090,17 +4242,25 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Collect Layer 1 (segment config) classifications for all layouts. // Layer 2 (module graph) runs later in renderChunk once Rollup's // module info is available. - // Invariant: rscClassificationManifest must be built from the same - // `routes` value passed to generateRscEntry below so that layout - // indices in the manifest correspond 1:1 to the route.layouts arrays - // used during codegen. renderChunk clears this after patching. - rscClassificationManifest = collectRouteClassificationManifest(routes); - rscActionOwnerRoutes = - this.environment.config.command === "build" && hasServerActions ? routes : null; - rscActionOwnerSharedRoots = [globalErrorPath, globalNotFoundPath].filter( - (path): path is string => path !== null, - ); - return generateRscEntry( + // Invariant: each manifest must be built from the same `routes` + // value passed to its generator below so that layout indices in the + // manifest correspond 1:1 to the route.layouts arrays used during + // codegen. renderChunk consumes the manifest for that virtual module. + if (id !== RESOLVED_APP_REQUEST_ENTRY) { + rscClassificationManifests.set(id, collectRouteClassificationManifest(routes)); + rscActionOwnerRoutes = + this.environment.config.command === "build" && hasServerActions ? routes : null; + rscActionOwnerSharedRoots = [globalErrorPath, globalNotFoundPath].filter( + (path): path is string => path !== null, + ); + } + const generateEntry = + id === RESOLVED_APP_REQUEST_ENTRY + ? generateAppRequestRscEntry + : id === RESOLVED_APP_RESPONSE_ENTRY + ? generateAppResponseRscEntry + : generateRscEntry; + return generateEntry( appDir, routes, middlewarePath, @@ -4164,6 +4324,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (id === RESOLVED_CACHE_ADAPTERS) { return generateCacheAdaptersModule(options.cache); } + if (id === RESOLVED_CDN_CACHE_ADAPTER) { + return generateCdnCacheAdapterModule(options.cache); + } if (id === RESOLVED_IMAGE_ADAPTERS) { return generateImageAdaptersModule(options.images); } @@ -4251,12 +4414,22 @@ export const loadServerActionClient = ${ // pulling ModuleInfo from the wrong graph would give nonsense // results. if (this.environment?.name !== "rsc") return null; - if (!rscClassificationManifest) return null; // Cheap pre-filter: skip chunks that don't mention the stub at all // (e.g. the scan-phase chunk and every non-entry chunk). const hasClassificationStub = code.includes("__VINEXT_CLASS"); if (!hasClassificationStub) return null; + // Both generated App RSC graphs can be present in one multi-entry + // build. Associate the chunk with the virtual module that generated + // its route table so each graph receives (and consumes) its own + // manifest regardless of render order. + const rscEntryId = [RESOLVED_RSC_ENTRY, RESOLVED_APP_RESPONSE_ENTRY].find((id) => + chunk.moduleIds.includes(id), + ); + if (!rscEntryId) return null; + const rscClassificationManifest = rscClassificationManifests.get(rscEntryId); + if (!rscClassificationManifest) return null; + // Patching per-chunk (rather than scanning the whole bundle in // generateBundle) assumes the stub body and its per-route call sites // are emitted into the same chunk. That holds with current codegen: @@ -4303,11 +4476,11 @@ export const loadServerActionClient = ${ const nextCode = patchPlan.kind === "skip" ? code : patchPlan.code; if (patchPlan.kind === "skip") return null; - // Consume the manifest exactly once per RSC entry. Clearing here - // prevents a stale manifest from leaking into a subsequent build pass - // if the load hook is not re-triggered (e.g., in non-standard rebuild - // paths). - rscClassificationManifest = null; + // Consume the manifest exactly once for this generated RSC module. + // Keeping the sibling entry's manifest intact lets a multi-entry + // build patch both graphs while still preventing stale state from + // leaking into a later non-standard rebuild path. + rscClassificationManifests.delete(rscEntryId); // The patched body is longer than the stub, so any existing source // map would be stale. RSC entry source maps are not served or @@ -4377,6 +4550,78 @@ export const loadServerActionClient = ${ }, }, }, + { + name: "vinext:multi-stage-host-entry", + apply: "build", + + transform: { + // The adapter owns entry matching. Do not pre-filter by an import + // spelling here: host entries may reach vinext through an alias or an + // adapter-owned wrapper, and the callback receives both source and id + // specifically so it can recognize those layouts. + filter: { id: /virtual:|\.[cm]?[jt]sx?(?:\?|$)/ }, + handler(code, id) { + const transformed = selectedMultiStageOutput?.transformHostEntry?.({ code, id }); + return transformed == null ? null : { code: transformed, map: null }; + }, + }, + }, + { + name: "vinext:multi-stage-host-output", + apply: "build", + enforce: "post", + + writeBundle: { + sequential: true, + order: "post", + async handler(outputOptions) { + if (!selectedMultiStageOutput?.finalizeBuildOutput || !outputOptions.dir) return; + await selectedMultiStageOutput.finalizeBuildOutput({ + outDir: path.resolve(root, outputOptions.dir), + root, + }); + }, + }, + }, + { + name: "vinext:multi-stage-server-output", + apply: "build", + + configEnvironment(name, config) { + // Vite's standalone `build.ssr` path still names its sole environment + // `client`. Distinguish that server build from the real browser + // environment when applying server-stage chunk partitioning. + const isStandaloneSsrEnvironment = typeof config.build?.ssr === "string"; + // App Router's `ssr` environment is the client-component renderer and + // must not receive server-stage output configuration. In a Pages-only + // build, however, `ssr` is the actual server environment. + if ( + !selectedMultiStageOutput || + (name === "client" && !isStandaloneSsrEnvironment) || + (hasAppDir && name === "ssr" && !isStandaloneSsrEnvironment) + ) { + return null; + } + const bundlerOptions = getBuildBundlerOptions(config.build); + const output = bundlerOptions?.output; + if (Array.isArray(output)) { + return null; + } + return { + build: { + ...withBuildBundlerOptions({ + output: { + chunkFileNames: createMultiStageChunkFileNames( + resolveAssetsDir(nextConfig.assetPrefix ?? ""), + output?.chunkFileNames, + ), + codeSplitting: createMultiStageCodeSplittingConfig(output?.codeSplitting), + }, + }), + }, + }; + }, + }, { name: "vinext:css-url-assets-defaults", apply: "build", @@ -4475,6 +4720,11 @@ export const loadServerActionClient = ${ fileName: CACHEABILITY_MANIFEST_MODULE, source: "export default null;\n", }); + this.emitFile({ + type: "asset", + fileName: PREGENERATED_CONCRETE_PATHS_MODULE, + source: "delete globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS;\n", + }); }, }, { @@ -7426,3 +7676,8 @@ export type { // Export NextConfig type so next.config.ts files can import it from "vinext" // instead of "next". export type { NextConfig } from "./config/next-config.js"; +export type { + VinextMultiStageOutput, + VinextResponseStageDispatchOptions, + VinextResponseStageTransport, +} from "./server/multi-stage.js"; diff --git a/packages/vinext/src/server/app-elements-wire-key.ts b/packages/vinext/src/server/app-elements-wire-key.ts new file mode 100644 index 000000000..5b9413109 --- /dev/null +++ b/packages/vinext/src/server/app-elements-wire-key.ts @@ -0,0 +1,66 @@ +/** Encode and validate the lightweight AppElements slot identity wire shape. */ +export function createAppElementsWireSlotId(slotName: string, treePath: string): string { + return `slot:${slotName}:${treePath}`; +} + +export function isAppElementsWireSlotId(key: string): boolean { + if (!key.startsWith("slot:")) return false; + const body = key.slice("slot:".length); + const separatorIndex = body.indexOf(":"); + return separatorIndex > 0 && body.charCodeAt(separatorIndex + 1) === 0x2f; +} + +export type AppElementsWireElementKey = + | { kind: "layout"; treePath: string } + | { interceptionContext: string | null; kind: "page"; path: string } + | { interceptionContext: string | null; kind: "route"; path: string } + | { kind: "slot"; name: string; treePath: string } + | { kind: "template"; treePath: string }; + +function parsePathWithInterception(input: string): { + interceptionContext: string | null; + path: string; +} | null { + const separatorIndex = input.indexOf("\0"); + const path = separatorIndex === -1 ? input : input.slice(0, separatorIndex); + if (!path.startsWith("/")) return null; + return { + interceptionContext: separatorIndex === -1 ? null : input.slice(separatorIndex + 1), + path, + }; +} + +function parseTreePath(input: string): string | null { + return input.startsWith("/") ? input : null; +} + +export function parseAppElementsWireElementKey(key: string): AppElementsWireElementKey | null { + if (key.startsWith("route:")) { + const parsed = parsePathWithInterception(key.slice("route:".length)); + return parsed + ? { interceptionContext: parsed.interceptionContext, kind: "route", path: parsed.path } + : null; + } + if (key.startsWith("page:")) { + const parsed = parsePathWithInterception(key.slice("page:".length)); + return parsed + ? { interceptionContext: parsed.interceptionContext, kind: "page", path: parsed.path } + : null; + } + if (key.startsWith("layout:")) { + const treePath = parseTreePath(key.slice("layout:".length)); + return treePath ? { kind: "layout", treePath } : null; + } + if (key.startsWith("template:")) { + const treePath = parseTreePath(key.slice("template:".length)); + return treePath ? { kind: "template", treePath } : null; + } + if (key.startsWith("slot:")) { + const body = key.slice("slot:".length); + const separatorIndex = body.indexOf(":"); + if (separatorIndex <= 0) return null; + const treePath = parseTreePath(body.slice(separatorIndex + 1)); + return treePath ? { kind: "slot", name: body.slice(0, separatorIndex), treePath } : null; + } + return null; +} diff --git a/packages/vinext/src/server/app-elements-wire.ts b/packages/vinext/src/server/app-elements-wire.ts index 0086424fe..00346dde9 100644 --- a/packages/vinext/src/server/app-elements-wire.ts +++ b/packages/vinext/src/server/app-elements-wire.ts @@ -16,6 +16,12 @@ import { releaseAppElementRenderDependency } from "./app-render-dependency.js"; import type { BfcacheSegmentIdentity } from "./bfcache-identity.js"; import { compareStrings } from "../utils/compare.js"; import { isUnknownRecord } from "../utils/record.js"; +import { + createAppElementsWireSlotId, + isAppElementsWireSlotId, + parseAppElementsWireElementKey, + type AppElementsWireElementKey, +} from "./app-elements-wire-key.js"; const APP_INTERCEPTION_SEPARATOR = "\0"; const LEGACY_APP_SOURCE_PAGE_KEY = "__sourcePage"; @@ -214,13 +220,6 @@ type AppElementsMetadata = { sourcePage: string | null; }; -type AppElementsWireElementKey = - | { kind: "layout"; treePath: string } - | { interceptionContext: string | null; kind: "page"; path: string } - | { interceptionContext: string | null; kind: "route"; path: string } - | { kind: "slot"; name: string; treePath: string } - | { kind: "template"; treePath: string }; - type AppElementsWireMetadataInput = { dynamicStaleTimeSeconds?: number; interception?: AppElementsInterception | null; @@ -332,83 +331,15 @@ function createAppPayloadTemplateId(treePath: string): string { return `template:${treePath}`; } -function createAppPayloadSlotId(slotName: string, treePath: string): string { - return `slot:${slotName}:${treePath}`; -} - function createAppPayloadCacheKey(rscUrl: string, interceptionContext: string | null): string { return appendInterceptionContext(rscUrl, interceptionContext); } -function parsePathWithInterception(input: string): { - interceptionContext: string | null; - path: string; -} | null { - const separatorIndex = input.indexOf(APP_INTERCEPTION_SEPARATOR); - const path = separatorIndex === -1 ? input : input.slice(0, separatorIndex); - if (!path.startsWith("/")) return null; - - return { - interceptionContext: separatorIndex === -1 ? null : input.slice(separatorIndex + 1), - path, - }; -} - -/** - * AppElements tree paths are absolute route-tree paths on the wire. - * Bare segment names are not valid layout/template/slot tree identities. - */ -function parseTreePath(input: string): string | null { - return input.startsWith("/") ? input : null; -} - -function parseAppElementsWireElementKey(key: string): AppElementsWireElementKey | null { - if (key.startsWith("route:")) { - const parsed = parsePathWithInterception(key.slice("route:".length)); - if (!parsed) return null; - return { interceptionContext: parsed.interceptionContext, kind: "route", path: parsed.path }; - } - - if (key.startsWith("page:")) { - const parsed = parsePathWithInterception(key.slice("page:".length)); - if (!parsed) return null; - return { interceptionContext: parsed.interceptionContext, kind: "page", path: parsed.path }; - } - - if (key.startsWith("layout:")) { - const treePath = parseTreePath(key.slice("layout:".length)); - return treePath ? { kind: "layout", treePath } : null; - } - - if (key.startsWith("template:")) { - const treePath = parseTreePath(key.slice("template:".length)); - return treePath ? { kind: "template", treePath } : null; - } - - if (key.startsWith("slot:")) { - const body = key.slice("slot:".length); - const separatorIndex = body.indexOf(":"); - if (separatorIndex <= 0) return null; - const name = body.slice(0, separatorIndex); - const treePath = parseTreePath(body.slice(separatorIndex + 1)); - return treePath ? { kind: "slot", name, treePath } : null; - } - - return null; -} - function isAppElementsWireBfcacheIdentityId(key: string): boolean { const kind = parseAppElementsWireElementKey(key)?.kind; return kind === "page" || kind === "layout" || kind === "template" || kind === "slot"; } -function isAppElementsWireSlotId(key: string): boolean { - if (!key.startsWith("slot:")) return false; - const body = key.slice("slot:".length); - const separatorIndex = body.indexOf(":"); - return separatorIndex > 0 && body.charCodeAt(separatorIndex + 1) === 0x2f; -} - function isSourcePageSegments(value: unknown): value is readonly string[] { return ( Array.isArray(value) && @@ -975,7 +906,7 @@ export const AppElementsWire: AppElementsWireCodec = { encodeOutgoingPayload: buildOutgoingAppPayload, encodePageId: createAppPayloadPageId, encodeRouteId: createAppPayloadRouteId, - encodeSlotId: createAppPayloadSlotId, + encodeSlotId: createAppElementsWireSlotId, encodeTemplateId: createAppPayloadTemplateId, isSlotId: isAppElementsWireSlotId, parseElementKey: parseAppElementsWireElementKey, diff --git a/packages/vinext/src/server/app-middleware.ts b/packages/vinext/src/server/app-middleware.ts index f71e6240c..1b958620d 100644 --- a/packages/vinext/src/server/app-middleware.ts +++ b/packages/vinext/src/server/app-middleware.ts @@ -1,7 +1,7 @@ import type { NextI18nConfig } from "../config/next-config.js"; import { isExternalUrl } from "../utils/external-url.js"; import { applyMiddlewareRequestHeaders, setHeadersContext } from "vinext/shims/headers"; -import { setNavigationContext } from "vinext/shims/navigation"; +import { setNavigationContext } from "vinext/shims/navigation-context-accessors"; import { FLIGHT_HEADERS, VINEXT_MW_CTX_HEADER } from "./headers.js"; import { buildRequestHeadersFromMiddlewareResponse } from "../utils/middleware-request-headers.js"; import { mergeMiddlewareResponseHeaders } from "./middleware-response-headers.js"; diff --git a/packages/vinext/src/server/app-mounted-slots-header.ts b/packages/vinext/src/server/app-mounted-slots-header.ts index 98ddb0fec..5ef481ef5 100644 --- a/packages/vinext/src/server/app-mounted-slots-header.ts +++ b/packages/vinext/src/server/app-mounted-slots-header.ts @@ -30,7 +30,7 @@ * - isr-cache (RSC cache key generation) */ -import { AppElementsWire } from "./app-elements-wire.js"; +import { isAppElementsWireSlotId } from "./app-elements-wire-key.js"; /** Hard cap on the raw header value byte length. Real values are <1 KB. */ const MAX_RAW_HEADER_LENGTH = 4096; @@ -47,7 +47,7 @@ const MAX_SLOT_TOKENS = 16; */ function isValidSlotToken(token: string): boolean { if (token.length === 0 || token.length > MAX_TOKEN_LENGTH) return false; - return AppElementsWire.isSlotId(token); + return isAppElementsWireSlotId(token); } export function normalizeMountedSlotsHeader(raw: string | null | undefined): string | null { diff --git a/packages/vinext/src/server/app-pages-bridge.ts b/packages/vinext/src/server/app-pages-bridge.ts index 3f5d4f30a..3a124c67a 100644 --- a/packages/vinext/src/server/app-pages-bridge.ts +++ b/packages/vinext/src/server/app-pages-bridge.ts @@ -13,6 +13,7 @@ export type PagesEntry = { ctx: unknown, trustedRevalidateOrigin: string | undefined, edgeRuntime: EdgeApiExecutionRuntime, + initialResponseHeaders?: Headers, ) => Promise | Response; matchApiRoute?: (url: string, request: Request) => PagesRouteMatch | null; matchPageRoute?: (url: string, request: Request) => PagesRouteMatch | null; @@ -23,6 +24,7 @@ export type PagesEntry = { parsedUrl: unknown, middlewareRequestHeaders?: Headers | null, options?: { isDataReq?: boolean }, + initialResponseHeaders?: Headers, ) => Promise | Response; }; @@ -74,6 +76,7 @@ type RenderPagesFallbackOptions = { appRouteMatch?: AppRouteMatch | null; isDataRequest?: boolean; isRscRequest: boolean; + initialResponseHeaders?: Headers; matchKind?: "dynamic" | "static"; middlewareContext: AppMiddlewareContext; pathname?: string; @@ -118,6 +121,7 @@ export async function renderPagesFallback( appRouteMatch = null, isDataRequest = false, isRscRequest, + initialResponseHeaders, matchKind, middlewareContext, pathname = options.url.pathname, @@ -168,13 +172,16 @@ export async function renderPagesFallback( } } const executionContext = getRequestExecutionContext(); - const pagesApiResponse = await pagesEntry.handleApiRoute( + const apiArgs = [ pagesRequest, pagesUrl, undefined, executionContext?.trustedRevalidateOrigin ?? new URL(pagesRequest.url).origin, executionContext?.hostRuntime ?? "node", - ); + ] as const; + const pagesApiResponse = await (initialResponseHeaders + ? pagesEntry.handleApiRoute(...apiArgs, initialResponseHeaders) + : pagesEntry.handleApiRoute(...apiArgs)); const draftCookie = getDraftModeCookieHeader(); return applyDraftModeCookie( applyRouteHandlerMiddlewareContext(pagesApiResponse, middlewareContext), @@ -205,22 +212,20 @@ export async function renderPagesFallback( const renderRequest = pagesDataRequest ? cloneRequestWithUrl(pagesRequest, pagesDataRequest.url) : pagesRequest; + const renderArgs = [ + renderRequest, + pagesUrl, + {}, + undefined, + middlewareContext.requestHeaders, + ] as const; const pagesRes = isDataRequest - ? await pagesEntry.renderPage( - renderRequest, - pagesUrl, - {}, - undefined, - middlewareContext.requestHeaders, - { isDataReq: true }, - ) - : await pagesEntry.renderPage( - renderRequest, - pagesUrl, - {}, - undefined, - middlewareContext.requestHeaders, - ); + ? await (initialResponseHeaders + ? pagesEntry.renderPage(...renderArgs, { isDataReq: true }, initialResponseHeaders) + : pagesEntry.renderPage(...renderArgs, { isDataReq: true })) + : await (initialResponseHeaders + ? pagesEntry.renderPage(...renderArgs, undefined, initialResponseHeaders) + : pagesEntry.renderPage(...renderArgs)); if (pagesRes.status === 404 && pageMatch === null) return null; return applyDraftModeCookie( applyPagesMiddlewareContext(pagesRes, middlewareContext), diff --git a/packages/vinext/src/server/app-request-stage-context.ts b/packages/vinext/src/server/app-request-stage-context.ts new file mode 100644 index 000000000..2f40c9414 --- /dev/null +++ b/packages/vinext/src/server/app-request-stage-context.ts @@ -0,0 +1,10 @@ +import { setHeadersContext } from "vinext/shims/headers"; +import { setRootParams } from "vinext/shims/root-params"; + +/** The request stage does not render user code, so it owns no navigation context. */ +export function setAppRequestStageNavigationContext(): void {} + +export function clearAppRequestStageContext(): void { + setHeadersContext(null); + setRootParams(null); +} diff --git a/packages/vinext/src/server/app-request-stage-dispatch.ts b/packages/vinext/src/server/app-request-stage-dispatch.ts new file mode 100644 index 000000000..c54c6c8d1 --- /dev/null +++ b/packages/vinext/src/server/app-request-stage-dispatch.ts @@ -0,0 +1,110 @@ +import { isDraftModeRequest } from "vinext/shims/headers"; +import { isRouteTreePrefetchRequest } from "./app-route-tree-prefetch.js"; +import type { AppRscRequestHandler } from "./app-rsc-handler.js"; +import { + APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + type DispatchAppWorkerResponseStage, +} from "./app-worker-stages.js"; +import { getScriptNonceFromHeaderSources } from "./csp.js"; +import { VINEXT_PRERENDER_ROUTE_PARAMS_HEADER } from "./headers.js"; +import type { VinextCacheabilityProbeMode } from "./multi-stage.js"; +import type { TrustedPrerenderState } from "./prerender-route-params.js"; +import { restoreStaticFileSignalFromTransport } from "./static-file-signal.js"; + +export type AppRequestStageDispatchOptions = { + basePath: string; + buildId: string | null; + draftModeSecret: string; + handleRequest: AppRscRequestHandler; + prerenderDiscovery: boolean; + probeMode: VinextCacheabilityProbeMode | null; + trustedPrerenderState?: TrustedPrerenderState | null; +}; + +/** + * Whether this request needs the complete App response graph before ordinary + * request-stage routing can safely classify it. + */ +export function appRequestUsesFullResponseGraph( + request: Request, + options: Pick< + AppRequestStageDispatchOptions, + "basePath" | "draftModeSecret" | "probeMode" | "trustedPrerenderState" + >, +): boolean { + if (request.method !== "GET" && request.method !== "HEAD") return true; + if ( + request.headers + .get("upgrade") + ?.split(",") + .some((value) => value.trim().toLowerCase() === "websocket") + ) { + return true; + } + if (process.env.VINEXT_PRERENDER === "1") return true; + if (options.trustedPrerenderState) return true; + if (isDraftModeRequest(request, options.draftModeSecret)) return true; + if (request.headers.has(VINEXT_PRERENDER_ROUTE_PARAMS_HEADER)) return true; + + const cacheControl = request.headers.get("cache-control")?.toLowerCase() ?? ""; + if ( + !options.probeMode && + /(?:^|,)\s*(?:no-cache|no-store)(?:\s*(?:,|$)|\s*=)/.test(cacheControl) + ) { + return true; + } + if ( + getScriptNonceFromHeaderSources(request.headers) !== undefined || + isRouteTreePrefetchRequest(request) + ) { + return true; + } + + const url = new URL(request.url); + const pathname = + options.basePath && url.pathname.startsWith(options.basePath + "/") + ? url.pathname.slice(options.basePath.length) + : url.pathname; + return pathname.startsWith("/__vinext/"); +} + +/** + * Select the request-only or complete App graph and perform the transport + * bookkeeping needed by a full-stage dispatch. + */ +export async function dispatchAppRequestStage( + request: Request, + ctx: unknown, + dispatchResponseStage: DispatchAppWorkerResponseStage | null | undefined, + options: AppRequestStageDispatchOptions, +): Promise { + if (!dispatchResponseStage) { + throw new Error("App request stage requires a response-stage dispatcher"); + } + if (appRequestUsesFullResponseGraph(request, options)) { + const staticFileSignalToken = crypto.randomUUID(); + const response = await dispatchResponseStage( + request, + { + kind: "app-full-request", + buildId: options.buildId, + cacheability: { + policyHeaders: null, + probeMode: options.probeMode, + resolvedRoutePathname: new URL(request.url).pathname, + }, + draftModeCookie: null, + middlewareCookieOverlay: null, + prerenderDiscovery: options.prerenderDiscovery, + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: new URL(request.url).origin, + scriptNonce: null, + staticFileSignalToken, + trustedPrerenderState: options.trustedPrerenderState ?? null, + }, + { cache: "bypass" }, + ); + return restoreStaticFileSignalFromTransport(response, staticFileSignalToken); + } + return options.handleRequest(request, ctx, false, dispatchResponseStage, options.probeMode); +} diff --git a/packages/vinext/src/server/app-request-stage-independent-entry.ts b/packages/vinext/src/server/app-request-stage-independent-entry.ts new file mode 100644 index 000000000..a04639915 --- /dev/null +++ b/packages/vinext/src/server/app-request-stage-independent-entry.ts @@ -0,0 +1,200 @@ +/** Request-only App Worker stage with no local renderer fallback dependency. */ + +import "./server-globals.js"; +import requestRscHandler, { + __assetPrefix, + __basePath, + __imageAllowedWidths, + __imageConfig, + __prerenderSecret, +} from "virtual:vinext-app-request-entry"; +import { runWithExecutionContext, type ExecutionContextLike } from "vinext/shims/request-context"; +// @ts-expect-error -- virtual module resolved by vinext +import { registerConfiguredCacheAdapters } from "virtual:vinext-cdn-cache-adapter"; +import { applyCdnResponseIdentityHeaders, validateCdnRequest } from "./cache-control.js"; +// @ts-expect-error -- virtual module resolved by vinext +import { registerConfiguredImageOptimizer } from "virtual:vinext-image-adapters"; +import type { DispatchAppWorkerResponseStage } from "./app-worker-stages.js"; +import { + getImageOptimizer, + handleConfiguredImageOptimization, + isImageOptimizationPath, +} from "./image-optimization.js"; +import { + createStaticAssetRequest, + finalizeMissingStaticAssetResponse, + resolveStaticAssetSignal, +} from "./worker-utils.js"; +import { + cloneRequestWithHeaders, + filterInternalHeaders, + isOpenRedirectShaped, +} from "./request-pipeline.js"; +import { + VINEXT_CACHEABILITY_PROBE_HEADER, + VINEXT_CACHEABILITY_PROBE_QUERY_PARAM, + VINEXT_EXPECTED_WORKER_VERSION_HEADER, + VINEXT_PRERENDER_SECRET_HEADER, + VINEXT_REVALIDATE_HOST_HEADER, +} from "./headers.js"; +import { readTrustedPrerenderStateFromHeaders } from "./prerender-route-params.js"; +import { badRequestResponse, notFoundResponse } from "./http-error-responses.js"; +import { assetPrefixPathname, isNextStaticPath } from "../utils/asset-prefix.js"; +import { createWorkerRevalidationContext } from "./worker-revalidation-context.js"; +import { + createWorkerPrerenderDiscoveryContext, + createWorkerPrerenderReadinessResponse, +} from "./worker-prerender-discovery.js"; +import type { + VinextAssetFetcher, + VinextCacheabilityProbeMode, + VinextRequestStageContext, +} from "./multi-stage.js"; +import type { WorkerCacheabilityProbeRoute } from "./cacheability-request.js"; + +export type AppRequestStageEnv = Record; +type AppRequestStageContext = ExecutionContextLike & VinextRequestStageContext; + +const workerBasePath = typeof __basePath === "string" ? __basePath : ""; +const workerAssetPathPrefix = assetPrefixPathname( + typeof __assetPrefix === "string" ? __assetPrefix : "", +); + +export function handleRequestStage( + request: Request, + env: AppRequestStageEnv | undefined, + ctx: AppRequestStageContext | undefined, + dispatchResponseStage: DispatchAppWorkerResponseStage, +): Promise { + const originalRequest = request; + return handleRequest(request, env, ctx, dispatchResponseStage, ctx?.assets).then((response) => + applyCdnResponseIdentityHeaders(response, originalRequest), + ); +} + +async function handleRequest( + request: Request, + env: AppRequestStageEnv | undefined, + platformCtx: ExecutionContextLike | undefined, + dispatchResponseStage: DispatchAppWorkerResponseStage, + assets: VinextAssetFetcher | undefined, +): Promise { + let ctx = platformCtx?.trustedRevalidateOrigin + ? platformCtx + : createWorkerRevalidationContext( + platformCtx, + (internalRequest, internalCtx) => + handleRequest(internalRequest, env, internalCtx, dispatchResponseStage, assets), + "node", + ); + + registerConfiguredCacheAdapters(env); + registerConfiguredImageOptimizer(env); + + ctx = createWorkerPrerenderDiscoveryContext(ctx, request, __prerenderSecret); + const readinessResponse = createWorkerPrerenderReadinessResponse(ctx, request); + let didValidateCdnRequest = false; + if (readinessResponse) { + const validationResponse = await validateCdnRequest(request); + if (validationResponse) return validationResponse; + didValidateCdnRequest = true; + // An authenticated readiness request must continue through the response + // dispatcher so independently hosted stages are proven ready as a unit. + // Failed capability checks stay inside the framework-owned namespace. + if (readinessResponse.status !== 204) return readinessResponse; + } + + let probeMode: VinextCacheabilityProbeMode | null = null; + let probeRoute: WorkerCacheabilityProbeRoute | null = null; + if (request.headers.has(VINEXT_CACHEABILITY_PROBE_HEADER)) { + const { readWorkerCacheabilityProbeMode, readWorkerCacheabilityProbeRoute } = + await import("./cacheability-request.js"); + probeMode = readWorkerCacheabilityProbeMode(request, __prerenderSecret); + if (probeMode) { + probeRoute = readWorkerCacheabilityProbeRoute(request); + const probeUrl = new URL(request.url); + probeUrl.searchParams.delete(VINEXT_CACHEABILITY_PROBE_QUERY_PARAM); + request = new Request(probeUrl, request); + } + } + + if (!didValidateCdnRequest) { + const cdnValidationResponse = await validateCdnRequest(request); + if (cdnValidationResponse) return cdnValidationResponse; + } + + const url = new URL(request.url); + if (isImageOptimizationPath(url.pathname) && assets && getImageOptimizer()) { + return handleConfiguredImageOptimization( + request, + (assetPath) => Promise.resolve(assets.fetch(new Request(new URL(assetPath, request.url)))), + __imageAllowedWidths, + __imageConfig, + ); + } + if (isOpenRedirectShaped(url.pathname)) return notFoundResponse(); + try { + decodeURIComponent(url.pathname); + } catch { + return badRequestResponse(); + } + + const missingBuildAsset = isNextStaticPath(url.pathname, workerBasePath, workerAssetPathPrefix); + const trustedPrerenderState = readTrustedPrerenderStateFromHeaders( + request.headers, + __prerenderSecret, + ); + const filteredHeaders = ctx.isInternalPagesRevalidation + ? new Headers(request.headers) + : filterInternalHeaders(request.headers); + filteredHeaders.delete(VINEXT_PRERENDER_SECRET_HEADER); + filteredHeaders.delete(VINEXT_REVALIDATE_HOST_HEADER); + if (readinessResponse?.status === 204) { + const expectedWorkerVersion = request.headers.get(VINEXT_EXPECTED_WORKER_VERSION_HEADER); + if (expectedWorkerVersion) { + // The request stage already authenticated the build capability. Preserve + // only the version assertion needed by the independently hosted response + // stage; the prerender secret remains confined to this gateway. + filteredHeaders.set(VINEXT_EXPECTED_WORKER_VERSION_HEADER, expectedWorkerVersion); + } + } + request = cloneRequestWithHeaders(request, filteredHeaders); + + let responseStageDispatched = false; + const trackedDispatchResponseStage: DispatchAppWorkerResponseStage = ( + stageRequest, + props, + options, + ) => { + responseStageDispatched = true; + return dispatchResponseStage(stageRequest, props, options); + }; + + const handle = () => + requestRscHandler( + request, + ctx, + trackedDispatchResponseStage, + probeMode, + ctx.isPrerenderPathDiscovery === true, + trustedPrerenderState, + ); + const result = await runWithExecutionContext(ctx, handle); + let response = result; + if (assets) { + const assetResponse = await resolveStaticAssetSignal(response, { + fetchAsset: (path) => Promise.resolve(assets.fetch(createStaticAssetRequest(path, request))), + }); + if (assetResponse) response = assetResponse; + } + response = finalizeMissingStaticAssetResponse(response, missingBuildAsset); + if (probeMode && probeRoute && !responseStageDispatched) { + const { finalizeRequestStageCacheabilityProbe } = await import("./cacheability-request.js"); + response = finalizeRequestStageCacheabilityProbe(response, { + mode: probeMode, + responseStageDispatched, + route: probeRoute, + }); + } + return response; +} diff --git a/packages/vinext/src/server/app-response-stage-entry.ts b/packages/vinext/src/server/app-response-stage-entry.ts new file mode 100644 index 000000000..4eb9ab231 --- /dev/null +++ b/packages/vinext/src/server/app-response-stage-entry.ts @@ -0,0 +1,96 @@ +/** Cacheable App response stage. This is the only multi-stage App entry that imports user routes. */ + +import rscHandler, { __cacheabilityManifest } from "virtual:vinext-app-response-entry"; +import { runWithExecutionContext, type ExecutionContextLike } from "vinext/shims/request-context"; +// @ts-expect-error -- virtual module resolved by vinext +import { registerConfiguredCacheAdapters } from "virtual:vinext-cache-adapters"; +// @ts-expect-error -- virtual module resolved by vinext +import { registerConfiguredImageOptimizer } from "virtual:vinext-image-adapters"; +import { + isAppWorkerResponseStageProps, + type AppWorkerResponseStageProps, +} from "./app-worker-stages.js"; +import { serializeStaticFileSignalForTransport } from "./static-file-signal.js"; +import { createWorkerRevalidationContext } from "./worker-revalidation-context.js"; +import { validateCdnRequest } from "./cache-control.js"; +import { createWorkerPrerenderReadinessResponse } from "./worker-prerender-discovery.js"; +import type { + VinextRequestStageTransport, + VinextResponseStageDispatchOptions, +} from "./multi-stage.js"; +import { withResponseStageCacheability } from "./response-stage-cacheability.js"; + +type AppResponseStageEnv = Record; + +export async function handleResponseStage( + request: Request, + env: AppResponseStageEnv | undefined, + platformCtx: ExecutionContextLike | undefined, + props: AppWorkerResponseStageProps, + dispatchRequestStage: VinextRequestStageTransport, + options: VinextResponseStageDispatchOptions = { cache: "bypass" }, +): Promise { + if (!isAppWorkerResponseStageProps(props)) { + return new Response("Invalid vinext App response stage", { status: 400 }); + } + if (props.requestOrigin !== new URL(request.url).origin) { + return new Response("Invalid vinext App response stage", { status: 400 }); + } + registerConfiguredImageOptimizer(env); + let ctx = createWorkerRevalidationContext( + platformCtx, + (internalRequest) => dispatchRequestStage(internalRequest), + "node", + ); + if (props.kind === "app-full-request" && props.prerenderDiscovery) { + ctx = { ...ctx, isPrerenderPathDiscovery: true }; + } + return withResponseStageCacheability( + { + buildId: process.env.__VINEXT_BUILD_ID, + cache: options.cache, + context: ctx, + policyHeaders: props.cacheability.policyHeaders, + probeMode: props.cacheability.probeMode, + rawManifest: __cacheabilityManifest, + registerCacheAdapters: () => registerConfiguredCacheAdapters(env), + request, + representation: props.cacheability.representation, + resolvedRoutePathname: props.cacheability.resolvedRoutePathname, + }, + async (cacheabilityContext) => { + if (props.kind === "app-full-request") { + const currentBuildId = process.env.__VINEXT_BUILD_ID ?? null; + if (props.buildId !== currentBuildId) { + return new Response("Incompatible vinext App response stage", { status: 409 }); + } + if (props.prerenderDiscovery) { + const readinessResponse = createWorkerPrerenderReadinessResponse( + cacheabilityContext, + request, + ); + if (readinessResponse) { + return (await validateCdnRequest(request)) ?? readinessResponse; + } + } + const fullEntry = await import("virtual:vinext-rsc-entry"); + const render = () => + fullEntry.default( + request, + cacheabilityContext, + false, + undefined, + null, + props.trustedPrerenderState, + ); + return serializeStaticFileSignalForTransport( + await runWithExecutionContext(cacheabilityContext, render), + props.staticFileSignalToken, + ); + } + const render = () => + rscHandler.handleResponseStage(request, cacheabilityContext, props, options); + return runWithExecutionContext(cacheabilityContext, render); + }, + ); +} diff --git a/packages/vinext/src/server/app-route-handler-execution.ts b/packages/vinext/src/server/app-route-handler-execution.ts index afeffe3e6..35947001d 100644 --- a/packages/vinext/src/server/app-route-handler-execution.ts +++ b/packages/vinext/src/server/app-route-handler-execution.ts @@ -12,6 +12,7 @@ import { runWithRootParamsUsage } from "vinext/shims/root-params"; import { applyCdnResponseHeaders, hasExplicitNonCacheableResponsePolicy, + getCdnResponsePolicyHeaderNames, NEVER_CACHE_CONTROL, } from "./cache-control.js"; import { isrCacheControl, type IsrWritePolicy } from "./isr-cache.js"; @@ -44,7 +45,6 @@ import { import { getRouteCacheabilityCaptureOptions, getRouteCacheabilityDynamicReason, - CACHEABILITY_POLICY_HEADERS, isRouteCacheabilityEvaluation, markRouteCacheabilityExplicitResponsePolicy, markRouteCacheabilityResponseBodyComplete, @@ -119,7 +119,7 @@ type CompletedAppRouteHandlerResponse = { function hasExplicitCacheableResponsePolicy(headers: Headers): boolean { return ( !hasExplicitNonCacheableResponsePolicy(headers) && - CACHEABILITY_POLICY_HEADERS.some((name) => headers.has(name)) + [...getCdnResponsePolicyHeaderNames()].some((name) => headers.has(name)) ); } @@ -323,7 +323,7 @@ export async function executeAppRouteHandler( } let { dynamicUsedInHandler, response } = handlerResult; assertSupportedAppRouteHandlerResponse(response); - const handlerSetCachePolicy = CACHEABILITY_POLICY_HEADERS.some((name) => + const handlerSetCachePolicy = [...getCdnResponsePolicyHeaderNames()].some((name) => response.headers.has(name), ); const hasExplicitCacheablePolicy = hasExplicitCacheableResponsePolicy(response.headers); diff --git a/packages/vinext/src/server/app-route-handler-middleware-context.ts b/packages/vinext/src/server/app-route-handler-middleware-context.ts new file mode 100644 index 000000000..3ecd214b9 --- /dev/null +++ b/packages/vinext/src/server/app-route-handler-middleware-context.ts @@ -0,0 +1,25 @@ +import { mergeMiddlewareResponseHeaders } from "./middleware-response-headers.js"; + +export type RouteHandlerMiddlewareContext = { + headers: Headers | null; + status: number | null; +}; + +/** Apply request-stage middleware response metadata to a route-handler response. */ +export function applyRouteHandlerMiddlewareContext( + response: Response, + middlewareContext: RouteHandlerMiddlewareContext, +): Response { + if (!middlewareContext.headers && middlewareContext.status == null) { + return response; + } + + const responseHeaders = new Headers(response.headers); + mergeMiddlewareResponseHeaders(responseHeaders, middlewareContext.headers); + + return new Response(response.body, { + status: middlewareContext.status ?? response.status, + statusText: response.statusText, + headers: responseHeaders, + }); +} diff --git a/packages/vinext/src/server/app-route-handler-runtime.ts b/packages/vinext/src/server/app-route-handler-runtime.ts index 6bca19da8..fd648f4f4 100644 --- a/packages/vinext/src/server/app-route-handler-runtime.ts +++ b/packages/vinext/src/server/app-route-handler-runtime.ts @@ -371,18 +371,23 @@ export function createTrackedAppRouteRequest( const requestWithOverrides = requestHeaders ? cloneRequestWithHeaders(input, requestHeaders) : input; - const sourceCfMetadata = Reflect.get(requestWithOverrides, "cf", requestWithOverrides); const sourceCfDescriptor = Reflect.getOwnPropertyDescriptor(requestWithOverrides, "cf"); + const sourceCfAccessor = sourceCfDescriptor?.get; + const sourceCfMetadata = sourceCfAccessor + ? undefined + : Reflect.get(requestWithOverrides, "cf", requestWithOverrides); const nextRequest = requestMode === "force-static" ? createForceStaticNextRequest(requestWithOverrides, nextConfig) : requestWithOverrides instanceof NextRequest && sourceCfDescriptor?.configurable !== false ? requestWithOverrides : new NextRequest(requestWithOverrides, { nextConfig: nextConfig ?? undefined }); - const rawCfMetadata = - sourceCfMetadata === undefined + const rawCfMetadata = sourceCfAccessor + ? undefined + : sourceCfMetadata === undefined ? Reflect.get(nextRequest, "cf", nextRequest) : sourceCfMetadata; + const hasCfMetadata = sourceCfAccessor !== undefined || rawCfMetadata !== undefined; const originalCfDescriptor = sourceCfDescriptor ?? Reflect.getOwnPropertyDescriptor(nextRequest, "cf"); const accessCf = (read: () => T, forceStaticValue: T): T => { @@ -391,11 +396,17 @@ export function createTrackedAppRouteRequest( markDynamicAccess("request.cf"); return read(); }; - const controlledCfGetter = (): unknown => accessCf(() => rawCfMetadata, undefined); + const controlledCfGetter = (): unknown => + accessCf( + sourceCfAccessor + ? () => Reflect.apply(sourceCfAccessor, requestWithOverrides, []) + : () => rawCfMetadata, + undefined, + ); // Keep request-specific Workers metadata behind one target-owned policy // boundary. Request methods stay bound to this branded target, so indirect // reads from branded Web APIs still reach the controlled accessor. - if (rawCfMetadata !== undefined) { + if (hasCfMetadata) { if (requestMode === "force-static") { // Keep target-bound branded wrappers from reaching the underlying // Workers value. Reflection traps hide this configurable accessor. @@ -414,12 +425,16 @@ export function createTrackedAppRouteRequest( } const cloneTrackedRequest = (): NextRequest => { const cloned = nextRequest.clone(); - if (rawCfMetadata !== undefined) { - Object.defineProperty(cloned, "cf", { - value: rawCfMetadata, - enumerable: originalCfDescriptor?.enumerable ?? false, - configurable: true, - }); + if (hasCfMetadata) { + Object.defineProperty( + cloned, + "cf", + sourceCfDescriptor ?? { + value: rawCfMetadata, + enumerable: originalCfDescriptor?.enumerable ?? false, + configurable: true, + }, + ); } return wrapRequest(cloned); }; diff --git a/packages/vinext/src/server/app-rsc-combined-handler.ts b/packages/vinext/src/server/app-rsc-combined-handler.ts new file mode 100644 index 000000000..381f2c78a --- /dev/null +++ b/packages/vinext/src/server/app-rsc-combined-handler.ts @@ -0,0 +1,37 @@ +/** Combined request/response handler used by default single-stage App entries. */ + +import { + createAppRscRequestHandler, + type AppRscHandlerRoute, + type AppRscRequestHandler, + type CreateAppRscHandlerOptions, +} from "./app-rsc-handler.js"; +import type { AppWorkerResponseStageProps } from "./app-worker-stages.js"; +import type { VinextResponseStageDispatchOptions } from "./multi-stage.js"; + +export type AppRscHandler = AppRscRequestHandler & { + handleResponseStage( + request: Request, + ctx: unknown, + props: AppWorkerResponseStageProps, + options?: VinextResponseStageDispatchOptions, + ): Promise; +}; + +export function createAppRscHandler( + options: CreateAppRscHandlerOptions, +): AppRscHandler { + const appRscHandler = createAppRscRequestHandler(options); + return Object.assign(appRscHandler, { + handleResponseStage( + request: Request, + ctx: unknown, + props: AppWorkerResponseStageProps, + stageOptions?: VinextResponseStageDispatchOptions, + ) { + return import("./app-rsc-response-stage.js").then(({ renderAppWorkerResponseStage }) => + renderAppWorkerResponseStage(options, request, ctx, props, stageOptions), + ); + }, + }); +} diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 6317884b1..638a67b4a 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -9,8 +9,13 @@ import { requestContextFromRequest } from "../config/request-context.js"; import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; import { isExternalUrl } from "../utils/external-url.js"; import { + getEffectiveRequestCookieHeader, + getDraftModeCookieHeader, getHeadersContext, + hasEffectiveRequestCookieChanges, headersContextFromRequest, + isDraftModeEnabled, + isDraftModeRequest, runWithHeadersContext, } from "vinext/shims/headers"; import { @@ -26,11 +31,12 @@ import { VINEXT_PRERENDER_SECRET_HEADER, VINEXT_PRERENDER_SPECULATIVE_HEADER, VINEXT_PRERENDER_STATIC_PARAMS_PATH, + VINEXT_PARAMS_HEADER, + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, VINEXT_REVALIDATE_HOST_HEADER, VINEXT_INTERCEPTION_CONTEXT_HEADER, VINEXT_INTERCEPTION_ID_HEADER, } from "./headers.js"; -import { ensureFetchPatch, setCurrentFetchSoftTags } from "vinext/shims/fetch-cache"; import type { ReactFormState } from "react-dom/client"; import { getRequestExecutionContext, @@ -47,6 +53,7 @@ import { import { flattenErrorCauses } from "../utils/error-cause.js"; import { addBasePathToPathname, hasBasePath, stripBasePath } from "../utils/base-path.js"; import { mergeRewriteQuery } from "../utils/query.js"; +import { hasMiddlewareRequestHeaderOverrides } from "../utils/middleware-request-headers.js"; import type { AppMiddlewareContext, ApplyAppMiddlewareResult } from "./app-middleware.js"; import { mergeMiddlewareResponseHeaders } from "./app-page-response.js"; import type { @@ -61,12 +68,22 @@ import { stripRscSuffix, VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM, } from "./app-rsc-cache-busting.js"; -import { applyAppRscConfigHeaders, finalizeAppRscResponse } from "./app-rsc-response-finalizer.js"; +import { + applyAppRscConfigHeaders, + finalizeAppRscResponse, + markAppRscResponseConfigHeadersApplied, +} from "./app-rsc-response-finalizer.js"; import { normalizeRscRequest } from "./app-rsc-request-normalization.js"; import { buildNextDataNotFoundResponse, normalizePagesDataRequest } from "./pages-data-route.js"; import { normalizeDefaultLocalePathname } from "./pages-i18n.js"; import { badRequestResponse, notFoundResponse } from "./http-error-responses.js"; -import { isOnDemandRevalidateRequest, PRERENDER_REVALIDATE_HEADER } from "./isr-cache.js"; +import { + isOnDemandRevalidateRequest, + PRERENDER_REVALIDATE_HEADER, +} from "./revalidation-request.js"; +import { prepareSharedAppPageDispatch } from "./worker-stages.js"; +import type { PagesRouteDataKind } from "./pages-request-pipeline.js"; +import { hasPagesPreviewCookie } from "./pages-response-stage.js"; import { isInterceptionMatchedUrlPath, normalizePath } from "./normalize-path.js"; import { getRenderedConcreteUrlPathsForRoute } from "./pregenerated-concrete-paths.js"; import { getScriptNonceFromHeaderSources } from "./csp.js"; @@ -84,7 +101,13 @@ import { buildPostMwRequestContext } from "./app-post-middleware-context.js"; import type { AppRscRenderMode } from "./app-rsc-render-mode.js"; import type { AppPagePprFallbackCacheShell } from "./app-ppr-fallback-shell.js"; import type { ClientReuseManifestParseResult } from "./client-reuse-manifest.js"; -import { applyCdnResponseHeaders, NEVER_CACHE_CONTROL } from "./cache-control.js"; +import { + applyCdnResponseHeaders, + captureCdnResponsePolicyOverrides, + getCdnResponsePolicyHeaderNames, + NEVER_CACHE_CONTROL, + reconcileCdnResponseHeadersAfterOuterPolicy, +} from "./cache-control.js"; import { cloneRequestWithHeaders, cloneRequestWithUrl, @@ -96,6 +119,7 @@ import { matchPrerenderRouteParamsPayload, readTrustedPrerenderRouteParams, serializePrerenderRouteParamsHeader, + type TrustedPrerenderState, } from "./prerender-route-params.js"; import { createServerActionNotFoundResponse, @@ -111,10 +135,21 @@ import { markRouteCacheabilityDynamic, preserveRouteCacheabilityResponsePolicy, } from "vinext/shims/cacheability-classification"; +import { + APP_METADATA_RESPONSE_STAGE_NO_MATCH_HEADER, + APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + type AppMatchedWorkerResponseStageProps, + type DispatchAppWorkerResponseStage, + type RenderAppWorkerResponseStageLocally, +} from "./app-worker-stages.js"; +import type { VinextCacheabilityProbeMode } from "./multi-stage.js"; +import { + consumePagesResponseStagePolicyOwner, + withResponseStageVary, +} from "./response-stage-policy.js"; type AppPageParams = Record; type RequestContext = ReturnType; -const STATIC_METADATA_CONFIG_HEADER_OVERRIDES = new Set(["cache-control"]); const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false"; const HAS_CONFIG_REDIRECTS = process.env.__VINEXT_HAS_CONFIG_REDIRECTS !== "false"; const HAS_CONFIG_REWRITES = process.env.__VINEXT_HAS_CONFIG_REWRITES !== "false"; @@ -182,6 +217,22 @@ function haveSamePageParams(first: AppPageParams, second: AppPageParams): boolea return true; } +function requestOptsOutOfWorkerResponseStage( + request: Request, + options: Pick, "draftModeSecret">, + scriptNonce: string | undefined, + allowInternalRscDocumentFallback: boolean, +): boolean { + if (request.method !== "GET" && request.method !== "HEAD") return true; + if (allowInternalRscDocumentFallback || scriptNonce !== undefined) return true; + if (isDraftModeRequest(request, options.draftModeSecret) || isDraftModeEnabled()) return true; + if (isOnDemandRevalidateRequest(request.headers.get(PRERENDER_REVALIDATE_HEADER))) return true; + if (request.headers.has(VINEXT_PRERENDER_ROUTE_PARAMS_HEADER)) return true; + + const requestCacheControl = request.headers.get("cache-control")?.toLowerCase() ?? ""; + return /(?:^|,)\s*(?:no-cache|no-store)(?:\s*(?:,|$)|\s*=)/.test(requestCacheControl); +} + function hasUrlParserDotSegment(pathname: string): boolean { return pathname.split("/").some((segment) => { const decodedDots = segment.replaceAll(/%2e/gi, "."); @@ -200,7 +251,7 @@ type RunAppMiddlewareOptions = { validateExternalRewriteRequest: () => Promise; }; -type AppRscHandlerRoute = { +export type AppRscHandlerRoute = { __loadPage?: unknown; __loadRouteHandler?: unknown; isDynamic: boolean; @@ -360,8 +411,15 @@ type RenderNotFoundOptions = { type RenderPagesFallbackOptions = { allowRscDocumentFallback?: boolean; appRouteMatch?: { route: { isDynamic: boolean; pattern: string } } | null; + dispatchPagesResponseStage?: ( + request: Request, + resourceKind: "api" | "page", + dataKind?: PagesRouteDataKind, + hasRequestAwareDocument?: boolean, + ) => Promise; isDataRequest?: boolean; isRscRequest: boolean; + initialResponseHeaders?: Headers; matchKind?: "dynamic" | "static"; middlewareContext: AppRscMiddlewareContext; pathname?: string; @@ -376,7 +434,7 @@ type NavigationContextValue = { searchParams: URLSearchParams; }; -type CreateAppRscHandlerOptions = { +export type CreateAppRscHandlerOptions = { basePath: string; buildId: string | null; clearRequestContext: () => void; @@ -406,6 +464,8 @@ type CreateAppRscHandlerOptions = { * Node server and dev included, not just the Cloudflare worker entry. */ registerCacheAdapters: (env?: Record) => void; + /** Request-only entries dynamically load the response graph for local bypasses. */ + renderResponseStageLocally?: RenderAppWorkerResponseStageLocally; handleProgressiveActionRequest?: ( options: HandleProgressiveActionRequestOptions, ) => Promise; @@ -421,6 +481,7 @@ type CreateAppRscHandlerOptions = { ) => Promise; i18nConfig: NextI18nConfig | null; imageConfig?: ImageConfig; + isMetadataRoute?: (pathname: string) => boolean; isDev: boolean; hasInterceptionId: (interceptionId: string) => boolean; loadPrerenderPagesRoutes?: () => Promise; @@ -508,6 +569,7 @@ async function applyRewrite( validateExternalRewriteRequest: () => Promise; }, cleanPathname: string, + recordCacheability = true, ): Promise { if (!HAS_CONFIG_REWRITES || !options.rewrites.length) return null; @@ -519,7 +581,7 @@ async function applyRewrite( options.requestContext, options.basePathState, options.paramsPathname, - markConditionalRewriteCacheability, + recordCacheability ? markConditionalRewriteCacheability : undefined, ); if (!rewritten) return null; @@ -556,6 +618,7 @@ async function applyConfigHeadersToMiddlewareRedirect( basePathState: BasePathMatchState; configHeaders: NextHeader[]; pathname: string; + recordCacheability: boolean; requestContext: RequestContext; }, ): Promise { @@ -572,6 +635,7 @@ async function applyConfigHeadersToMiddlewareRedirect( pathname: options.pathname, requestContext: options.requestContext, basePathState: options.basePathState, + recordCacheability: options.recordCacheability, }); if (!headers.entries().next().done) { @@ -645,7 +709,9 @@ async function handleAppRscRequest( pagesDataRequest: Request | null, dispatchInternalRequest: (request: Request) => Promise, allowInternalRscDocumentFallback: boolean, - setInterceptionResponseUncacheable: (uncacheable: boolean) => void, + dispatchResponseStage?: DispatchAppWorkerResponseStage, + responseStageProbeMode: VinextCacheabilityProbeMode | null = null, + setInterceptionResponseUncacheable: (uncacheable: boolean) => void = () => {}, ): Promise { const handlerStart = process.env.NODE_ENV !== "production" ? performance.now() : 0; @@ -862,7 +928,7 @@ async function handleAppRscRequest( options.configRedirects, preMiddlewareRequestContext, basePathState, - markConditionalRedirectCacheability, + dispatchResponseStage ? undefined : markConditionalRedirectCacheability, ) : null; if (configMatchers && redirect) { @@ -930,7 +996,7 @@ async function handleAppRscRequest( request: userlandRequest, validateExternalRewriteRequest: () => validateClaimedOutsideBasePathRsc(true), }); - if (middlewareResult.pathnameEligible) { + if (!dispatchResponseStage && middlewareResult.pathnameEligible) { // Next.js runs matched middleware before serving a page response. A CDN // HIT in front of this Worker would skip that request-specific boundary, // so this architecture must remain private until middleware is isolated @@ -949,6 +1015,7 @@ async function handleAppRscRequest( basePathState, configHeaders: options.configHeaders, pathname: matchPathname(requestCleanPathname), + recordCacheability: dispatchResponseStage === undefined, requestContext: preMiddlewareRequestContext, }); } @@ -968,6 +1035,213 @@ async function handleAppRscRequest( } const scriptNonce = getScriptNonceFromHeaderSources(request.headers, middlewareContext.headers); + const hasMiddlewareCookieOverlay = hasEffectiveRequestCookieChanges( + request.headers.get("cookie"), + ); + const middlewareCookieOverlay = hasMiddlewareCookieOverlay + ? (getEffectiveRequestCookieHeader() ?? "") + : null; + const draftModeCookie = + dispatchResponseStage || options.renderResponseStageLocally ? getDraftModeCookieHeader() : null; + const responseStageCacheability = (resolvedRouteUrl: string) => ({ + policyHeaders: null, + probeMode: responseStageProbeMode, + resolvedRoutePathname: pathnameForResolvedUrl(resolvedRouteUrl), + }); + let responseStagePolicyPromise: Promise | null> | undefined; + const loadResponseStagePolicy = () => + (responseStagePolicyPromise ??= options.configHeaders.length + ? import("./config-headers.js").then(({ resolveResponseStageCachePolicy }) => + withResponseStageVary( + resolveResponseStageCachePolicy({ + basePathState, + configHeaders: options.configHeaders, + pathname: matchPathname(requestCleanPathname), + requestContext: preMiddlewareRequestContext, + }), + middlewareContext.headers?.get("Vary"), + ), + ) + : Promise.resolve(withResponseStageVary(null, middlewareContext.headers?.get("Vary")))); + let canUseSharedWorkerResponseStage = + draftModeCookie === null && + !hasMiddlewareCookieOverlay && + !hasMiddlewareRequestHeaderOverrides( + middlewareContext.requestHeaders ?? middlewareContext.headers, + ) && + !requestOptsOutOfWorkerResponseStage( + request, + options, + scriptNonce, + allowInternalRscDocumentFallback, + ); + const isOnDemandRevalidate = isOnDemandRevalidateRequest( + request.headers.get(PRERENDER_REVALIDATE_HEADER), + ); + const transportedResponseStage: RenderAppWorkerResponseStageLocally | undefined = + dispatchResponseStage + ? async (stageRequest, props) => { + const cache = + responseStageProbeMode || + isOnDemandRevalidate || + ((props.kind === "app-page" || props.kind === "app-route-handler") && + props.bypassInterceptionContextCache) + ? "bypass" + : canUseSharedWorkerResponseStage + ? "shared" + : "bypass"; + let response = await dispatchResponseStage( + props.kind === "app-page" + ? prepareSharedAppPageDispatch(stageRequest, cache) + : stageRequest, + { + ...props, + cacheability: { + ...props.cacheability, + policyHeaders: await loadResponseStagePolicy(), + }, + }, + { cache }, + ); + if (stageRequest.method.toUpperCase() === "HEAD" && response.body) { + await response.body.cancel(); + response = new Response(null, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); + } + if (props.kind !== "app-page" || !props.isRscRequest) { + return response; + } + + // These headers describe the current routed request, not the shared + // RSC bytes. Compose them above the adapter so a cache HIT cannot + // replay another query/path and never loses dynamic params. + const headers = new Headers(response.headers); + if (Object.keys(props.params).length > 0) { + headers.set(VINEXT_PARAMS_HEADER, encodeURIComponent(JSON.stringify(props.params))); + } else { + headers.delete(VINEXT_PARAMS_HEADER); + } + headers.set( + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, + encodeURIComponent(props.resolvedUrl), + ); + return preserveFullyBufferedBodyMetadata( + response, + new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }), + ); + } + : undefined; + const responseStageRequest = (stageRequest = request): Request => { + const activeHeaders = getHeadersContext()?.headers; + if (!activeHeaders) return stageRequest; + return cloneRequestWithHeaders(stageRequest, new Headers(activeHeaders)); + }; + const renderMetadataRouteIfMatched = async (): Promise => { + if ( + !filesystemRouteEligible || + (options.isMetadataRoute + ? !options.isMetadataRoute(cleanPathname) + : !options.handleMetadataRouteRequest) + ) { + return null; + } + const metadataResponseStage = transportedResponseStage ?? options.renderResponseStageLocally; + if (metadataResponseStage) { + const response = await metadataResponseStage(responseStageRequest(), { + kind: "app-metadata", + buildId: options.buildId, + cacheability: responseStageCacheability(resolvedUrl), + canonicalPathname, + cleanPathname, + draftModeCookie, + isRscRequest, + middlewareCookieOverlay, + mountedSlotsHeader, + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: url.origin, + renderMode, + resolvedUrl, + scriptNonce: scriptNonce ?? null, + }); + if (response.headers.get(APP_METADATA_RESPONSE_STAGE_NO_MATCH_HEADER) === "1") { + await response.body?.cancel(); + return null; + } + return response; + } + return options.handleMetadataRouteRequest?.(cleanPathname) ?? null; + }; + const applyConfigHeadersToResponseStage = async ( + response: Response, + preserveExistingPolicy = false, + ): Promise => { + // Responses returned by a transport binding can have immutable headers. + // Compose config headers on a mutable copy while retaining stream/cache + // metadata carried by the response-stage result. + const headers = new Headers(response.headers); + await applyAppRscConfigHeaders(headers, request, { + basePath: options.basePath, + configHeaders: options.configHeaders, + i18nConfig: options.i18nConfig, + overwriteExisting: preserveExistingPolicy + ? new Set() + : getCdnResponsePolicyHeaderNames(), + recordCacheability: dispatchResponseStage === undefined, + requestContext: preMiddlewareRequestContext, + }); + return preserveFullyBufferedBodyMetadata( + response, + new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }), + ); + }; + let preHandlerResponseHeadersPromise: Promise | undefined; + const loadPreHandlerResponseHeaders = () => + (preHandlerResponseHeadersPromise ??= (async () => { + const headers = new Headers(); + await applyAppRscConfigHeaders(headers, request, { + basePath: options.basePath, + configHeaders: options.configHeaders, + i18nConfig: options.i18nConfig, + overwriteExisting: getCdnResponsePolicyHeaderNames(), + recordCacheability: false, + requestContext: preMiddlewareRequestContext, + }); + mergeMiddlewareResponseHeaders(headers, middlewareContext.headers); + return headers; + })()); + let outerResponsePolicyPromise: Promise | undefined; + const loadOuterResponsePolicy = () => + (outerResponsePolicyPromise ??= Promise.all([ + loadPreHandlerResponseHeaders(), + loadResponseStagePolicy(), + ]).then(([headers, responseStagePolicy]) => + captureCdnResponsePolicyOverrides(headers, new Headers(responseStagePolicy ?? [])), + )); + const composeResponseStageResponse = async (response: Response): Promise => { + // Positive config cache policy was already transported into the response + // stage before admission. Preserve its completed policy so a late render + // failure cannot be made public again and the uncached gateway does not + // expose a shared-cache directive. Single-stage rendering still applies + // config and middleware policy with ordinary Next.js precedence. + response = await applyConfigHeadersToResponseStage( + response, + Boolean(dispatchResponseStage || options.renderResponseStageLocally), + ); + response = applyMiddlewareContextToResponse(response, middlewareContext); + reconcileCdnResponseHeadersAfterOuterPolicy(response.headers, await loadOuterResponsePolicy()); + return markAppRscResponseConfigHeadersApplied(response); + }; const postMiddlewareRequestContext = buildPostMwRequestContext(userlandRequest); filesystemRouteEligible ||= didMiddlewareRewrite; @@ -996,6 +1270,7 @@ async function handleAppRscRequest( validateExternalRewriteRequest: () => validateClaimedOutsideBasePathRsc(true), }, matchPathname(cleanPathname), + dispatchResponseStage === undefined, ); if (beforeFilesRewrite instanceof Response) return beforeFilesRewrite; if (beforeFilesRewrite) { @@ -1036,6 +1311,7 @@ async function handleAppRscRequest( validateExternalRewriteRequest: () => validateClaimedOutsideBasePathRsc(true), }, matchPathname(cleanPathname), + dispatchResponseStage === undefined, ); if (rewritten instanceof Response) return rewritten; if (!rewritten) continue; @@ -1065,6 +1341,7 @@ async function handleAppRscRequest( validateExternalRewriteRequest: () => validateClaimedOutsideBasePathRsc(true), }, matchPathname(cleanPathname), + dispatchResponseStage === undefined, ); if (rewritten instanceof Response) return rewritten; if (!rewritten) continue; @@ -1097,23 +1374,9 @@ async function handleAppRscRequest( return Response.redirect(new URL(imageRedirect, url.origin).href, 302); } - if (filesystemRouteEligible && options.handleMetadataRouteRequest) { - const metadataRouteResponse = await options.handleMetadataRouteRequest(cleanPathname); - if (metadataRouteResponse && HAS_CONFIG_HEADERS && options.configHeaders.length) { - const { applyConfigHeadersToResponse } = await import("./config-headers.js"); - applyConfigHeadersToResponse(metadataRouteResponse.headers, { - basePathState, - configHeaders: options.configHeaders, - overwriteExisting: STATIC_METADATA_CONFIG_HEADER_OVERRIDES, - pathname: matchPathname( - cleanPathnameIsRequestPathname ? requestCleanPathname : cleanPathname, - ), - requestContext: preMiddlewareRequestContext, - }); - } - if (metadataRouteResponse) { - return applyMiddlewareContextToResponse(metadataRouteResponse, middlewareContext); - } + const metadataRouteResponse = await renderMetadataRouteIfMatched(); + if (metadataRouteResponse) { + return composeResponseStageResponse(metadataRouteResponse); } const publicFileResponse = filesystemRouteEligible @@ -1294,7 +1557,7 @@ async function handleAppRscRequest( void sourceRequest.body.cancel().catch(() => {}); } } - if (sourceMiddlewareResult.pathnameEligible) { + if (!dispatchResponseStage && sourceMiddlewareResult.pathnameEligible) { markRouteCacheabilityDynamic( sourceMiddlewareResult.matched ? "middleware matched this request" @@ -1336,6 +1599,10 @@ async function handleAppRscRequest( } if (addedSourceHeader) { targetHeadersContext.readonlyHeaders = undefined; + // Source-route middleware runs after the initial response-stage + // eligibility decision. A header added here is observable by the + // intercepted render, so its representation is request-specific. + canUseSharedWorkerResponseStage = false; } if (sourceMiddlewareResult.rewritten) { // Rewrites such as locale insertion are valid only when they resolve to @@ -1450,6 +1717,7 @@ async function handleAppRscRequest( basePath: options.basePath, configHeaders: options.configHeaders, i18nConfig: options.i18nConfig, + recordCacheability: dispatchResponseStage === undefined, requestContext: preMiddlewareRequestContext, }, ); @@ -1483,12 +1751,85 @@ async function handleAppRscRequest( matchKind: "dynamic" | "static", ): Promise => { if (!filesystemRouteEligible) return null; + let sharedOuterPolicyNeedsReconciliation = false; + const dispatchPagesResponseStage = dispatchResponseStage + ? async ( + stageRequest: Request, + resourceKind: "api" | "page", + dataKind?: PagesRouteDataKind, + hasRequestAwareDocument?: boolean, + ) => { + const pagesOnDemandRevalidate = + resourceKind === "page" && + isOnDemandRevalidateRequest(stageRequest.headers.get(PRERENDER_REVALIDATE_HEADER)); + const cache = + responseStageProbeMode || + pagesOnDemandRevalidate || + hasPagesPreviewCookie(stageRequest.headers.get("cookie")) || + (resourceKind === "page" && dataKind === "static" && hasRequestAwareDocument) + ? "bypass" + : canUseSharedWorkerResponseStage + ? "shared" + : "bypass"; + const renderRequest = responseStageRequest(stageRequest); + let response = await dispatchResponseStage( + renderRequest, + { + kind: "hybrid-pages", + buildId: options.buildId, + cacheability: { + ...responseStageCacheability(resolvedUrl), + policyHeaders: await loadResponseStagePolicy(), + ...(isDataRequest ? { representation: "pages-data" as const } : {}), + }, + allowRscDocumentFallback: + didMiddlewareRewritePathname || allowInternalRscDocumentFallback, + appRouteMatch: match + ? { isDynamic: match.route.isDynamic, pattern: match.route.pattern } + : null, + canonicalPathname, + cleanPathname, + draftModeCookie, + isDataRequest, + isRscRequest, + matchKind, + middlewareCookieOverlay, + preHandlerHeaders: + cache === "shared" && resourceKind === "page" && dataKind === "static" + ? null + : [...(await loadPreHandlerResponseHeaders())], + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: url.origin, + resourceKind, + requestUrl: request.url, + resolvedUrl, + scriptNonce: scriptNonce ?? null, + }, + { cache }, + ); + const policyOwner = + resourceKind === "page" + ? consumePagesResponseStagePolicyOwner(response) + : { owner: null, response }; + response = policyOwner.response; + const requestTimePolicyOwner = + policyOwner.owner === "request-time" || + (policyOwner.owner === null && dataKind === "server"); + sharedOuterPolicyNeedsReconciliation = + cache === "shared" && resourceKind === "page" && !requestTimePolicyOwner; + return applyConfigHeadersToResponseStage( + response, + resourceKind === "api" || requestTimePolicyOwner, + ); + } + : undefined; const response = !isInterceptionMatch && (match === null || match.route.isDynamic) ? ((await options.renderPagesFallback?.({ appRouteMatch: match ?? null, allowRscDocumentFallback: didMiddlewareRewritePathname || allowInternalRscDocumentFallback, + dispatchPagesResponseStage, isDataRequest, isRscRequest, matchKind, @@ -1500,15 +1841,30 @@ async function handleAppRscRequest( })) ?? null) : null; if (response) preserveRouteCacheabilityResponsePolicy(); - if (!response || !pagesDataRequest || resolvedUrl === originalResolvedUrl) return response; + if (!response) return null; + if (sharedOuterPolicyNeedsReconciliation) { + reconcileCdnResponseHeadersAfterOuterPolicy( + response.headers, + await loadOuterResponsePolicy(), + ); + } + + if (!pagesDataRequest || resolvedUrl === originalResolvedUrl) { + return dispatchPagesResponseStage + ? markAppRscResponseConfigHeadersApplied(response) + : response; + } const headers = new Headers(response.headers); headers.set("x-nextjs-rewrite", resolvedUrl); - return new Response(response.body, { + const rewrittenResponse = new Response(response.body, { headers, status: response.status, statusText: response.statusText, }); + return dispatchPagesResponseStage + ? markAppRscResponseConfigHeadersApplied(rewrittenResponse) + : rewrittenResponse; }; const staticPagesFallbackResponse = await renderPagesForMatchKind("static"); if (staticPagesFallbackResponse) { @@ -1535,6 +1891,7 @@ async function handleAppRscRequest( validateExternalRewriteRequest: () => validateClaimedOutsideBasePathRsc(true), }, matchPathname(cleanPathname), + dispatchResponseStage === undefined, ); if (afterFilesRewrite instanceof Response) { invalidateInterceptionCacheProof(); @@ -1548,6 +1905,10 @@ async function handleAppRscRequest( filesystemRouteEligible = true; const claimedRscCacheBustingRedirect = await validateClaimedOutsideBasePathRsc(); if (claimedRscCacheBustingRedirect) return claimedRscCacheBustingRedirect; + const rewrittenMetadataResponse = await renderMetadataRouteIfMatched(); + if (rewrittenMetadataResponse) { + return composeResponseStageResponse(rewrittenMetadataResponse); + } match = matchCleanPathname(); const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static"); if (rewrittenStaticPagesResponse) { @@ -1589,6 +1950,7 @@ async function handleAppRscRequest( validateExternalRewriteRequest: () => validateClaimedOutsideBasePathRsc(true), }, matchPathname(cleanPathname), + dispatchResponseStage === undefined, ); if (fallbackRewrite instanceof Response) { invalidateInterceptionCacheProof(); @@ -1602,6 +1964,10 @@ async function handleAppRscRequest( filesystemRouteEligible = true; const claimedRscCacheBustingRedirect = await validateClaimedOutsideBasePathRsc(); if (claimedRscCacheBustingRedirect) return claimedRscCacheBustingRedirect; + const rewrittenMetadataResponse = await renderMetadataRouteIfMatched(); + if (rewrittenMetadataResponse) { + return composeResponseStageResponse(rewrittenMetadataResponse); + } match = matchCleanPathname(); const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static"); if (rewrittenStaticPagesResponse) { @@ -1684,6 +2050,27 @@ async function handleAppRscRequest( return new Response("", { status: 404 }); } + const notFoundResponseStage = transportedResponseStage ?? options.renderResponseStageLocally; + if (notFoundResponseStage) { + const response = await notFoundResponseStage(responseStageRequest(), { + kind: "app-not-found", + buildId: options.buildId, + cacheability: responseStageCacheability(resolvedUrl), + canonicalPathname, + cleanPathname, + draftModeCookie, + isRscRequest, + middlewareCookieOverlay, + mountedSlotsHeader, + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: url.origin, + renderMode, + resolvedUrl, + scriptNonce: scriptNonce ?? null, + }); + return composeResponseStageResponse(response); + } + const renderedNotFoundResponse = await options.renderNotFound({ isRscRequest, middlewareContext, @@ -1721,6 +2108,18 @@ async function handleAppRscRequest( const prerenderRouteParams = prerenderRouteParamsMatch?.params ?? null; const isPrerenderFallbackShell = prerenderRouteParamsMatch?.kind === "fallback-shell"; const renderParams = prerenderRouteParams ?? params; + const responseStageMatchKind: AppMatchedWorkerResponseStageProps["matchKind"] = + isInterceptionMatch + ? "interception" + : cleanPathnameIsRequestPathname && options.matchRequestRoute + ? "request" + : "resolved"; + const responseStageRoutePathname = isInterceptionMatch + ? preActionRoutePathname + : cleanPathnameIsRequestPathname + ? requestCleanPathname || "/" + : cleanPathname || "/"; + const matchedResponseStage = transportedResponseStage ?? options.renderResponseStageLocally; let runtimeFallbackShells: AppPagePprFallbackCacheShell[] = []; if ( options.createPprFallbackShells && @@ -1747,9 +2146,6 @@ async function handleAppRscRequest( setRootParams(rootParams); if (route.routeHandler) { - setCurrentFetchSoftTags( - buildPageCacheTags(cleanPathname, [], [...route.routeSegments], "route"), - ); // Next.js edge route handlers run through web/adapter.ts, which strips // internal search params from the request URL. Node route handlers only // strip `_rsc` from the parsed query object and rebuild request.url from @@ -1765,6 +2161,36 @@ async function handleAppRscRequest( for (const internalRscValue of internalRscValues) { routeHandlerUrl.searchParams.append(VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM, internalRscValue); } + if (matchedResponseStage) { + const response = await matchedResponseStage(responseStageRequest(), { + kind: "app-route-handler", + buildId: options.buildId, + cacheability: responseStageCacheability(resolvedUrl), + bypassInterceptionContextCache, + canonicalPathname, + cleanPathname, + draftModeCookie, + interceptionContext: interceptionContextHeader, + interceptionId: interceptionIdHeader, + isRscRequest, + matchKind: responseStageMatchKind, + middlewareCookieOverlay, + mountedSlotsHeader, + params, + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: url.origin, + renderMode, + resolvedUrl, + routePattern: route.pattern, + routePathname: responseStageRoutePathname, + scriptNonce: scriptNonce ?? null, + }); + return composeResponseStageResponse(response); + } + const { setCurrentFetchSoftTags } = await import("vinext/shims/fetch-cache"); + setCurrentFetchSoftTags( + buildPageCacheTags(cleanPathname, [], [...route.routeSegments], "route"), + ); return options.dispatchMatchedRouteHandler({ cleanPathname, middlewareContext, @@ -1779,42 +2205,66 @@ async function handleAppRscRequest( }); } - const pageResponse = await options.dispatchMatchedPage({ - bypassInterceptionContextCache, - clientReuseManifest, - cleanPathname, - displayPathname: canonicalPathname, - formState, - actionError: normalizedProgressiveActionError, - actionFailed, - handlerStart, - interceptionContext: interceptionContextHeader, - interceptionId: interceptionIdHeader, - interceptionPathname: cleanPathnameIsRequestPathname ? requestCleanPathname : cleanPathname, - isProgressiveActionRender, - isRscRequest, - middlewareContext, - mountedSlotsHeader, - params: renderParams, - pprFallbackCacheShells: runtimeFallbackShells, - pprFallbackShell: isPrerenderFallbackShell - ? { - fallbackParamNames: prerenderRouteParamsMatch.fallbackParamNames, - routePattern: route.pattern, - } - : undefined, - renderedConcreteUrlPaths: getRenderedConcreteUrlPathsForRoute(route.pattern), - skipStaticParamsValidation: isPrerenderFallbackShell, - staticParamsValidationParams: - prerenderRouteParams === null || isPrerenderFallbackShell ? undefined : params, - rootParams, - request, - renderedPathAndSearch: resolvedUrl, - route, - scriptNonce, - searchParams: resolvedSearchParams, - renderMode, - }); + const pageResponse = matchedResponseStage + ? await matchedResponseStage(responseStageRequest(), { + kind: "app-page", + buildId: options.buildId, + cacheability: responseStageCacheability(resolvedUrl), + bypassInterceptionContextCache, + canonicalPathname, + cleanPathname, + draftModeCookie, + interceptionContext: interceptionContextHeader, + interceptionId: interceptionIdHeader, + isRscRequest, + matchKind: responseStageMatchKind, + middlewareCookieOverlay, + mountedSlotsHeader, + params, + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: url.origin, + renderMode, + resolvedUrl, + routePattern: route.pattern, + routePathname: responseStageRoutePathname, + scriptNonce: scriptNonce ?? null, + }).then(composeResponseStageResponse) + : await options.dispatchMatchedPage({ + bypassInterceptionContextCache, + clientReuseManifest, + cleanPathname, + displayPathname: canonicalPathname, + formState, + actionError: normalizedProgressiveActionError, + actionFailed, + handlerStart, + interceptionContext: interceptionContextHeader, + interceptionId: interceptionIdHeader, + interceptionPathname: cleanPathnameIsRequestPathname ? requestCleanPathname : cleanPathname, + isProgressiveActionRender, + isRscRequest, + middlewareContext, + mountedSlotsHeader, + params: renderParams, + pprFallbackCacheShells: runtimeFallbackShells, + pprFallbackShell: isPrerenderFallbackShell + ? { + fallbackParamNames: prerenderRouteParamsMatch.fallbackParamNames, + routePattern: route.pattern, + } + : undefined, + renderedConcreteUrlPaths: getRenderedConcreteUrlPathsForRoute(route.pattern), + skipStaticParamsValidation: isPrerenderFallbackShell, + staticParamsValidationParams: + prerenderRouteParams === null || isPrerenderFallbackShell ? undefined : params, + rootParams, + request, + renderedPathAndSearch: resolvedUrl, + route, + scriptNonce, + searchParams: resolvedSearchParams, + renderMode, + }); // No-JS progressive form actions write cookies via cookies().set() / draftMode() // *during action execution*, before the page rerender begins. Those writes only @@ -1877,10 +2327,27 @@ function applyProgressiveActionSideEffects( } } -export function createAppRscHandler( +export type AppRscRequestHandler = ( + request: Request, + ctx: unknown, + allowInternalRscDocumentFallback?: boolean, + dispatchResponseStage?: DispatchAppWorkerResponseStage, + responseStageProbeMode?: VinextCacheabilityProbeMode | null, + trustedPrerenderState?: TrustedPrerenderState | null, +) => Promise; + +/** Build the request-only handler without retaining the response renderer graph. */ +export function createAppRscRequestHandler( options: CreateAppRscHandlerOptions, -): (request: Request, ctx: unknown) => Promise { - return async function appRscHandler(rawRequest, ctx, allowInternalRscDocumentFallback = false) { +): AppRscRequestHandler { + const appRscHandler = async function appRscHandler( + rawRequest: Request, + ctx: unknown, + allowInternalRscDocumentFallback = false, + dispatchResponseStage?: DispatchAppWorkerResponseStage, + responseStageProbeMode: VinextCacheabilityProbeMode | null = null, + transportedPrerenderState?: TrustedPrerenderState | null, + ): Promise { // Register config-driven cache adapters before anything touches the cache. // On the Cloudflare worker the entry already registered them with `env` (this // guarded call is a no-op); on Node/dev this is where they get wired, with no @@ -1934,11 +2401,21 @@ export function createAppRscHandler( // internal-header list) lets readTrustedPrerenderRouteParams's // VINEXT_PRERENDER gate pass on the reconstructed request. If the secret // header is ever added to VINEXT_INTERNAL_HEADERS, that second read breaks. - const prerenderRouteParamsPayload = readTrustedPrerenderRouteParams(rawRequest); - const isTrustedSpeculativePrerender = - process.env.VINEXT_PRERENDER === "1" && - rawRequest.headers.get(VINEXT_PRERENDER_SECRET_HEADER) !== null && - rawRequest.headers.get(VINEXT_PRERENDER_SPECULATIVE_HEADER) === "1"; + // A remote response stage receives only the authenticated, serialized + // state. Single-stage Node requests retain the existing verified-header + // boundary, then recursive renders carry the resolved state explicitly. + const trustedPrerenderState: TrustedPrerenderState | null = + transportedPrerenderState !== undefined + ? transportedPrerenderState + : process.env.VINEXT_PRERENDER === "1" && + rawRequest.headers.get(VINEXT_PRERENDER_SECRET_HEADER) !== null + ? { + routeParams: readTrustedPrerenderRouteParams(rawRequest), + speculative: rawRequest.headers.get(VINEXT_PRERENDER_SPECULATIVE_HEADER) === "1", + } + : null; + const prerenderRouteParamsPayload = trustedPrerenderState?.routeParams ?? null; + const isTrustedSpeculativePrerender = trustedPrerenderState?.speculative === true; const filteredHeaders = executionContext?.isInternalPagesRevalidation ? new Headers(rawRequest.headers) : filterInternalHeaders(rawRequest.headers); @@ -1982,7 +2459,13 @@ export function createAppRscHandler( const responsePromise = runWithRequestContext(requestContext, () => runWithPrerenderWorkUnit( async () => { - ensureFetchPatch(); + // A separately deployed response stage owns all render/data-cache + // execution. Keep its fetch runtime out of the request-stage startup + // graph; single-stage handlers still install it before user code runs. + if (!dispatchResponseStage) { + const { ensureFetchPatch } = await import("vinext/shims/fetch-cache"); + ensureFetchPatch(); + } const preMiddlewareRequestContext = requestContextFromRequest(request); const middlewareContext: AppRscMiddlewareContext = { headers: null, @@ -2000,8 +2483,18 @@ export function createAppRscHandler( isPagesDataRequest, isPagesDataRequest, pagesDataRequest, - (internalRequest) => appRscHandler(internalRequest, ctx, true), + (internalRequest) => + appRscHandler( + internalRequest, + ctx, + true, + dispatchResponseStage, + responseStageProbeMode, + trustedPrerenderState, + ), allowInternalRscDocumentFallback, + dispatchResponseStage, + responseStageProbeMode, (uncacheable) => { interceptionResponseUncacheable = uncacheable; }, @@ -2018,6 +2511,7 @@ export function createAppRscHandler( configHeaders: options.configHeaders, i18nConfig: options.i18nConfig, middlewareHeaders: middlewareContext.headers, + recordCacheability: dispatchResponseStage === undefined, requestContext: preMiddlewareRequestContext, }); return interceptionResponseUncacheable @@ -2039,4 +2533,6 @@ export function createAppRscHandler( } return closeAfterResponseWithBody(response, requestContext); }; + + return appRscHandler; } diff --git a/packages/vinext/src/server/app-rsc-response-finalizer.ts b/packages/vinext/src/server/app-rsc-response-finalizer.ts index dbff82651..dfdbf99f4 100644 --- a/packages/vinext/src/server/app-rsc-response-finalizer.ts +++ b/packages/vinext/src/server/app-rsc-response-finalizer.ts @@ -3,6 +3,7 @@ import type { RequestContext } from "../config/request-context.js"; import { isStaticFileSignal } from "./static-file-signal.js"; import { applyCdnResponseHeaders, + getCdnResponsePolicyHeaderNames, hasExplicitNonCacheableResponsePolicy, isNonCacheableCacheControl, NO_STORE_CACHE_CONTROL, @@ -13,10 +14,7 @@ import { hasBasePath, stripBasePath } from "../utils/base-path.js"; import { normalizeDefaultLocalePathname } from "./pages-i18n.js"; import { sanitizeMethodNotAllowedHeaders } from "./http-error-responses.js"; import { hasPostConfigLinkHeaders } from "./app-response-header-provenance.js"; -import { - CACHEABILITY_POLICY_HEADERS, - captureRouteCacheabilityResponsePolicy, -} from "vinext/shims/cacheability-classification"; +import { captureRouteCacheabilityResponsePolicy } from "vinext/shims/cacheability-classification"; type FinalizeAppRscResponseOptions = { basePath: string; @@ -35,13 +33,16 @@ type FinalizeAppRscResponseOptions = { * before middleware runs. */ requestContext: RequestContext; + /** Existing response headers that matching next.config rules may replace. */ + overwriteExisting?: ReadonlySet; /** Response headers emitted by middleware after config matching. */ middlewareHeaders?: Headers | null; + /** Whether config matching should update the active cacheability classification. */ + recordCacheability?: boolean; }; const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false"; const configHeadersAlreadyApplied = new WeakSet(); -const CONFIG_CACHE_POLICY_HEADERS = new Set(CACHEABILITY_POLICY_HEADERS); function normalizeExplicitNonCacheablePolicy(headers: Headers): void { if (!hasExplicitNonCacheableResponsePolicy(headers)) return; @@ -84,11 +85,12 @@ export async function applyAppRscConfigHeaders( basePathState: { basePath: options.basePath, hadBasePath }, appendToPostConfigLink: hasPostConfigLinkHeaders(headers), middlewareHeaders: options.middlewareHeaders, + recordCacheability: options.recordCacheability, // Next.js next.config headers override its renderer-owned Cache-Control, // including for force-dynamic App Pages. Other response headers retain // the existing merge precedence. // test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts - overwriteExisting: CONFIG_CACHE_POLICY_HEADERS, + overwriteExisting: options.overwriteExisting ?? getCdnResponsePolicyHeaderNames(), }); } diff --git a/packages/vinext/src/server/app-rsc-response-stage.ts b/packages/vinext/src/server/app-rsc-response-stage.ts new file mode 100644 index 000000000..2533cdf96 --- /dev/null +++ b/packages/vinext/src/server/app-rsc-response-stage.ts @@ -0,0 +1,416 @@ +import { + applyEffectiveRequestCookieHeader, + headersContextFromRequest, + restoreDraftModeTransition, +} from "vinext/shims/headers"; +import { ensureFetchPatch, setCurrentFetchSoftTags } from "vinext/shims/fetch-cache"; +import { + getRequestExecutionContext, + type ExecutionContextLike, +} from "vinext/shims/request-context"; +import { pickRootParams, setRootParams } from "vinext/shims/root-params"; +import { + closeAfterResponse, + closeAfterResponseWithBody, + createRequestContext, + runWithRequestContext, +} from "vinext/shims/unified-request-context"; +import type { AppRscHandlerRoute, CreateAppRscHandlerOptions } from "./app-rsc-handler.js"; +import { + APP_METADATA_RESPONSE_STAGE_NO_MATCH_HEADER, + APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + type AppMatchedWorkerResponseStageProps, + type AppWorkerResponseStageProps, +} from "./app-worker-stages.js"; +import type { VinextResponseStageDispatchOptions } from "./multi-stage.js"; +import type { AppMiddlewareContext } from "./app-middleware.js"; +import type { AppPagePprFallbackCacheShell } from "./app-ppr-fallback-shell.js"; +import { normalizeRscRequest } from "./app-rsc-request-normalization.js"; +import { + hasRscCacheBustingSearchParam, + stripRscCacheBustingSearchParam, + stripRscSuffix, + VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM, +} from "./app-rsc-cache-busting.js"; +import { VINEXT_REVALIDATE_HOST_HEADER } from "./headers.js"; +import { buildPageCacheTags } from "./implicit-tags.js"; +import { getRenderedConcreteUrlPathsForRoute } from "./pregenerated-concrete-paths.js"; +import { runWithPrerenderWorkUnit } from "./prerender-work-unit-setup.js"; +import { + cloneRequestWithHeaders, + cloneRequestWithUrl, + filterInternalHeaders, +} from "./request-pipeline.js"; + +type AppPageParams = Record; + +function haveSamePageParams(first: AppPageParams, second: AppPageParams): boolean { + const firstKeys = Object.keys(first); + const secondKeys = Object.keys(second); + if (firstKeys.length !== secondKeys.length) return false; + for (const key of firstKeys) { + const firstValue = first[key]; + const secondValue = second[key]; + if (Array.isArray(firstValue)) { + if ( + !Array.isArray(secondValue) || + firstValue.length !== secondValue.length || + firstValue.some((value, index) => value !== secondValue[index]) + ) { + return false; + } + } else if (firstValue !== secondValue) { + return false; + } + } + return true; +} + +function hasProperty( + value: object, + key: TKey, +): value is object & Record { + return key in value; +} + +function isEdgeRouteHandler(handler: unknown): boolean { + if (!handler || typeof handler !== "object" || !hasProperty(handler, "runtime")) return false; + return handler.runtime === "edge" || handler.runtime === "experimental-edge"; +} + +function isExecutionContextLike(value: unknown): value is ExecutionContextLike { + return ( + !!value && + typeof value === "object" && + hasProperty(value, "waitUntil") && + typeof value.waitUntil === "function" + ); +} + +function requestWithoutRscCacheBustingSearchParam(request: Request): Request { + const url = new URL(request.url); + if (!hasRscCacheBustingSearchParam(url)) return request; + stripRscCacheBustingSearchParam(url); + return cloneRequestWithUrl(request, url.toString()); +} + +function requestWithoutRscSuffix(request: Request): Request { + const url = new URL(request.url); + const pathname = stripRscSuffix(url.pathname); + if (pathname === url.pathname) return request; + url.pathname = pathname; + return cloneRequestWithUrl(request, url.toString()); +} + +function pathnameForResolvedUrl(resolvedUrl: string): string { + return resolvedUrl.split("#", 1)[0].split("?", 1)[0]; +} + +function rematchAppWorkerResponseStageRoute( + options: CreateAppRscHandlerOptions, + normalized: Exclude, Response>, + props: AppMatchedWorkerResponseStageProps, +): { params: AppPageParams; route: TRoute } | null { + let match: { params: AppPageParams; route: TRoute } | null = null; + if (props.matchKind === "interception") { + if (normalized.interceptionContextHeader !== null) { + match = + options.matchInterceptRoute?.( + props.routePathname, + normalized.interceptionContextHeader, + props.interceptionId, + ) ?? null; + } + } else if (props.matchKind === "request") { + match = options.matchRequestRoute?.(props.routePathname) ?? null; + } else { + match = options.matchRoute(props.routePathname); + } + if (!match) return null; + if ( + match.route.pattern !== props.routePattern || + !haveSamePageParams(match.params, props.params) + ) { + return null; + } + return match; +} + +export async function renderAppWorkerResponseStage( + options: CreateAppRscHandlerOptions, + rawRequest: Request, + ctx: unknown, + props: AppWorkerResponseStageProps, + _stageOptions?: VinextResponseStageDispatchOptions, +): Promise { + if ( + props.protocolVersion !== APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION || + props.buildId !== options.buildId + ) { + return new Response("Incompatible vinext App response stage", { status: 409 }); + } + if (props.kind === "app-full-request") { + return new Response("Invalid vinext App response stage", { status: 400 }); + } + options.registerCacheAdapters(); + await options.ensureInstrumentation?.(); + + const executionContext = isExecutionContextLike(ctx) + ? ctx + : (getRequestExecutionContext() ?? null); + const filteredHeaders = executionContext?.isInternalPagesRevalidation + ? new Headers(rawRequest.headers) + : filterInternalHeaders(rawRequest.headers); + filteredHeaders.delete(VINEXT_REVALIDATE_HOST_HEADER); + const request = cloneRequestWithHeaders(rawRequest, filteredHeaders); + if (props.kind === "hybrid-pages") { + try { + if (new URL(props.requestUrl).origin !== new URL(request.url).origin) { + return new Response("Invalid vinext App response stage", { status: 400 }); + } + } catch { + return new Response("Invalid vinext App response stage", { status: 400 }); + } + } + const liveNormalized = normalizeRscRequest(request, options.basePath, true); + if (liveNormalized instanceof Response) { + return new Response("Invalid vinext App response stage", { status: 400 }); + } + // Classification belongs to the pre-middleware request stage. Middleware + // may legally override these headers for userland; keep those live headers + // on `request`, but never let them mutate the trusted stage operation. + const normalized = + props.kind === "hybrid-pages" + ? liveNormalized + : { + ...liveNormalized, + interceptionContextHeader: + props.kind === "app-page" || props.kind === "app-route-handler" + ? props.interceptionContext + : liveNormalized.interceptionContextHeader, + interceptionIdHeader: + props.kind === "app-page" || props.kind === "app-route-handler" + ? props.interceptionId + : liveNormalized.interceptionIdHeader, + isRscRequest: props.isRscRequest, + mountedSlotsHeader: props.mountedSlotsHeader, + renderMode: props.renderMode, + }; + const match = + props.kind === "hybrid-pages" || props.kind === "app-metadata" || props.kind === "app-not-found" + ? null + : rematchAppWorkerResponseStageRoute(options, normalized, props); + if ( + props.kind !== "hybrid-pages" && + props.kind !== "app-metadata" && + props.kind !== "app-not-found" && + !match + ) { + return new Response("Invalid vinext App response stage", { status: 400 }); + } + + const route = match?.route ?? null; + if (route) { + if (options.ensureRouteLoaded) await options.ensureRouteLoaded(route); + if ( + (props.kind === "app-page" && route.routeHandler) || + (props.kind === "app-route-handler" && !route.routeHandler) + ) { + return new Response("Invalid vinext App response stage", { status: 400 }); + } + } + + const headersContext = headersContextFromRequest(request, { + draftModeSecret: options.draftModeSecret, + }); + const requestContext = createRequestContext({ + headersContext, + executionContext, + unstableCacheRevalidation: "background", + }); + const middlewareContext: AppMiddlewareContext = { + headers: null, + requestHeaders: null, + status: null, + }; + + const responsePromise = runWithRequestContext(requestContext, () => + runWithPrerenderWorkUnit( + async () => { + if (props.middlewareCookieOverlay !== null) { + applyEffectiveRequestCookieHeader(props.middlewareCookieOverlay); + } + if (props.draftModeCookie !== null) { + restoreDraftModeTransition(props.draftModeCookie); + } + ensureFetchPatch(); + if (props.kind === "app-metadata") { + if (!options.handleMetadataRouteRequest) { + return new Response("Invalid vinext App response stage", { status: 400 }); + } + const response = await options.handleMetadataRouteRequest(props.cleanPathname); + return ( + response ?? + new Response(null, { + status: 204, + headers: { [APP_METADATA_RESPONSE_STAGE_NO_MATCH_HEADER]: "1" }, + }) + ); + } + if (props.kind === "app-not-found") { + options.setNavigationContext({ + pathname: props.canonicalPathname, + searchParams: new URL(props.resolvedUrl, request.url).searchParams, + params: {}, + }); + setRootParams({}); + const response = await options.renderNotFound({ + isRscRequest: normalized.isRscRequest, + middlewareContext, + request, + route: null, + scriptNonce: props.scriptNonce ?? undefined, + }); + return response ?? new Response("Not Found", { status: 404 }); + } + if (props.kind === "hybrid-pages") { + if (!options.renderPagesFallback) { + return new Response("Invalid vinext App response stage", { status: 400 }); + } + const resolvedPathname = pathnameForResolvedUrl(props.resolvedUrl); + const resolvedResourceKind = + resolvedPathname === "/api" || resolvedPathname.startsWith("/api/") ? "api" : "page"; + if (resolvedResourceKind !== props.resourceKind) { + return new Response("Invalid vinext App response stage", { status: 400 }); + } + const pageRequest = props.isDataRequest + ? cloneRequestWithUrl(request, new URL(props.resolvedUrl, request.url).toString()) + : request; + const pagesResponse = await options.renderPagesFallback({ + allowRscDocumentFallback: props.allowRscDocumentFallback, + appRouteMatch: props.appRouteMatch + ? { + route: { + isDynamic: props.appRouteMatch.isDynamic, + pattern: props.appRouteMatch.pattern, + }, + } + : null, + isDataRequest: props.isDataRequest, + isRscRequest: props.isRscRequest, + matchKind: props.matchKind, + middlewareContext, + initialResponseHeaders: + props.preHandlerHeaders === null ? undefined : new Headers(props.preHandlerHeaders), + pathname: props.resolvedUrl, + pagesDataRequest: props.isDataRequest ? request : null, + request: pageRequest, + url: new URL(props.requestUrl), + }); + if (!pagesResponse) { + return new Response("Invalid vinext App response stage", { status: 404 }); + } + return pagesResponse; + } + + if (!route) { + return new Response("Invalid vinext App response stage", { status: 400 }); + } + const searchParams = new URL(props.resolvedUrl, request.url).searchParams; + const renderParams = props.params; + options.setNavigationContext({ + pathname: props.canonicalPathname, + searchParams, + params: renderParams, + }); + const rootParams = pickRootParams(renderParams, route.rootParamNames); + setRootParams(rootParams); + + if (props.kind === "app-route-handler") { + setCurrentFetchSoftTags( + buildPageCacheTags(props.cleanPathname, [], [...route.routeSegments], "route"), + ); + const normalizedUserlandRequest = requestWithoutRscSuffix(request); + const userlandRequest = + requestWithoutRscCacheBustingSearchParam(normalizedUserlandRequest); + const routeHandlerRequest = isEdgeRouteHandler(route.routeHandler) + ? userlandRequest + : normalizedUserlandRequest; + const routeHandlerUrl = new URL(routeHandlerRequest.url); + const internalRscValues = isEdgeRouteHandler(route.routeHandler) + ? [] + : routeHandlerUrl.searchParams.getAll(VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM); + routeHandlerUrl.search = searchParams.toString(); + for (const internalRscValue of internalRscValues) { + routeHandlerUrl.searchParams.append( + VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM, + internalRscValue, + ); + } + return options.dispatchMatchedRouteHandler({ + cleanPathname: props.cleanPathname, + middlewareContext, + params: route.isDynamic ? renderParams : null, + request: cloneRequestWithUrl(routeHandlerRequest, routeHandlerUrl.toString()), + route, + searchParams, + }); + } + + let pprFallbackCacheShells: AppPagePprFallbackCacheShell[] = []; + if ( + options.createPprFallbackShells && + request.method === "GET" && + !normalized.isRscRequest && + route.params + ) { + pprFallbackCacheShells = options.createPprFallbackShells( + { + params: route.params, + pattern: route.pattern, + rootParamNames: route.rootParamNames, + }, + renderParams, + ); + } + + return options.dispatchMatchedPage({ + bypassInterceptionContextCache: props.bypassInterceptionContextCache, + clientReuseManifest: normalized.clientReuseManifest, + cleanPathname: props.cleanPathname, + displayPathname: props.canonicalPathname, + formState: null, + handlerStart: process.env.NODE_ENV !== "production" ? performance.now() : 0, + interceptionContext: normalized.interceptionContextHeader, + interceptionId: normalized.interceptionIdHeader, + interceptionPathname: + props.matchKind === "resolved" ? props.cleanPathname : normalized.requestCleanPathname, + isProgressiveActionRender: false, + isRscRequest: normalized.isRscRequest, + middlewareContext, + mountedSlotsHeader: normalized.mountedSlotsHeader, + params: renderParams, + pprFallbackCacheShells, + renderedConcreteUrlPaths: getRenderedConcreteUrlPathsForRoute(route.pattern), + rootParams, + request, + renderedPathAndSearch: props.resolvedUrl, + route, + searchParams, + scriptNonce: props.scriptNonce ?? undefined, + renderMode: normalized.renderMode, + }); + }, + { route: () => props.canonicalPathname }, + ), + ); + + let response: Response; + try { + response = await responsePromise; + } catch (error) { + await closeAfterResponse(requestContext); + throw error; + } + return closeAfterResponseWithBody(response, requestContext); +} diff --git a/packages/vinext/src/server/app-worker-stages.ts b/packages/vinext/src/server/app-worker-stages.ts new file mode 100644 index 000000000..906fdbac2 --- /dev/null +++ b/packages/vinext/src/server/app-worker-stages.ts @@ -0,0 +1,256 @@ +import type { AppRscRenderMode } from "./app-rsc-render-mode.js"; +import type { + VinextResponseStageCacheability, + VinextResponseStageTransport, +} from "./multi-stage.js"; +import { isTrustedPrerenderState, type TrustedPrerenderState } from "./prerender-route-params.js"; + +export const APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION = 7; +export const APP_METADATA_RESPONSE_STAGE_NO_MATCH_HEADER = "x-vinext-app-metadata-stage-no-match"; +const STATIC_FILE_SIGNAL_TOKEN_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +type AppPageParams = Record; + +type AppWorkerResponseStageEnvelope = { + buildId: string | null; + cacheability: VinextResponseStageCacheability; + draftModeCookie: string | null; + middlewareCookieOverlay: string | null; + protocolVersion: typeof APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION; + /** Canonical public request origin used to partition and validate shared renders. */ + requestOrigin: string; + scriptNonce: string | null; +}; + +type AppFullRequestWorkerResponseStageProps = AppWorkerResponseStageEnvelope & { + kind: "app-full-request"; + prerenderDiscovery: boolean; + staticFileSignalToken: string; + trustedPrerenderState: TrustedPrerenderState | null; +}; + +export type AppMatchedWorkerResponseStageProps = AppWorkerResponseStageEnvelope & { + kind: "app-page" | "app-route-handler"; + bypassInterceptionContextCache: boolean; + canonicalPathname: string; + cleanPathname: string; + interceptionContext: string | null; + interceptionId: string | null; + isRscRequest: boolean; + matchKind: "interception" | "request" | "resolved"; + mountedSlotsHeader: string | null; + params: AppPageParams; + resolvedUrl: string; + routePattern: string; + routePathname: string; + renderMode: AppRscRenderMode; +}; + +type AppNotFoundWorkerResponseStageProps = AppWorkerResponseStageEnvelope & { + kind: "app-not-found"; + canonicalPathname: string; + cleanPathname: string; + isRscRequest: boolean; + mountedSlotsHeader: string | null; + renderMode: AppRscRenderMode; + resolvedUrl: string; +}; + +type AppMetadataWorkerResponseStageProps = AppWorkerResponseStageEnvelope & { + kind: "app-metadata"; + canonicalPathname: string; + cleanPathname: string; + isRscRequest: boolean; + mountedSlotsHeader: string | null; + renderMode: AppRscRenderMode; + resolvedUrl: string; +}; + +type HybridPagesWorkerResponseStageProps = AppWorkerResponseStageEnvelope & { + kind: "hybrid-pages"; + allowRscDocumentFallback: boolean; + appRouteMatch: { + isDynamic: boolean; + pattern: string; + } | null; + canonicalPathname: string; + cleanPathname: string; + isDataRequest: boolean; + isRscRequest: boolean; + matchKind: "dynamic" | "static"; + /** Complete response-header snapshot installed before request-time Pages user code runs. */ + preHandlerHeaders: Array<[string, string]> | null; + resourceKind: "api" | "page"; + requestUrl: string; + resolvedUrl: string; +}; + +export type AppWorkerResponseStageProps = + | AppFullRequestWorkerResponseStageProps + | AppMatchedWorkerResponseStageProps + | AppMetadataWorkerResponseStageProps + | AppNotFoundWorkerResponseStageProps + | HybridPagesWorkerResponseStageProps; + +export type DispatchAppWorkerResponseStage = + VinextResponseStageTransport; + +export type RenderAppWorkerResponseStageLocally = ( + request: Request, + props: AppWorkerResponseStageProps, +) => Promise; + +function isAppPageParams(value: unknown): value is AppPageParams { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + for (const param of Object.values(value)) { + if (typeof param === "string") continue; + if (!Array.isArray(param) || param.some((item) => typeof item !== "string")) return false; + } + return true; +} + +function isCanonicalHttpOrigin(value: unknown): value is string { + if (typeof value !== "string") return false; + try { + const url = new URL(value); + return (url.protocol === "http:" || url.protocol === "https:") && url.origin === value; + } catch { + return false; + } +} + +export function isAppWorkerResponseStageProps( + value: unknown, +): value is AppWorkerResponseStageProps { + if (!value || typeof value !== "object") return false; + const props = value as Partial; + if ( + props.protocolVersion !== APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION || + (props.buildId !== null && typeof props.buildId !== "string") || + !isResponseStageCacheability(props.cacheability) || + (props.draftModeCookie !== null && typeof props.draftModeCookie !== "string") || + (props.middlewareCookieOverlay !== null && typeof props.middlewareCookieOverlay !== "string") || + !isCanonicalHttpOrigin(props.requestOrigin) || + (props.scriptNonce !== null && typeof props.scriptNonce !== "string") + ) { + return false; + } + if (props.kind === "app-full-request") { + return ( + typeof props.prerenderDiscovery === "boolean" && + typeof props.staticFileSignalToken === "string" && + STATIC_FILE_SIGNAL_TOKEN_RE.test(props.staticFileSignalToken) && + (props.trustedPrerenderState === null || isTrustedPrerenderState(props.trustedPrerenderState)) + ); + } + if (props.kind === "hybrid-pages") { + const hybrid = props as Partial; + const appRouteMatch = hybrid.appRouteMatch; + return ( + typeof hybrid.allowRscDocumentFallback === "boolean" && + (appRouteMatch === null || + (typeof appRouteMatch === "object" && + typeof appRouteMatch.isDynamic === "boolean" && + typeof appRouteMatch.pattern === "string" && + appRouteMatch.pattern.startsWith("/"))) && + typeof hybrid.canonicalPathname === "string" && + hybrid.canonicalPathname.startsWith("/") && + typeof hybrid.cleanPathname === "string" && + hybrid.cleanPathname.startsWith("/") && + typeof hybrid.isDataRequest === "boolean" && + typeof hybrid.isRscRequest === "boolean" && + (hybrid.matchKind === "dynamic" || hybrid.matchKind === "static") && + (hybrid.preHandlerHeaders === null || isSerializedHeaders(hybrid.preHandlerHeaders)) && + (hybrid.resourceKind === "api" || hybrid.resourceKind === "page") && + typeof hybrid.requestUrl === "string" && + typeof hybrid.resolvedUrl === "string" && + hybrid.resolvedUrl.startsWith("/") + ); + } + if (props.kind === "app-metadata" || props.kind === "app-not-found") { + const special = props as Partial< + AppMetadataWorkerResponseStageProps | AppNotFoundWorkerResponseStageProps + >; + return ( + typeof special.canonicalPathname === "string" && + special.canonicalPathname.startsWith("/") && + typeof special.cleanPathname === "string" && + special.cleanPathname.startsWith("/") && + typeof special.isRscRequest === "boolean" && + (special.mountedSlotsHeader === null || typeof special.mountedSlotsHeader === "string") && + typeof special.resolvedUrl === "string" && + special.resolvedUrl.startsWith("/") && + (special.renderMode === "navigation" || + special.renderMode === "prefetch-empty" || + special.renderMode === "prefetch-dynamic-shell" || + special.renderMode === "prefetch-loading-shell") + ); + } + return ( + (props.kind === "app-page" || props.kind === "app-route-handler") && + typeof props.bypassInterceptionContextCache === "boolean" && + typeof props.canonicalPathname === "string" && + props.canonicalPathname.startsWith("/") && + typeof props.cleanPathname === "string" && + props.cleanPathname.startsWith("/") && + (props.interceptionContext === null || typeof props.interceptionContext === "string") && + (props.interceptionId === null || typeof props.interceptionId === "string") && + typeof props.isRscRequest === "boolean" && + (props.matchKind === "interception" || + props.matchKind === "request" || + props.matchKind === "resolved") && + (props.mountedSlotsHeader === null || typeof props.mountedSlotsHeader === "string") && + isAppPageParams(props.params) && + typeof props.resolvedUrl === "string" && + props.resolvedUrl.startsWith("/") && + typeof props.routePattern === "string" && + props.routePattern.startsWith("/") && + typeof props.routePathname === "string" && + props.routePathname.startsWith("/") && + (props.renderMode === "navigation" || + props.renderMode === "prefetch-empty" || + props.renderMode === "prefetch-dynamic-shell" || + props.renderMode === "prefetch-loading-shell") + ); +} + +function isSerializedHeaders(value: unknown): value is Array<[string, string]> { + return ( + Array.isArray(value) && + value.every( + (entry) => + Array.isArray(entry) && + entry.length === 2 && + typeof entry[0] === "string" && + typeof entry[1] === "string", + ) + ); +} + +function isResponseStageCacheability(value: unknown): value is VinextResponseStageCacheability { + if (!value || typeof value !== "object") return false; + const cacheability = value as Partial; + return ( + (cacheability.probeMode === null || + cacheability.probeMode === "probe" || + cacheability.probeMode === "identity") && + (cacheability.policyHeaders === null || + (Array.isArray(cacheability.policyHeaders) && + cacheability.policyHeaders.every( + (entry) => + Array.isArray(entry) && + entry.length === 2 && + typeof entry[0] === "string" && + typeof entry[1] === "string", + ))) && + (cacheability.representation === undefined || + cacheability.representation === "app-route" || + cacheability.representation === "html" || + cacheability.representation === "pages-data" || + cacheability.representation === "rsc-full" || + cacheability.representation === "rsc-loading-shell") && + typeof cacheability.resolvedRoutePathname === "string" && + cacheability.resolvedRoutePathname.startsWith("/") + ); +} diff --git a/packages/vinext/src/server/cache-control.ts b/packages/vinext/src/server/cache-control.ts index dac517fa9..33d05a8db 100644 --- a/packages/vinext/src/server/cache-control.ts +++ b/packages/vinext/src/server/cache-control.ts @@ -39,6 +39,33 @@ export function hasExplicitNonCacheableResponsePolicy(headers: Headers): boolean return Boolean(cacheControl && isNonCacheableCacheControl(cacheControl)); } +/** Lowercase response-policy names owned by core and the active adapter. */ +export function getCdnResponsePolicyHeaderNames(): ReadonlySet { + return new Set([ + "cache-control", + ...(getCdnCacheAdapter().responsePolicyHeaderNames ?? []).map((name) => name.toLowerCase()), + ]); +} + +/** Capture only cache-policy provenance from an outer composition stage. */ +function captureCdnResponsePolicyHeaders(headers: Headers): Headers { + const policy = new Headers(); + for (const name of getCdnResponsePolicyHeaderNames()) { + const value = headers.get(name); + if (value !== null) policy.set(name, value); + } + return policy; +} + +/** Capture policy values that were added above an already-transported baseline. */ +export function captureCdnResponsePolicyOverrides(headers: Headers, baseline: Headers): Headers { + const overrides = captureCdnResponsePolicyHeaders(headers); + for (const [name, value] of overrides) { + if (baseline.get(name) === value) overrides.delete(name); + } + return overrides; +} + /** Delegate provider-specific request routing validation to the CDN adapter. */ export async function validateCdnRequest(request: Request): Promise { return (await getCdnCacheAdapter().validateRequest?.(request)) ?? null; @@ -80,6 +107,50 @@ export function applyCdnResponseHeaders(headers: Headers, input: CdnCacheableHea } } +/** + * Reconcile request-stage policy composed above a reusable response artifact. + * A newly applied private policy must clear any cacheable provider headers that + * belonged to the inner artifact before the final response leaves the gateway. + * `outerPolicyHeaders` contains only policy set by the uncached request stage, + * so an identical inner value cannot hide explicit outer provenance. + */ +export function reconcileCdnResponseHeadersAfterOuterPolicy( + headers: Headers, + outerPolicyHeaders: Headers, +): void { + // Set-Cookie is additive and therefore is not part of the policy-only + // provenance snapshot. It can still be introduced by the uncached request + // stage after a shared artifact returns, and the completed response must not + // retain that artifact's shared-cache policy. + if (headers.has("set-cookie")) { + applyCdnResponseHeaders(headers, { cacheControl: NO_STORE_CACHE_CONTROL }); + return; + } + const cacheControl = outerPolicyHeaders.get("cache-control"); + if (cacheControl !== null) { + applyCdnResponseHeaders(headers, { cacheControl }); + // Preserve any explicit provider-specific policy authored alongside the + // generic middleware policy after the adapter has derived its defaults. + for (const name of getCdnResponsePolicyHeaderNames()) { + if (name === "cache-control") continue; + const value = outerPolicyHeaders.get(name); + if (value !== null) headers.set(name, value); + } + return; + } + for (const name of getCdnResponsePolicyHeaderNames()) { + const value = outerPolicyHeaders.get(name); + if (value !== null && isNonCacheableCacheControl(value)) { + headers.set(name, value); + applyCdnResponseHeaders(headers, { cacheControl: value }); + return; + } + } + if (hasExplicitNonCacheableResponsePolicy(outerPolicyHeaders)) { + applyCdnResponseHeaders(headers, { cacheControl: NO_STORE_CACHE_CONTROL }); + } +} + /** Apply adapter-owned build identity to an HTML or RSC page response. */ export function applyCdnResponseIdentityHeaders(response: Response, request: Request): Response { const accept = request.headers.get("Accept")?.toLowerCase() ?? ""; diff --git a/packages/vinext/src/server/cacheability-manifest.ts b/packages/vinext/src/server/cacheability-manifest.ts index 8633d2fcb..039775e82 100644 --- a/packages/vinext/src/server/cacheability-manifest.ts +++ b/packages/vinext/src/server/cacheability-manifest.ts @@ -237,7 +237,10 @@ const CONTEXTUAL_RSC_HEADERS = [ VINEXT_RSC_STATE_FINGERPRINT_HEADER, ] as const; -export function cacheabilityRequestIdentity(request: Request): { +export function cacheabilityRequestIdentity( + request: Request, + trustedRepresentation?: CacheabilityRepresentation, +): { representation: CacheabilityRepresentation; requestKey: string; } | null { @@ -247,6 +250,7 @@ export function cacheabilityRequestIdentity(request: Request): { const url = new URL(request.url); const requestKey = `${url.pathname}${url.search}`; + if (trustedRepresentation) return { representation: trustedRepresentation, requestKey }; if (/(?:^|\/)_next\/data\/[^/]+\/.+\.json$/.test(url.pathname)) { return { representation: "pages-data", requestKey }; } diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index cab52259e..4aec586e0 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -8,12 +8,14 @@ import { import { applyCdnResponseBuildIdentityHeaders, applyCdnResponseHeaders, + getCdnResponsePolicyHeaderNames, hasExplicitNonCacheableResponsePolicy, isNonCacheableCacheControl, NO_STORE_CACHE_CONTROL, } from "./cache-control.js"; import { VINEXT_CACHEABILITY_PROBE_HEADER, + VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER, VINEXT_PRERENDER_SECRET_HEADER, VINEXT_RSC_VARY_HEADER, } from "./headers.js"; @@ -31,8 +33,10 @@ import { parseCacheabilityManifest, type CacheabilityManifest, type CacheabilityManifestRoute, + type CacheabilityRouteKind, type CacheabilityRepresentation, } from "./cacheability-manifest.js"; +import { applyResponseStagePolicyHeaders } from "./response-stage-policy.js"; type CacheabilityProbeRouteState = | "dynamic" @@ -42,14 +46,18 @@ type CacheabilityProbeRouteState = type CacheabilityProbeResult = { cacheControl?: string; - kind?: "app-page" | "app-route" | "pages-page"; + kind?: "app-page" | "app-route" | "pages-api" | "pages-page"; pattern?: string; reason?: string; /** The renderer itself completed with a reusable static policy. */ rendererStatic?: boolean; + /** Concrete pathname resolved by request-stage routing before rendering. */ + routePathname?: string; scope?: "identity" | "pattern"; state: CacheabilityProbeRouteState; status: number; + /** Routing completed without invoking the reusable response stage. */ + terminal?: true; version: 1; }; @@ -72,14 +80,48 @@ function cacheabilityVaryRejectionReason( : null; } -export function createWorkerCacheabilityContext( - base: ExecutionContextLike, +export type WorkerCacheabilityProbeMode = "identity" | "probe"; + +export type WorkerCacheabilityProbeRoute = { + kind: CacheabilityRouteKind; + pattern: string; +}; + +/** Encode the trusted route identity carried by a staged probe request. */ +export function serializeWorkerCacheabilityProbeRoute(route: WorkerCacheabilityProbeRoute): string { + return encodeURIComponent(JSON.stringify([route.kind, route.pattern])); +} + +/** Read route identity only after the surrounding probe request is authenticated. */ +export function readWorkerCacheabilityProbeRoute( + request: Request, +): WorkerCacheabilityProbeRoute | null { + const raw = request.headers.get(VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER); + if (!raw) return null; + try { + const value = JSON.parse(decodeURIComponent(raw)) as unknown; + if (!Array.isArray(value) || value.length !== 2) return null; + const [kind, pattern] = value; + if ( + (kind !== "app-page" && kind !== "app-route" && kind !== "pages-page") || + typeof pattern !== "string" || + !pattern.startsWith("/") + ) { + return null; + } + return { kind, pattern }; + } catch { + return null; + } +} + +/** Authenticate and read the cacheability probe mode before internal headers are filtered. */ +export function readWorkerCacheabilityProbeMode( request: Request, expectedSecret: string | null | undefined, - responseVary?: "verbatim", -): ExecutionContextLike { +): WorkerCacheabilityProbeMode | null { const requestedMode = request.headers.get(VINEXT_CACHEABILITY_PROBE_HEADER); - if (requestedMode !== "1" && requestedMode !== "identity") return base; + if (requestedMode !== "1" && requestedMode !== "identity") return null; if ( !expectedSecret || !workerCapabilityMatches( @@ -87,19 +129,41 @@ export function createWorkerCacheabilityContext( expectedSecret, ) ) { - return base; + return null; } + return requestedMode === "identity" ? "identity" : "probe"; +} + +/** Create probe state from a mode that was authenticated at the request boundary. */ +export function createWorkerCacheabilityProbeContext( + base: ExecutionContextLike, + mode: WorkerCacheabilityProbeMode, + responseVary?: "verbatim", + resolvedRoutePathname?: string, +): ExecutionContextLike { const state: RouteCacheabilityState = { captureDeadlineAt: Date.now() + CACHEABILITY_PROBE_TIMEOUT_MS, - mode: requestedMode === "identity" ? "identity" : "probe", + mode, + responsePolicyHeaderNames: [...getCdnResponsePolicyHeaderNames()], responseVary, + resolvedRoutePathname, }; return Object.assign(Object.create(Object.getPrototypeOf(base)), base, { [CACHEABILITY_REQUEST_STATE]: state, }); } +export function createWorkerCacheabilityContext( + base: ExecutionContextLike, + request: Request, + expectedSecret: string | null | undefined, + responseVary?: "verbatim", +): ExecutionContextLike { + const mode = readWorkerCacheabilityProbeMode(request, expectedSecret); + return mode ? createWorkerCacheabilityProbeContext(base, mode, responseVary) : base; +} + let cachedManifest: | { buildId: string; manifest: CacheabilityManifest | null; raw: string } | undefined; @@ -120,8 +184,17 @@ export function createWorkerCacheabilityAdmissionContext( buildId: string | null | undefined, requiresCompletedResponseAdmission = rawManifest != null, responseVary?: "verbatim", + resolvedRoutePathname?: string, + trustedRepresentation?: CacheabilityRepresentation, + options?: { applyCompletedResponsePolicy?: boolean }, ): ExecutionContextLike { - const identity = cacheabilityRequestIdentity(request); + const identity = cacheabilityRequestIdentity(request, trustedRepresentation); + const routePathname = identity + ? cacheabilityRoutePathname( + resolvedRoutePathname ?? new URL(request.url).pathname, + identity.representation, + ) + : undefined; if (!rawManifest) { if (!requiresCompletedResponseAdmission) return base; const state: RouteCacheabilityState = { @@ -129,14 +202,13 @@ export function createWorkerCacheabilityAdmissionContext( ? { policy: "runtime", ...identity, - routePathname: cacheabilityRoutePathname( - new URL(request.url).pathname, - identity.representation, - ), + routePathname, } : { policy: "deny" }, captureDeadlineAt: Date.now() + CACHEABILITY_PROBE_TIMEOUT_MS, mode: "admit", + applyCompletedResponsePolicy: options?.applyCompletedResponsePolicy, + responsePolicyHeaderNames: [...getCdnResponsePolicyHeaderNames()], responseVary, }; return Object.assign(Object.create(Object.getPrototypeOf(base)), base, { @@ -153,14 +225,13 @@ export function createWorkerCacheabilityAdmissionContext( manifest, policy: "manifest", ...identity, - routePathname: cacheabilityRoutePathname( - new URL(request.url).pathname, - identity.representation, - ), + routePathname, } : { policy: "deny" }, captureDeadlineAt: Date.now() + CACHEABILITY_PROBE_TIMEOUT_MS, mode: "admit", + applyCompletedResponsePolicy: options?.applyCompletedResponsePolicy, + responsePolicyHeaderNames: [...getCdnResponsePolicyHeaderNames()], responseVary, }; return Object.assign(Object.create(Object.getPrototypeOf(base)), base, { @@ -174,6 +245,40 @@ function readState(ctx: ExecutionContextLike): RouteCacheabilityState | null { ); } +/** Apply request-stage-vetted positive config policy inside the admission boundary. */ +export function applyResponseStageCachePolicy( + response: Response, + ctx: ExecutionContextLike, + policyHeaders: ReadonlyArray | null | undefined, +): Response { + if (!policyHeaders?.length) return response; + const state = readState(ctx); + if (state) state.explicitConfigCachePolicy = true; + + try { + applyResponseStagePolicyHeaders(response.headers, policyHeaders); + return response; + } catch { + const headers = new Headers(response.headers); + applyResponseStagePolicyHeaders(headers, policyHeaders); + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); + } +} + +/** Record policy that a renderer applied before producing its response. */ +export function recordResponseStageCachePolicy( + ctx: ExecutionContextLike, + policyHeaders: ReadonlyArray | null | undefined, +): void { + if (!policyHeaders?.length) return; + const state = readState(ctx); + if (state) state.explicitConfigCachePolicy = true; +} + function probeResponse( state: RouteCacheabilityState, routeState: CacheabilityProbeRouteState, @@ -187,6 +292,7 @@ function probeResponse( pattern: state.route?.pattern, reason: outcome.reason, ...(rendererStatic !== undefined ? { rendererStatic } : {}), + ...(state.resolvedRoutePathname ? { routePathname: state.resolvedRoutePathname } : {}), ...(routeState === "dynamic" ? { scope: state.patternDynamicReason ? ("pattern" as const) : ("identity" as const) } : {}), @@ -201,6 +307,38 @@ function probeResponse( ); } +/** + * Convert an authenticated probe that terminated in request routing into a + * valid identity-scoped dynamic result. Middleware redirects, custom responses, + * and external rewrites never reach the reusable response stage. + */ +export function finalizeRequestStageCacheabilityProbe( + response: Response, + options: { + mode: WorkerCacheabilityProbeMode | null; + responseStageDispatched: boolean; + route: WorkerCacheabilityProbeRoute | null; + }, +): Response { + if (!options.mode || options.responseStageDispatched || !options.route) return response; + void response.body?.cancel().catch(() => {}); + const body: CacheabilityProbeResult = { + kind: options.route.kind, + pattern: options.route.pattern, + reason: "request routing completed without response-stage rendering", + scope: "identity", + state: "dynamic", + status: response.status, + terminal: true, + version: 1, + }; + return applyCdnResponseBuildIdentityHeaders( + Response.json(body, { + headers: { "Cache-Control": NO_STORE_CACHE_CONTROL }, + }), + ); +} + async function drainProbeBody(response: Response, deadlineAt: number): Promise { if (!response.body) return null; const reader = response.body.getReader(); @@ -461,15 +599,15 @@ function inferFinalAppPageCacheability( // Config headers run after the framework snapshots its provisional policy. // Match Next.js by honoring a later explicit public policy instead of // replacing it with the renderer-derived default during admission. - const changedPolicy = ( - ["cloudflare-cdn-cache-control", "cdn-cache-control", "cache-control"] as const - ).find((name) => { - const value = response.headers.get(name); - return ( - value !== null && - (state.explicitConfigCachePolicy || value !== state.frameworkResponseCachePolicy?.[name]) - ); - }); + const changedPolicy = [...(state.responsePolicyHeaderNames ?? CACHEABILITY_POLICY_HEADERS)] + .reverse() + .find((name) => { + const value = response.headers.get(name); + return ( + value !== null && + (state.explicitConfigCachePolicy || value !== state.frameworkResponseCachePolicy?.[name]) + ); + }); if (!changedPolicy) return null; const cacheControl = response.headers.get(changedPolicy)!; @@ -489,11 +627,14 @@ function inferFinalAppPageCacheability( }; } -function inferPagesPageCacheability(response: Response): RouteCacheabilityOutcome { - const cacheControl = - response.headers.get("Cloudflare-CDN-Cache-Control") ?? - response.headers.get("CDN-Cache-Control") ?? - response.headers.get("Cache-Control"); +function inferPagesPageCacheability( + response: Response, + state: RouteCacheabilityState, +): RouteCacheabilityOutcome { + const cacheControl = [...(state.responsePolicyHeaderNames ?? CACHEABILITY_POLICY_HEADERS)] + .reverse() + .map((name) => response.headers.get(name)) + .find((value) => value !== null); if (!cacheControl || isNonCacheableCacheControl(cacheControl)) { return { cacheable: false }; } @@ -526,7 +667,7 @@ function completedRouteOutcome( if (response.headers.has("set-cookie")) { return { cacheable: false, reason: "response sets a cookie" }; } - return inferPagesPageCacheability(response); + return inferPagesPageCacheability(response, state); } if (state.route?.kind === "app-page") { return inferFinalAppPageCacheability(response, state) ?? rendererOutcome; @@ -544,7 +685,7 @@ function completedRouteOutcome( // Ported from Next.js: // test/e2e/getserversideprops/test/index.test.ts // test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts - const responseOutcome = inferPagesPageCacheability(response); + const responseOutcome = inferPagesPageCacheability(response, state); return responseOutcome.cacheable ? responseOutcome : (rendererOutcome ?? responseOutcome); } @@ -569,7 +710,7 @@ function cacheabilityEvaluationFailureResponse(pattern: string): Response { function hasStrictFinalResponseVeto(response: Response, state: RouteCacheabilityState): boolean { if (state.finalResponseVetoReason || response.headers.has("set-cookie")) return true; - for (const name of CACHEABILITY_POLICY_HEADERS) { + for (const name of state.responsePolicyHeaderNames ?? CACHEABILITY_POLICY_HEADERS) { const value = response.headers.get(name); if ( value !== null && @@ -594,18 +735,18 @@ async function finalizeWorkerCacheabilityAdmission( const admission = state.admission; - // Route Handlers normally prove body completion inside their execution - // boundary, so the outer Worker does not buffer them a second time. Config - // headers run later, however, and can make an otherwise dynamic response - // public. Capture only that unproven final-public case before it can escape. - // A manifest-bearing deployment normally authorizes the route pattern. An - // unlisted Route Handler can still opt in with an explicit application or - // config cache policy, but only after this finalizer has checked the fully - // completed response. - if (state.route?.kind === "app-route") { + // App Route Handlers normally prove body completion inside their execution + // boundary, while Pages APIs arrive here with their stream still live. + // Config or application headers can explicitly publish either response. + // Require a clean completed body before an unlisted endpoint can enter the + // shared cache. + if (state.route?.kind === "app-route" || state.route?.kind === "pages-api") { let manifestRoute: CacheabilityManifestRoute | null = null; + const responseOutcome = inferPagesPageCacheability(response, state); const hasExplicitRuntimePolicy = - state.explicitResponseCachePolicy === true || state.explicitConfigCachePolicy === true; + state.explicitResponseCachePolicy === true || + state.explicitConfigCachePolicy === true || + (state.route.kind === "pages-api" && responseOutcome.cacheable); if ( !admission || admission.policy === "deny" || @@ -614,7 +755,7 @@ async function finalizeWorkerCacheabilityAdmission( ) { return responseWithCachePolicy(response, response.body, null); } - if (admission.policy === "manifest") { + if (admission.policy === "manifest" && state.route.kind === "app-route") { const manifest = admission.manifest as CacheabilityManifest; manifestRoute = findCacheabilityManifestRoute( manifest, @@ -644,11 +785,14 @@ async function finalizeWorkerCacheabilityAdmission( return responseWithCachePolicy(response, response.body, null); } - const outcome = inferPagesPageCacheability(response); + const outcome = responseOutcome; if (!outcome.cacheable || !outcome.cacheControl) { return responseWithCachePolicy(response, response.body, null); } - if (state.completedResponseBody) return response; + if (state.completedResponseBody) { + if (!state.applyCompletedResponsePolicy) return response; + return responseWithCachePolicy(response, response.body, outcome); + } let captured: CapturedAdmissionBody; try { diff --git a/packages/vinext/src/server/client-reuse-manifest.ts b/packages/vinext/src/server/client-reuse-manifest.ts index 67edf643f..4340176f1 100644 --- a/packages/vinext/src/server/client-reuse-manifest.ts +++ b/packages/vinext/src/server/client-reuse-manifest.ts @@ -2,7 +2,7 @@ import { parseArtifactCompatibilityEnvelope, type ArtifactCompatibilityEnvelope, } from "./artifact-compatibility.js"; -import { AppElementsWire } from "./app-elements-wire.js"; +import { parseAppElementsWireElementKey } from "./app-elements-wire-key.js"; import { fnv1a64 } from "../utils/hash.js"; import { isNonNegativeSafeInteger } from "../utils/number.js"; import { isUnknownRecord } from "../utils/record.js"; @@ -279,7 +279,7 @@ function currentCommitVersionMatchesReplayWindow( } function parseEntryKind(id: string): ClientReuseManifestEntryKind | null { - const parsed = AppElementsWire.parseElementKey(id); + const parsed = parseAppElementsWireElementKey(id); if (parsed === null) return null; return parsed.kind; } diff --git a/packages/vinext/src/server/config-headers.ts b/packages/vinext/src/server/config-headers.ts index a589842c3..e4a099489 100644 --- a/packages/vinext/src/server/config-headers.ts +++ b/packages/vinext/src/server/config-headers.ts @@ -6,15 +6,49 @@ import { } from "../config/config-matchers.js"; import type { HeaderRecord } from "./request-pipeline.js"; import { - CACHEABILITY_POLICY_HEADERS, markRouteCacheabilityDynamic, markRouteCacheabilityExplicitConfigPolicy, markRouteCacheabilityFinalResponseUncacheable, } from "vinext/shims/cacheability-classification"; import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache"; +import { getCdnResponsePolicyHeaderNames } from "./cache-control.js"; +import { mergeVaryHeader } from "./middleware-response-headers.js"; const ADDITIVE_CONFIG_HEADER_NAMES = new Set(["set-cookie", "vary"]); -const CACHEABILITY_POLICY_HEADER_NAMES = new Set(CACHEABILITY_POLICY_HEADERS); + +export type ResponseStageCachePolicyOptions = { + basePathState?: BasePathMatchState; + configHeaders: NextHeader[]; + pathname: string; + requestContext: RequestContext; +}; + +/** + * Resolve positive config cache policy that must accompany an inner artifact. + * The request stage evaluates every condition. Shared stage transports must key + * the complete serialized props, so the matched policy partitions the artifact + * without exposing request-only header, cookie, or host values to the renderer. + */ +export function resolveResponseStageCachePolicy({ + basePathState, + configHeaders, + pathname, + requestContext, +}: ResponseStageCachePolicyOptions): Array<[string, string]> | null { + const matched = retainLastSingularConfigValues( + matchHeaders(pathname, configHeaders, requestContext, basePathState), + ); + const policy = matched + .filter((header) => { + const name = header.key.toLowerCase(); + return ( + name === "vary" || + (getCdnResponsePolicyHeaderNames().has(name) && !isNonCacheableCacheControl(header.value)) + ); + }) + .map((header) => [header.key, header.value] as [string, string]); + return policy.length > 0 ? policy : null; +} function markConditionalConfigHeaderCacheability(rule: NextHeader): void { if ( @@ -23,9 +57,10 @@ function markConditionalConfigHeaderCacheability(rule: NextHeader): void { condition.type === "header" || condition.type === "cookie" || condition.type === "host", ) ) { - // Query values are already part of the public Workers Cache key. Headers, - // cookies, and hostnames are not, so a response header selected by any of - // them cannot be shared safely under the request URL. + // Legacy single-stage admission keys the public request rather than a + // serialized stage envelope, so these conditions still require a veto. + // Multi-stage callers set recordCacheability=false and carry the matched + // policy in identity-keyed response-stage props instead. markRouteCacheabilityDynamic( "next.config headers depend on request headers, cookies, or hostnames", ); @@ -41,10 +76,10 @@ function markExplicitConfigResponseVeto( markRouteCacheabilityFinalResponseUncacheable("next.config headers set a cookie"); continue; } - if (CACHEABILITY_POLICY_HEADER_NAMES.has(name)) { + if (getCdnResponsePolicyHeaderNames().has(name)) { markRouteCacheabilityExplicitConfigPolicy(); } - if (CACHEABILITY_POLICY_HEADER_NAMES.has(name) && isNonCacheableCacheControl(header.value)) { + if (getCdnResponsePolicyHeaderNames().has(name) && isNonCacheableCacheControl(header.value)) { markRouteCacheabilityFinalResponseUncacheable( `next.config headers set a non-cacheable ${header.key} policy`, ); @@ -68,6 +103,8 @@ type ApplyConfigHeadersOptions = { appendToPostConfigLink?: boolean; /** Middleware response headers run after config and therefore suppress config values. */ middlewareHeaders?: Headers | null; + /** Whether this composition participates in response-cache admission. */ + recordCacheability?: boolean; }; function retainLastSingularConfigValues( @@ -133,10 +170,10 @@ export function applyConfigHeadersToResponse( options.configHeaders, options.requestContext, options.basePathState, - markConditionalConfigHeaderCacheability, + options.recordCacheability === false ? undefined : markConditionalConfigHeaderCacheability, ), ); - markExplicitConfigResponseVeto(matched); + if (options.recordCacheability !== false) markExplicitConfigResponseVeto(matched); for (const header of matched) { const lowerName = header.key.toLowerCase(); if (lowerName === "link") { @@ -155,6 +192,8 @@ export function applyConfigHeadersToResponse( // authoritative even when this config field may replace a renderer-owned // default (notably Cache-Control). continue; + } else if (lowerName === "vary") { + mergeVaryHeader(responseHeaders, header.value); } else if (ADDITIVE_CONFIG_HEADER_NAMES.has(lowerName)) { responseHeaders.append(header.key, header.value); } else if (options.overwriteExisting?.has(lowerName) || !responseHeaders.has(lowerName)) { @@ -174,10 +213,10 @@ export function applyConfigHeadersToHeaderRecord( options.configHeaders, options.requestContext, options.basePathState, - markConditionalConfigHeaderCacheability, + options.recordCacheability === false ? undefined : markConditionalConfigHeaderCacheability, ), ); - markExplicitConfigResponseVeto(matched); + if (options.recordCacheability !== false) markExplicitConfigResponseVeto(matched); for (const header of matched) { const lowerName = header.key.toLowerCase(); if (lowerName === "set-cookie") { diff --git a/packages/vinext/src/server/fetch-handler.ts b/packages/vinext/src/server/fetch-handler.ts index 60e66d9b8..6a1503294 100644 --- a/packages/vinext/src/server/fetch-handler.ts +++ b/packages/vinext/src/server/fetch-handler.ts @@ -12,7 +12,10 @@ * for the current project at build time. */ +// Re-export the adapter-selected Worker facade. A single-stage output exposes +// only `default`; a multi-stage output may additionally expose named +// entrypoints which must remain top-level exports in the final Worker module. // @ts-expect-error -- virtual module resolved by vinext at build time -import handler from "virtual:vinext-worker-entry"; - -export default handler; +export { default } from "virtual:vinext-worker-entry"; +// @ts-expect-error -- virtual module resolved by vinext at build time +export * from "virtual:vinext-worker-entry"; diff --git a/packages/vinext/src/server/headers.ts b/packages/vinext/src/server/headers.ts index 7d1237006..f9e91bbbe 100644 --- a/packages/vinext/src/server/headers.ts +++ b/packages/vinext/src/server/headers.ts @@ -40,6 +40,9 @@ export const VINEXT_EXPECTED_WORKER_VERSION_HEADER = "X-Vinext-Expected-Worker-V /** Authenticated staged-Worker request asking for a completed App Page classification. */ export const VINEXT_CACHEABILITY_PROBE_HEADER = "X-Vinext-Cacheability-Probe"; +/** Trusted route expected by an authenticated staged cacheability probe. */ +export const VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER = "X-Vinext-Cacheability-Probe-Route"; + /** * Per-attempt cache buster used while a newly staged Worker version propagates. * Authenticated probe requests remove it before middleware or route code runs. @@ -72,6 +75,9 @@ export const VINEXT_PRERENDER_READINESS_HEADER = "X-Vinext-Prerender-Readiness"; /** TPR (Tailored Per-Request) revalidation interval in seconds. */ export const VINEXT_REVALIDATE_HEADER = "x-vinext-revalidate"; +/** Actual Pages cache tag regenerated by an authenticated on-demand request. */ +export const VINEXT_REVALIDATED_CACHE_TAG_HEADER = "x-vinext-revalidated-cache-tag"; + /** Marker on cached ISR entries indicating RSC payload (value "1"). */ export const VINEXT_RSC_MARKER_HEADER = "x-vinext-rsc"; @@ -284,9 +290,11 @@ export const INTERNAL_HEADERS = [ /** Vinext-only internal headers stripped alongside Next.js protocol internals. */ export const VINEXT_INTERNAL_HEADERS = [ VINEXT_CACHEABILITY_PROBE_HEADER.toLowerCase(), + VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER.toLowerCase(), VINEXT_EXPECTED_WORKER_VERSION_HEADER.toLowerCase(), VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, VINEXT_PRERENDER_SPECULATIVE_HEADER, VINEXT_PRERENDER_CACHE_LIFE_HEADER, VINEXT_REVALIDATE_HOST_HEADER, + VINEXT_REVALIDATED_CACHE_TAG_HEADER, ]; diff --git a/packages/vinext/src/server/multi-stage.ts b/packages/vinext/src/server/multi-stage.ts new file mode 100644 index 000000000..ca3b9d761 --- /dev/null +++ b/packages/vinext/src/server/multi-stage.ts @@ -0,0 +1,101 @@ +import type { CacheabilityRepresentation } from "./cacheability-manifest.js"; + +/** + * Transport-neutral cache intent passed from the request stage to an + * adapter-owned response-stage transport. + */ +export type VinextResponseStageDispatchOptions = { + /** + * Whether the adapter may use its shared response transport. Bypassed work + * still uses the same response stage, but must not pass through a host cache. + * A shared transport must partition entries by the request method, complete + * request URL (including scheme, authority, exact path, and query), plus the + * complete serialized stage props; each can affect handler selection or + * response bytes. + */ + cache: "shared" | "bypass"; +}; + +export type VinextCacheabilityProbeMode = "probe" | "identity"; + +/** Trusted route/admission metadata transported independently of user headers. */ +export type VinextResponseStageCacheability = { + /** Safe positive next.config policy needed for CDN-level Next.js parity. */ + policyHeaders: Array<[string, string]> | null; + /** Present only after the request stage authenticates an internal probe. */ + probeMode: VinextCacheabilityProbeMode | null; + /** Resolved route pathname used for manifest authorization after outer rewrites. */ + resolvedRoutePathname: string; + /** Trusted representation retained when request-stage normalization changes the URL shape. */ + representation?: CacheabilityRepresentation; +}; + +/** + * Adapter-owned transport from the request stage to the response stage. + * + * The props and options are serializable stage metadata. An adapter may carry + * them over in-process dispatch, platform RPC, a service binding, or HTTP. If + * it caches shared dispatches, its identity must include the request method, + * complete request URL (including scheme, authority, exact path, and query), + * plus the complete serialized props. + */ +export type VinextResponseStageTransport = ( + request: Request, + props: Props, + options: VinextResponseStageDispatchOptions, +) => Promise; + +/** Adapter-selected server output for independently hostable vinext stages. */ +export type VinextMultiStageOutput = { + /** Adapter-owned module that becomes the deployment entry when selected. */ + entry: string; + type: "multi-stage"; + /** Decide whether the current build host supports this adapter's transport. */ + matchesBuild?: (build: { plugins: readonly { name?: string }[] }) => boolean; + /** Decorate a host entry without exposing transport-specific exports to core. */ + transformHostEntry?: (module: { code: string; id: string }) => string | null; + /** + * Let the adapter finalize host-owned deployment output after it is written. + * Core supplies paths only; the adapter owns every platform-specific detail. + */ + finalizeBuildOutput?: (output: { outDir: string; root: string }) => Promise | void; +}; + +/** Platform-neutral request-stage handler exposed to deployment adapters. */ +export type VinextRequestStageHandler = ( + request: Request, + env: Env, + context: Context, + dispatchResponseStage: VinextResponseStageTransport, +) => Promise; + +/** Adapter-owned reverse transport from a response stage back through request routing. */ +export type VinextRequestStageTransport = (request: Request) => Promise; + +/** Adapter-supplied host capability for serving deployment assets. */ +export type VinextAssetFetcher = { + fetch(request: Request): Promise | Response; +}; + +/** Optional host capabilities consumed by a platform-neutral request stage. */ +export type VinextRequestStageContext = { + assets?: VinextAssetFetcher; +}; + +/** Platform-neutral response-stage handler exposed to deployment adapters. */ +export type VinextResponseStageHandler = ( + request: Request, + env: Env, + context: Context, + props: unknown, + dispatchRequestStage: VinextRequestStageTransport, + options: VinextResponseStageDispatchOptions, +) => Promise; + +export type VinextRequestStageModule = { + handleRequestStage: VinextRequestStageHandler; +}; + +export type VinextResponseStageModule = { + handleResponseStage: VinextResponseStageHandler; +}; diff --git a/packages/vinext/src/server/pages-api-route.ts b/packages/vinext/src/server/pages-api-route.ts index 4d6daddc6..bffa8c320 100644 --- a/packages/vinext/src/server/pages-api-route.ts +++ b/packages/vinext/src/server/pages-api-route.ts @@ -102,6 +102,8 @@ type HandlePagesApiRouteOptions = { */ ctx?: ExecutionContextLike; edgeRuntime?: EdgeApiExecutionRuntime; + /** Headers installed before user code, matching Next.js custom-route ordering. */ + initialResponseHeaders?: Headers; match: PagesApiRouteMatch | null; reportRequestError?: (error: Error, routePattern: string) => void | Promise; request: Request; @@ -173,7 +175,20 @@ async function _handlePagesApiRoute(options: HandlePagesApiRouteOptions): Promis ); const response = await route.module.default(nextRequest); if (response instanceof Response) { - return finalizeEdgeApiResponse(response, options.edgeRuntime ?? "worker"); + const finalized = finalizeEdgeApiResponse(response, options.edgeRuntime ?? "worker"); + if ( + !options.initialResponseHeaders || + !options.initialResponseHeaders.keys().next().value + ) { + return finalized; + } + const headers = new Headers(options.initialResponseHeaders); + for (const [name, value] of finalized.headers) headers.set(name, value); + return new Response(finalized.body, { + headers, + status: finalized.status, + statusText: finalized.statusText, + }); } throw new Error("Edge API route did not return a Response"); @@ -204,6 +219,7 @@ async function _handlePagesApiRoute(options: HandlePagesApiRouteOptions): Promis const { req, res, responsePromise } = createPagesReqRes({ allowedRevalidateHeaderKeys: options.nextConfig?.allowedRevalidateHeaderKeys, body, + initialResponseHeaders: options.initialResponseHeaders, query, request: options.request, trustedRevalidateOrigin: options.trustedRevalidateOrigin, diff --git a/packages/vinext/src/server/pages-node-compat.ts b/packages/vinext/src/server/pages-node-compat.ts index 03652793b..7ce1fe1ad 100644 --- a/packages/vinext/src/server/pages-node-compat.ts +++ b/packages/vinext/src/server/pages-node-compat.ts @@ -72,6 +72,7 @@ type PagesRequestCookiesCarrier = { type CreatePagesReqResOptions = { allowedRevalidateHeaderKeys?: readonly string[]; body: unknown; + initialResponseHeaders?: Headers; query: PagesRequestQuery; request: Request; trustedRevalidateOrigin?: string; @@ -599,6 +600,9 @@ export function createPagesReqRes(options: CreatePagesReqResOptions): CreatePage options.trustedRevalidateOrigin ?? new URL(options.request.url).origin, options.allowedRevalidateHeaderKeys, ) as PagesReqResResponse; + for (const [name, value] of options.initialResponseHeaders ?? []) { + res.setHeader(name, value); + } attachPagesPreviewApi(req, res); return { req, res, responsePromise }; diff --git a/packages/vinext/src/server/pages-page-handler.ts b/packages/vinext/src/server/pages-page-handler.ts index 41006d841..7c8d61d8b 100644 --- a/packages/vinext/src/server/pages-page-handler.ts +++ b/packages/vinext/src/server/pages-page-handler.ts @@ -61,6 +61,7 @@ import { closeAfterResponse, closeAfterResponseWithBody, createRequestContext, + preserveFullyBufferedBodyMetadata, runWithRequestContext, } from "vinext/shims/unified-request-context"; import { getRequestExecutionContext } from "vinext/shims/request-context"; @@ -76,6 +77,7 @@ import { NEXTJS_CACHE_HEADER, NEXTJS_DEPLOYMENT_ID_HEADER, VINEXT_CACHE_HEADER, + VINEXT_REVALIDATED_CACHE_TAG_HEADER, } from "./headers.js"; import { buildMissIsrCacheControl, ISR_NEVER_CACHE_CONTROL } from "./isr-decision.js"; import { encodeCacheTag } from "../utils/encode-cache-tag.js"; @@ -85,31 +87,58 @@ import { type PagesGetInitialPropsRouter, } from "./pages-get-initial-props.js"; +type PagesStreamedHtmlResponse = Response & { + __vinextStreamedHtmlResponse?: boolean; +}; + +function preservePagesBodyMetadata(source: Response, target: Response): Response { + const result = preserveFullyBufferedBodyMetadata(source, target) as PagesStreamedHtmlResponse; + if ((source as PagesStreamedHtmlResponse).__vinextStreamedHtmlResponse === true) { + result.__vinextStreamedHtmlResponse = true; + } + return result; +} + export function finalizePagesPreviewResponse( response: Response, preview: PagesPreviewState, ): Response { if (preview.data === false && !preview.shouldClear) return response; const headers = new Headers(response.headers); - if (preview.data !== false) { + // Next.js expires stale preview cookies but only applies this policy while + // draft mode remains active. Keep the cleanup response private as a stricter + // edge-cache safeguard: otherwise a shared cache can replay Set-Cookie and + // the ordinary ISR body selected after the invalid cookie was rejected. + if (preview.data !== false || preview.shouldClear) { applyCdnResponseHeaders(headers, { cacheControl: PAGES_PREVIEW_CACHE_CONTROL }); } if (preview.shouldClear) appendPagesPreviewClearCookies(headers); - return new Response(response.body, { - headers, - status: response.status, - statusText: response.statusText, - }); + return preservePagesBodyMetadata( + response, + new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }), + ); } function withPagesCacheState( response: Response, state: "MISS" | "HIT" | "STALE" | "REVALIDATED", + revalidatedPathname?: string, ): Response { const headers = new Headers(response.headers); if (state === "REVALIDATED") { headers.set(NEXTJS_CACHE_HEADER, state); headers.delete(VINEXT_CACHE_HEADER); + if (revalidatedPathname !== undefined) { + const stem = + revalidatedPathname.length > 1 && revalidatedPathname.endsWith("/") + ? revalidatedPathname.slice(0, -1) + : revalidatedPathname; + headers.set(VINEXT_REVALIDATED_CACHE_TAG_HEADER, encodeCacheTag(`_N_T_${stem || "/"}`)); + } } else { setCacheStateHeaders(headers, state); } @@ -367,6 +396,7 @@ export function createPagesPageHandler( manifest: Record | null | undefined, middlewareHeaders: Headers | null | undefined, options: RenderPageOptions | null | undefined, + initialResponseHeaders?: Headers, ) => Promise { const { pageRoutes, @@ -433,6 +463,7 @@ export function createPagesPageHandler( manifest: Record | null | undefined, middlewareHeaders: Headers | null | undefined, options: RenderPageOptions | null | undefined, + initialResponseHeaders?: Headers, ): Promise { let isDataReq = !!(options && options.isDataReq); const requestUrl = new URL(request.url); @@ -599,10 +630,17 @@ export function createPagesPageHandler( if (shouldCoalesceOnDemand) { const cacheKey = pageIsrCacheKey("pages", routeUrl.split("?")[0]); const snapshot = await coalesceOnDemandRevalidation(cacheKey, async () => { - const response = await renderPage(request, url, manifest, middlewareHeaders, { - ...options, - __skipOnDemandCoalesce: true, - }); + const response = await renderPage( + request, + url, + manifest, + middlewareHeaders, + { + ...options, + __skipOnDemandCoalesce: true, + }, + initialResponseHeaders, + ); return { body: request.method === "HEAD" || response.status === 204 || response.status === 304 @@ -807,6 +845,7 @@ export function createPagesPageHandler( const createPageReqRes = () => { const reqRes = createPagesReqRes({ body: undefined, + initialResponseHeaders, query, request, url: originalRequestPathAndSearch, @@ -917,16 +956,23 @@ export function createPagesPageHandler( const notFoundRoute = findNotFoundRoute(); let notFoundResponse: Response; if (notFoundRoute && routePattern !== "/404" && routePattern !== "/_error") { - notFoundResponse = await renderPage(request, url, manifest, middlewareHeaders, { - statusCode: 404, - asPath: routerAsPath, - renderErrorPageOnMiss: false, - __forcedRoute: notFoundRoute, - __notFoundRevalidateSeconds: pageDataResult.revalidateSeconds, - __notFoundExpireSeconds: pageDataResult.expireSeconds, - __notFoundCachePathname: isrCachePathname, - __notFoundSourceHeaders: pageDataResult.responseHeaders, - }); + notFoundResponse = await renderPage( + request, + url, + manifest, + middlewareHeaders, + { + statusCode: 404, + asPath: routerAsPath, + renderErrorPageOnMiss: false, + __forcedRoute: notFoundRoute, + __notFoundRevalidateSeconds: pageDataResult.revalidateSeconds, + __notFoundExpireSeconds: pageDataResult.expireSeconds, + __notFoundCachePathname: isrCachePathname, + __notFoundSourceHeaders: pageDataResult.responseHeaders, + }, + initialResponseHeaders, + ); } else { notFoundResponse = mergePagesNotFoundSourceHeaders( buildDefaultPagesNotFoundResponse(), @@ -936,7 +982,11 @@ export function createPagesPageHandler( notFoundResponse = stripPagesNotFoundFramingHeaders(notFoundResponse); if (isOnDemandRevalidate) { - notFoundResponse = withPagesCacheState(notFoundResponse, "REVALIDATED"); + notFoundResponse = withPagesCacheState( + notFoundResponse, + "REVALIDATED", + isrCachePathname, + ); } else if (pageDataResult.cacheState) { notFoundResponse = withPagesCacheState(notFoundResponse, pageDataResult.cacheState); } @@ -945,7 +995,7 @@ export function createPagesPageHandler( if (pageDataResult.kind === "response") { let response = isOnDemandRevalidate && pageDataResult.onDemandRevalidateSuccess !== false - ? withPagesCacheState(pageDataResult.response, "REVALIDATED") + ? withPagesCacheState(pageDataResult.response, "REVALIDATED", isrCachePathname) : pageDataResult.response; if (shouldApplyErrorResponsePolicy) { response = applyPagesErrorCachePolicy( @@ -1176,14 +1226,21 @@ export function createPagesPageHandler( } if (errorRoute) { try { - return await renderPage(request, url, manifest, middlewareHeaders, { - statusCode: 500, - asPath: url, - renderErrorPageOnMiss: false, - __isInternalErrorRender: true, - __forcedRoute: errorRoute, - err: e instanceof Error ? e : new Error(String(e)), - }); + return await renderPage( + request, + url, + manifest, + middlewareHeaders, + { + statusCode: 500, + asPath: url, + renderErrorPageOnMiss: false, + __isInternalErrorRender: true, + __forcedRoute: errorRoute, + err: e instanceof Error ? e : new Error(String(e)), + }, + initialResponseHeaders, + ); } catch (errorPageErr) { console.error("[vinext] Error page render failed:", errorPageErr); } diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts index 24124940a..ec7f530b4 100644 --- a/packages/vinext/src/server/pages-page-response.ts +++ b/packages/vinext/src/server/pages-page-response.ts @@ -33,7 +33,7 @@ import { extractDocumentAssetProps, } from "./pages-document-asset-props.js"; import { isBotUserAgent } from "../utils/html-limited-bots.js"; -import { NEXTJS_CACHE_HEADER } from "./headers.js"; +import { NEXTJS_CACHE_HEADER, VINEXT_REVALIDATED_CACHE_TAG_HEADER } from "./headers.js"; import { matchesIfNoneMatch } from "./http-conditional.js"; // --------------------------------------------------------------------------- @@ -710,6 +710,10 @@ export async function renderPagesPageResponse( }); if (options.isOnDemandRevalidate) { responseHeaders.set(NEXTJS_CACHE_HEADER, "REVALIDATED"); + responseHeaders.set( + VINEXT_REVALIDATED_CACHE_TAG_HEADER, + encodeCacheTag(`_N_T_${stem || "/"}`), + ); } else { setCacheStateHeaders(responseHeaders, "MISS"); } diff --git a/packages/vinext/src/server/pages-request-pipeline.ts b/packages/vinext/src/server/pages-request-pipeline.ts index bc661c4d6..7b55ff9d8 100644 --- a/packages/vinext/src/server/pages-request-pipeline.ts +++ b/packages/vinext/src/server/pages-request-pipeline.ts @@ -38,7 +38,10 @@ import { normalizeDefaultLocalePathname, stripI18nLocaleForApiRoute } from "./pa import { mergeRewriteQuery } from "../utils/query.js"; import { addBasePathToPathname, hasBasePath } from "../utils/base-path.js"; import { patternToNextFormat } from "../routing/route-validation.js"; -import { isOnDemandRevalidateRequest, PRERENDER_REVALIDATE_HEADER } from "./isr-cache.js"; +import { + isOnDemandRevalidateRequest, + PRERENDER_REVALIDATE_HEADER, +} from "./revalidation-request.js"; import { methodNotAllowedResponse, sanitizeMethodNotAllowedHeaders, @@ -77,8 +80,22 @@ export type PagesRenderOptions = { export type FilesystemRoutePhase = "direct" | "beforeFiles" | "afterFiles" | "fallback"; +function headersFromRecord(record: HeaderRecord): Headers { + const headers = new Headers(); + for (const [name, value] of Object.entries(record)) { + if (Array.isArray(value)) { + for (const item of value) headers.append(name, item); + } else { + headers.set(name, value); + } + } + return headers; +} + +export type PagesRouteDataKind = "none" | "server" | "static"; + type PageRouteMatch = { - route: { isDynamic: boolean; pattern?: string; dataKind?: "static" | "server" | "none" }; + route: { isDynamic: boolean; pattern?: string; dataKind?: PagesRouteDataKind }; }; export async function fetchWorkerFilesystemRoute( @@ -157,6 +174,8 @@ export type PagesPipelineDeps = { isDataRequest: boolean; // trusted data classification for middleware protocol handling hasMiddleware: boolean; // true only when the app defines middleware/proxy ctx?: unknown; // Cloudflare ExecutionContext or undefined (for Node) + /** False when routing and middleware run outside the shared response stage. */ + recordCacheability?: boolean; // Raw, un-re-encoded query string (incl. leading "?") for building redirect Location // headers. Node adapters that build the Web Request from a raw req.url string should // pass it so the redirect query isn't re-encoded by URL parsing (e.g. a literal "#" @@ -205,7 +224,14 @@ export type PagesPipelineDeps = { stagedHeaders?: Headers, ) => Promise) | null; - handleApi?: ((request: Request, apiUrl: string, ctx: unknown) => Promise) | null; + handleApi?: + | (( + request: Request, + apiUrl: string, + ctx: unknown, + stagedHeaders: Headers, + ) => Promise) + | null; /** * Optional override for proxying external rewrite destinations. * When supplied, the pipeline calls this instead of proxyExternalRequest(currentRequest, url). @@ -321,6 +347,10 @@ export async function runPagesRequest( isDataReq, isDataRequest, } = deps; + const conditionalRedirectCacheability = + deps.recordCacheability === false ? undefined : markConditionalRedirectCacheability; + const conditionalRewriteCacheability = + deps.recordCacheability === false ? undefined : markConditionalRewriteCacheability; // Proxy helper: use deps.proxyExternal when supplied (dev adapter forwards // Node req body), otherwise fall back to proxyExternalRequest(currentReq, url). @@ -370,7 +400,7 @@ export async function runPagesRequest( configRedirects, reqCtx, basePathState, - markConditionalRedirectCacheability, + conditionalRedirectCacheability, ); if (redirect) { // Only prepend basePath when the request was actually under basePath. @@ -401,6 +431,18 @@ export async function runPagesRequest( let resolvedUrl = originalResolvedUrl; let resolvedPathnameIsRequestPathname = true; const middlewareHeaders: HeaderRecord = {}; + const mergeConfigHeadersIntoEarlyResponse = (response: Response): Response => { + if (configHeaders.length === 0) return response; + const matchedConfigHeaders: HeaderRecord = {}; + applyConfigHeadersToHeaderRecord(matchedConfigHeaders, { + configHeaders, + pathname: requestConfigMatchPathname, + requestContext: reqCtx, + basePathState, + recordCacheability: deps.recordCacheability, + }); + return mergeHeaders(response, matchedConfigHeaders); + }; let middlewareStatus: number | undefined; const serveFilesystemRoute = async ( requestPathname: string, @@ -440,7 +482,7 @@ export async function runPagesRequest( isDataRequest, }); - if (result.pathnameEligible) { + if (deps.recordCacheability !== false && result.pathnameEligible) { markRouteCacheabilityDynamic("middleware can match this pathname"); } @@ -484,14 +526,19 @@ export async function runPagesRequest( } return { type: "response", - response: new Response(null, { - status: result.redirectStatus ?? 307, - headers, - }), + response: mergeConfigHeadersIntoEarlyResponse( + new Response(null, { + status: result.redirectStatus ?? 307, + headers, + }), + ), }; } if (result.response) { - return { type: "response", response: result.response }; + return { + type: "response", + response: mergeConfigHeadersIntoEarlyResponse(result.response), + }; } } @@ -592,6 +639,7 @@ export async function runPagesRequest( pathname: requestConfigMatchPathname, requestContext: reqCtx, basePathState, + recordCacheability: deps.recordCacheability, }); } @@ -622,7 +670,7 @@ export async function runPagesRequest( rewriteRequestContext(), basePathState, configSourcePathname(), - markConditionalRewriteCacheability, + conditionalRewriteCacheability, ); if (rewritten) { if (isExternalUrl(rewritten)) { @@ -676,7 +724,12 @@ export async function runPagesRequest( apiRequestUrl.pathname = addBasePathToPathname(apiRequestUrl.pathname, basePath); apiRequest = cloneRequestWithUrl(request, apiRequestUrl.toString()); } - const response = await deps.handleApi(apiRequest, apiLookupUrl, deps.ctx ?? null); + const response = await deps.handleApi( + apiRequest, + apiLookupUrl, + deps.ctx ?? null, + headersFromRecord(middlewareHeaders), + ); const merged = mergeHeaders(response, middlewareHeaders, middlewareStatus); // Preserve the streaming marker so the adapter can decide stream-vs-buffer. // mergeHeaders may create a new Response object (losing non-standard @@ -724,7 +777,7 @@ export async function runPagesRequest( rewriteRequestContext(), basePathState, configSourcePathname(), - markConditionalRewriteCacheability, + conditionalRewriteCacheability, ); if (rewritten) { if (isExternalUrl(rewritten)) { @@ -778,7 +831,7 @@ export async function runPagesRequest( rewriteRequestContext(), basePathState, configSourcePathname(), - markConditionalRewriteCacheability, + conditionalRewriteCacheability, ); if (!fallbackRewrite) continue; if (isExternalUrl(fallbackRewrite)) { @@ -818,14 +871,7 @@ export async function runPagesRequest( // Convert staged middleware headers to a Web Headers object for renderPage. // Adapters that need to inject per-request values (e.g. CSP nonces) into the // rendered HTML can access them via this argument. - const stagedHeaders = new Headers(); - for (const [k, v] of Object.entries(middlewareHeaders)) { - if (Array.isArray(v)) { - for (const item of v) stagedHeaders.append(k, item); - } else { - stagedHeaders.set(k, v); - } - } + const stagedHeaders = headersFromRecord(middlewareHeaders); let response = await deps.renderPage(request, resolvedUrl, initialRenderOptions, stagedHeaders); @@ -839,7 +885,7 @@ export async function runPagesRequest( rewriteRequestContext(), basePathState, configSourcePathname(), - markConditionalRewriteCacheability, + conditionalRewriteCacheability, ); if (!fallbackRewrite) continue; if (isExternalUrl(fallbackRewrite)) { @@ -930,7 +976,7 @@ export async function runPagesRequest( rewriteRequestContext(), basePathState, configSourcePathname(), - markConditionalRewriteCacheability, + conditionalRewriteCacheability, ); if (!fallbackRewrite) continue; if (isExternalUrl(fallbackRewrite)) { diff --git a/packages/vinext/src/server/pages-request-stage-entry.ts b/packages/vinext/src/server/pages-request-stage-entry.ts new file mode 100644 index 000000000..695c42385 --- /dev/null +++ b/packages/vinext/src/server/pages-request-stage-entry.ts @@ -0,0 +1,595 @@ +/** + * Request-stage entry point for vinext Pages Router. + * + * The public pages-router-entry delegates here. Multi-stage hosts import the + * named request handler directly and provide their response-stage dispatcher. + */ + +import { + fetchWorkerFilesystemRoute, + runPagesRequest, + wrapMiddlewareWithBasePath, +} from "./pages-request-pipeline.js"; +import type { PagesPipelineDeps } from "./pages-request-pipeline.js"; +import { + DEFAULT_DEVICE_SIZES, + DEFAULT_IMAGE_SIZES, + handleConfiguredImageOptimization, + isImageOptimizationPath, +} from "./image-optimization.js"; +import type { ImageConfig } from "./image-optimization.js"; +import { + attachRequestCfMetadata, + cloneRequestWithHeaders, + cloneRequestWithUrl, + filterInternalHeaders, + isOpenRedirectShaped, +} from "./request-pipeline.js"; +import { notFoundStaticAssetResponse } from "./http-error-responses.js"; +import { finalizeMissingStaticAssetResponse } from "./worker-utils.js"; +import { assetPrefixPathname, isNextStaticPath } from "../utils/asset-prefix.js"; +import { hasBasePath, stripBasePath } from "../utils/base-path.js"; +import { createWorkerRevalidationContext } from "./worker-revalidation-context.js"; +import { + VINEXT_CACHEABILITY_PROBE_HEADER, + VINEXT_CACHEABILITY_PROBE_QUERY_PARAM, + VINEXT_EXPECTED_WORKER_VERSION_HEADER, + VINEXT_PRERENDER_SECRET_HEADER, + VINEXT_REVALIDATE_HOST_HEADER, +} from "./headers.js"; +import type { ExecutionContextLike } from "vinext/shims/request-context"; +import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; +import { normalizeDefaultLocalePathname } from "./pages-i18n.js"; +import { requestContextFromRequest } from "../config/request-context.js"; +import { resolveResponseStageCachePolicy } from "./config-headers.js"; +import { + applyCdnResponseIdentityHeaders, + captureCdnResponsePolicyOverrides, + reconcileCdnResponseHeadersAfterOuterPolicy, + validateCdnRequest, +} from "./cache-control.js"; +import { + PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + type DispatchWorkerResponseStage, + type WorkerResponseStageProps, +} from "./worker-stages.js"; +import { getPagesResponseStageCacheDisposition } from "./pages-response-stage.js"; +import type { + VinextAssetFetcher, + VinextCacheabilityProbeMode, + VinextRequestStageContext, + VinextResponseStageDispatchOptions, +} from "./multi-stage.js"; +import { + createWorkerPrerenderDiscoveryContext, + createWorkerPrerenderReadinessResponse, + isWorkerPrerenderDiscoveryPath, +} from "./worker-prerender-discovery.js"; +import { + consumePagesResponseStagePolicyOwner, + withResponseStageVary, +} from "./response-stage-policy.js"; +import type { WorkerCacheabilityProbeRoute } from "./cacheability-request.js"; + +// @ts-expect-error -- virtual module resolved by vinext at build time +import { registerConfiguredCacheAdapters } from "virtual:vinext-cdn-cache-adapter"; +// @ts-expect-error -- virtual module resolved by vinext at build time +import { registerConfiguredImageOptimizer } from "virtual:vinext-image-adapters"; +// Request-only generated entry: route metadata, config, and middleware. It +// deliberately excludes page/API modules and rendering dependencies. +// @ts-expect-error -- virtual module resolved by vinext at build time +import * as pagesEntry from "virtual:vinext-pages-request-entry"; + +type AssetFetcher = { + fetch(request: Request): Promise | Response; +}; + +export type PagesWorkerEnv = { + ASSETS?: AssetFetcher; +} & Record; + +export type PagesWorkerExecutionContext = { + waitUntil?(promise: Promise): void; + passThroughOnException?(): void; + cache?: unknown; +} & VinextRequestStageContext; + +type PagesStageRuntimeDispatch = ( + request: Request, + props: WorkerResponseStageProps, + options: VinextResponseStageDispatchOptions, + env: PagesWorkerEnv | undefined, + ctx: ExecutionContextLike, +) => Promise; + +function haveSameHeaders(first: Headers, second: Headers): boolean { + const firstEntries = [...first]; + const secondEntries = [...second]; + return ( + firstEntries.length === secondEntries.length && + firstEntries.every( + ([name, value], index) => + name === secondEntries[index]?.[0] && value === secondEntries[index]?.[1], + ) + ); +} + +function captureOuterResponsePolicyHeaders( + stagedHeaders: Headers, + transportedPolicyHeaders: Array<[string, string]> | null, +): Headers { + return captureCdnResponsePolicyOverrides( + stagedHeaders, + new Headers(transportedPolicyHeaders ?? []), + ); +} + +export type PagesLocalResponseStage = ( + request: Request, + env: PagesWorkerEnv | undefined, + ctx: ExecutionContextLike, + props: WorkerResponseStageProps, +) => Promise; + +const { + authorizeOnDemandRevalidate, + hasMiddleware, + hasRequestAwareDocument, + matchApiRoute, + matchPageRoute, + normalizeDataRequest, + publicFiles, + runMiddleware, + vinextConfig, +} = pagesEntry; + +export const pagesRequestStageBuildId: string | null = pagesEntry.buildId ?? null; +export const pagesRequestStagePrerenderSecret: string | null = pagesEntry.prerenderSecret ?? null; + +const basePath: string = vinextConfig?.basePath ?? ""; +const assetPathPrefix: string = assetPrefixPathname(vinextConfig?.assetPrefix ?? ""); +const trailingSlash: boolean = vinextConfig?.trailingSlash ?? false; +const i18nConfig = vinextConfig?.i18n ?? null; +const configRedirects = vinextConfig?.redirects ?? []; +const configRewrites = vinextConfig?.rewrites ?? { + beforeFiles: [], + afterFiles: [], + fallback: [], +}; +const configHeaders = vinextConfig?.headers ?? []; +const imageConfig: ImageConfig | undefined = vinextConfig?.images + ? { + qualities: vinextConfig.images.qualities, + dangerouslyAllowSVG: vinextConfig.images.dangerouslyAllowSVG, + dangerouslyAllowLocalIP: vinextConfig.images.dangerouslyAllowLocalIP, + contentDispositionType: vinextConfig.images.contentDispositionType, + contentSecurityPolicy: vinextConfig.images.contentSecurityPolicy, + } + : undefined; + +/** Run the request-time stage with a host-owned response dispatcher. */ +export function handleRequestStage( + request: Request, + env: PagesWorkerEnv | undefined, + ctx: PagesWorkerExecutionContext | undefined, + dispatchResponseStage: DispatchWorkerResponseStage, +): Promise { + const originalRequest = request; + return handleRequest( + request, + env, + ctx, + (stageRequest, props, options) => dispatchResponseStage(stageRequest, props, options), + false, + "node", + ctx?.assets, + ).then((response) => applyCdnResponseIdentityHeaders(response, originalRequest)); +} + +/** Preserve the direct single-entry Worker path without retaining it in request-only builds. */ +export function handleRequestStageLocally( + request: Request, + env: PagesWorkerEnv | undefined, + ctx: PagesWorkerExecutionContext | undefined, + dispatchResponseStage: PagesLocalResponseStage, +): Promise { + const originalRequest = request; + return handleRequest( + request, + env, + ctx, + (stageRequest, props, _options, stageEnv, stageCtx) => + dispatchResponseStage(stageRequest, stageEnv, stageCtx, props), + true, + "worker", + env?.ASSETS, + ).then((response) => applyCdnResponseIdentityHeaders(response, originalRequest)); +} + +async function handleRequest( + request: Request, + env: PagesWorkerEnv | undefined, + platformCtx: PagesWorkerExecutionContext | ExecutionContextLike | undefined, + dispatchResponseStage: PagesStageRuntimeDispatch, + forceCacheBypass: boolean, + defaultHostRuntime: "node" | "worker", + assets: VinextAssetFetcher | undefined, +): Promise { + let sharedResponseHeaders: Headers | null = null; + let sharedOuterPolicyHeaders: Headers | null = null; + let ctx = createWorkerRevalidationContext( + platformCtx, + (internalRequest, internalCtx) => + handleRequest( + internalRequest, + env, + internalCtx, + dispatchResponseStage, + forceCacheBypass, + defaultHostRuntime, + assets, + ), + defaultHostRuntime, + ); + + // Pass the Worker env so binding-backed adapters (for example KV and Images) + // can resolve their configured bindings before request handling begins. + registerConfiguredCacheAdapters(env); + registerConfiguredImageOptimizer(env); + + try { + ctx = createWorkerPrerenderDiscoveryContext(ctx, request, pagesEntry.prerenderSecret); + const readinessResponse = createWorkerPrerenderReadinessResponse(ctx, request); + let didValidateCdnRequest = false; + if (readinessResponse) { + const validationResponse = await validateCdnRequest(request); + if (validationResponse) return validationResponse; + didValidateCdnRequest = true; + // Keep authenticated readiness on the response-stage transport so a + // multi-stage host proves both halves of the deployment are available. + if (readinessResponse.status !== 204) return readinessResponse; + } + + let probeMode: VinextCacheabilityProbeMode | null = null; + let probeRoute: WorkerCacheabilityProbeRoute | null = null; + if (request.headers.has(VINEXT_CACHEABILITY_PROBE_HEADER)) { + const { readWorkerCacheabilityProbeMode, readWorkerCacheabilityProbeRoute } = + await import("./cacheability-request.js"); + probeMode = readWorkerCacheabilityProbeMode(request, pagesEntry.prerenderSecret); + if (probeMode) { + probeRoute = readWorkerCacheabilityProbeRoute(request); + const probeUrl = new URL(request.url); + probeUrl.searchParams.delete(VINEXT_CACHEABILITY_PROBE_QUERY_PARAM); + request = new Request(probeUrl, request); + } + } + + let responseStageDispatched = false; + const trackedDispatchResponseStage: PagesStageRuntimeDispatch = ( + stageRequest, + props, + options, + stageEnv, + stageCtx, + ) => { + responseStageDispatched = true; + return dispatchResponseStage(stageRequest, props, options, stageEnv, stageCtx); + }; + + if (!didValidateCdnRequest) { + const cdnValidationResponse = await validateCdnRequest(request); + if (cdnValidationResponse) return cdnValidationResponse; + } + + // Strip internal headers from inbound requests so callers cannot forge + // framework state. Request.headers is immutable in Workers. + const filteredHeaders = ctx.isInternalPagesRevalidation + ? new Headers(request.headers) + : filterInternalHeaders(request.headers); + filteredHeaders.delete(VINEXT_PRERENDER_SECRET_HEADER); + filteredHeaders.delete(VINEXT_REVALIDATE_HOST_HEADER); + if (readinessResponse?.status === 204) { + const expectedWorkerVersion = request.headers.get(VINEXT_EXPECTED_WORKER_VERSION_HEADER); + if (expectedWorkerVersion) { + // The request stage already authenticated the build capability. Preserve + // only the version assertion needed by the independently hosted response + // stage; the prerender secret remains confined to this gateway. + filteredHeaders.set(VINEXT_EXPECTED_WORKER_VERSION_HEADER, expectedWorkerVersion); + } + } + request = cloneRequestWithHeaders(request, filteredHeaders); + + const url = new URL(request.url); + let pathname = url.pathname; + + if (ctx.isPrerenderPathDiscovery && isWorkerPrerenderDiscoveryPath(pathname)) { + return trackedDispatchResponseStage( + request, + { + buildId: pagesEntry.buildId, + cacheability: { + policyHeaders: null, + probeMode: null, + resolvedRoutePathname: pathname, + }, + kind: "pages-prerender-discovery", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: url.host, + stagedHeaders: null, + }, + { cache: "bypass" }, + env, + ctx, + ); + } + + // Block protocol-relative URL open redirects in all shapes: + // literal //evil.com, /\\evil.com + // encoded /%5Cevil.com, /%2F/evil.com + // Browsers normalize backslash to forward slash, and percent-decode + // Location headers, so encoded variants must be rejected before any + // downstream redirect can echo them. + if (isOpenRedirectShaped(pathname)) { + return new Response("This page could not be found", { status: 404 }); + } + try { + normalizePathnameForRouteMatchStrict(pathname); + } catch { + return new Response("Bad Request", { status: 400 }); + } + + // Valid assets are served by Cloudflare's ASSETS binding before the worker + // is invoked. Missing asset-shaped requests still need to reach middleware + // so it can rewrite/respond; a final 404 is converted back below. + const missingBuildAsset = isNextStaticPath(pathname, basePath, assetPathPrefix); + + // Track basePath presence on the original request so matcher gating can + // distinguish requests inside basePath from requests outside it. + const hadBasePath = !basePath || hasBasePath(pathname, basePath); + { + const stripped = stripBasePath(pathname, basePath); + if (stripped !== pathname) { + const strippedUrl = new URL(request.url); + strippedUrl.pathname = stripped; + request = cloneRequestWithUrl(request, strippedUrl.toString()); + pathname = stripped; + } + } + + const middlewareRequest = request; + const dataNorm = normalizeDataRequest(request); + if (dataNorm.notFoundResponse && !vinextConfig?.skipProxyUrlNormalize) { + return dataNorm.notFoundResponse; + } + const isDataReq = dataNorm.isDataReq; + if (isDataReq && dataNorm.normalizedPathname) { + request = dataNorm.request; + pathname = dataNorm.normalizedPathname; + } + const responseStagePolicyPathname = i18nConfig + ? normalizeDefaultLocalePathname(pathname, i18nConfig, { hostname: url.hostname }) + : pathname; + const responseStagePolicyHeaders = resolveResponseStageCachePolicy({ + basePathState: { basePath, hadBasePath }, + configHeaders, + pathname: responseStagePolicyPathname, + requestContext: requestContextFromRequest(request), + }); + // A known route miss is rendered speculatively before fallback rewrites or + // the error page are selected. A remote transport may consume the request + // body while serializing that first render, so retain the post-middleware + // request as a replay source for every render in that retry sequence. + // Ordinary matched page renders keep the original streaming request. + let speculativeRenderRequest: Request | null = null; + + const deps: PagesPipelineDeps = { + basePath, + trailingSlash, + i18nConfig, + configRedirects, + configRewrites, + configHeaders, + hadBasePath, + isDataReq, + isDataRequest: isDataReq, + hasMiddleware, + ctx, + recordCacheability: forceCacheBypass, + middlewareRequest: + isDataReq && vinextConfig?.skipProxyUrlNormalize ? middlewareRequest : undefined, + dataNotFoundResponse: vinextConfig?.skipProxyUrlNormalize ? dataNorm.notFoundResponse : null, + authorizeOnDemandRevalidate: + typeof authorizeOnDemandRevalidate === "function" ? authorizeOnDemandRevalidate : undefined, + matchApiRoute: typeof matchApiRoute === "function" ? matchApiRoute : null, + matchPageRoute: typeof matchPageRoute === "function" ? matchPageRoute : null, + runMiddleware: + typeof runMiddleware === "function" + ? wrapMiddlewareWithBasePath(runMiddleware, basePath, hadBasePath) + : null, + renderPage: (req, resolvedUrl, options, stagedHeaders) => { + if (options?.renderErrorPageOnMiss === false && req.body !== null && !req.bodyUsed) { + speculativeRenderRequest = req; + } + const stageRequest = + speculativeRenderRequest === req && req.body !== null && !req.bodyUsed + ? attachRequestCfMetadata(req.clone(), req) + : req; + const matchedPage = + typeof matchPageRoute === "function" ? matchPageRoute(resolvedUrl, req) : null; + const transportedPolicyHeaders = withResponseStageVary( + responseStagePolicyHeaders, + stagedHeaders?.get("Vary"), + ); + const responseStageProps = (cache: "shared" | "bypass"): WorkerResponseStageProps => ({ + buildId: pagesEntry.buildId, + cacheability: { + policyHeaders: transportedPolicyHeaders, + probeMode, + ...(isDataReq ? { representation: "pages-data" as const } : {}), + resolvedRoutePathname: new URL(resolvedUrl, req.url).pathname, + }, + kind: "pages-page" as const, + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: new URL(req.url).host, + renderOptions: options ?? null, + resolvedUrl, + // Static/ISR pages cannot observe Node's response object. Keep their + // request-specific middleware headers outside the shared artifact so + // one safe render can still serve every outer composition. Pages + // request-time handlers may read res.getHeader(), so their complete + // pre-handler snapshot is part of the shared-stage identity. + stagedHeaders: + cache === "shared" && matchedPage?.route.dataKind === "static" + ? null + : [...(stagedHeaders ?? new Headers())], + }); + const cache = + forceCacheBypass || probeMode + ? "bypass" + : getPagesResponseStageCacheDisposition({ + authorizeOnDemandRevalidate: + typeof authorizeOnDemandRevalidate === "function" + ? authorizeOnDemandRevalidate + : undefined, + hasRequestAwareDocument: hasRequestAwareDocument === true, + request: req, + requestHeadersChanged: !haveSameHeaders(filteredHeaders, req.headers), + routeDataKind: matchedPage?.route.dataKind, + stagedHeaders, + }); + const isHeadRequest = req.method.toUpperCase() === "HEAD"; + const dispatched = trackedDispatchResponseStage( + stageRequest, + responseStageProps(cache), + { cache }, + env, + ctx, + ); + const responsePromise = dispatched.then((response) => { + const consumed = consumePagesResponseStagePolicyOwner(response); + if (cache === "shared") { + sharedResponseHeaders = new Headers(consumed.response.headers); + const requestTimePolicyOwner = + consumed.owner === "request-time" || + (consumed.owner === null && matchedPage?.route.dataKind === "server"); + sharedOuterPolicyHeaders = !requestTimePolicyOwner + ? captureOuterResponsePolicyHeaders( + stagedHeaders ?? new Headers(), + transportedPolicyHeaders, + ) + : null; + } + return consumed.response; + }); + if (!isHeadRequest) return responsePromise; + return responsePromise.then(async (response) => { + await response.body?.cancel(); + return new Response(null, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); + }); + }, + handleApi: (req, apiUrl, _ctx, stagedHeaders) => { + const transportedPolicyHeaders = withResponseStageVary( + responseStagePolicyHeaders, + stagedHeaders.get("Vary"), + ); + const responseStageProps = (): WorkerResponseStageProps => ({ + apiUrl, + buildId: pagesEntry.buildId, + cacheability: { + policyHeaders: transportedPolicyHeaders, + probeMode, + resolvedRoutePathname: new URL(apiUrl, req.url).pathname, + }, + kind: "pages-api" as const, + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: new URL(req.url).host, + stagedHeaders: [...stagedHeaders], + }); + const cache = + forceCacheBypass || probeMode + ? "bypass" + : getPagesResponseStageCacheDisposition({ + authorizeOnDemandRevalidate: + typeof authorizeOnDemandRevalidate === "function" + ? authorizeOnDemandRevalidate + : undefined, + request: req, + requestHeadersChanged: !haveSameHeaders(filteredHeaders, req.headers), + stagedHeaders, + }); + const dispatched = trackedDispatchResponseStage( + req, + responseStageProps(), + { cache }, + env, + ctx, + ); + return cache === "shared" + ? dispatched.then((response) => { + sharedResponseHeaders = new Headers(response.headers); + // API response headers are authored entirely by user code. The + // ordinary merge already lets that response override config. + sharedOuterPolicyHeaders = null; + return response; + }) + : dispatched; + }, + serveFilesystemRoute: async (requestPathname, _stagedHeaders, phase, resolvedUrl) => { + if (!assets) return false; + if (isImageOptimizationPath(requestPathname)) { + const imageUrl = new URL(resolvedUrl, request.url); + const imageRequest = new Request(imageUrl, request); + const allowedWidths = [ + ...(vinextConfig?.images?.deviceSizes ?? DEFAULT_DEVICE_SIZES), + ...(vinextConfig?.images?.imageSizes ?? DEFAULT_IMAGE_SIZES), + ]; + return handleConfiguredImageOptimization( + imageRequest, + (assetPath) => + Promise.resolve(assets.fetch(new Request(new URL(assetPath, request.url)))), + allowedWidths, + imageConfig, + ); + } + return fetchWorkerFilesystemRoute( + request, + requestPathname, + phase, + (assetRequest) => Promise.resolve(assets.fetch(assetRequest)), + publicFiles, + missingBuildAsset, + ); + }, + }; + + const result = await runPagesRequest(request, deps); + if (result.type === "response") { + let response = finalizeMissingStaticAssetResponse(result.response, missingBuildAsset); + if (sharedResponseHeaders && sharedOuterPolicyHeaders) { + reconcileCdnResponseHeadersAfterOuterPolicy(response.headers, sharedOuterPolicyHeaders); + } + if (probeMode && probeRoute && !responseStageDispatched) { + const { finalizeRequestStageCacheabilityProbe } = await import("./cacheability-request.js"); + response = finalizeRequestStageCacheabilityProbe(response, { + mode: probeMode, + responseStageDispatched, + route: probeRoute, + }); + } + return response; + } + + // Should not reach here for a production Worker because all callbacks are + // supplied by virtual:vinext-pages-request-entry. + return missingBuildAsset + ? notFoundStaticAssetResponse() + : new Response("This page could not be found", { status: 404 }); + } catch (error) { + console.error("[vinext] Worker error:", error); + return new Response("Internal Server Error", { status: 500 }); + } +} diff --git a/packages/vinext/src/server/pages-response-stage-entry.ts b/packages/vinext/src/server/pages-response-stage-entry.ts new file mode 100644 index 000000000..041ab1c50 --- /dev/null +++ b/packages/vinext/src/server/pages-response-stage-entry.ts @@ -0,0 +1,205 @@ +/** Cacheable Pages render/API stage. This is the only Pages Worker stage that imports user pages. */ + +import { runWithExecutionContext, type ExecutionContextLike } from "vinext/shims/request-context"; +import { createWorkerRevalidationContext } from "./worker-revalidation-context.js"; +import { + isPagesResponseStageProps, + PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER, + type WorkerResponseStageProps, +} from "./worker-stages.js"; +import type { + VinextRequestStageTransport, + VinextResponseStageDispatchOptions, +} from "./multi-stage.js"; +import { withResponseStageCacheability } from "./response-stage-cacheability.js"; +import { applyResponseStagePolicyHeaders } from "./response-stage-policy.js"; +import { beginRouteCacheability } from "vinext/shims/cacheability-classification"; +import { preserveFullyBufferedBodyMetadata } from "vinext/shims/unified-request-context"; +import { validateCdnRequest } from "./cache-control.js"; +import { createWorkerPrerenderReadinessResponse } from "./worker-prerender-discovery.js"; + +// @ts-expect-error -- virtual module resolved by vinext at build time +import { registerConfiguredCacheAdapters } from "virtual:vinext-cache-adapters"; +// @ts-expect-error -- virtual module resolved by vinext at build time +import { registerConfiguredImageOptimizer } from "virtual:vinext-image-adapters"; +// Response-only generated entry: page/API modules and rendering, without the +// user middleware module or request-stage routing runtime. +// @ts-expect-error -- virtual module resolved by vinext at build time +import * as pagesEntry from "virtual:vinext-pages-response-entry"; +// @ts-expect-error -- virtual module resolved by vinext at build time +import __cacheabilityManifest from "virtual:vinext-cacheability-manifest"; + +type PagesWorkerEnv = Record; + +type PagesWorkerExecutionContext = ExecutionContextLike & { + cache?: unknown; +}; + +type PagesStreamedHtmlResponse = Response & { + __vinextStreamedHtmlResponse?: boolean; +}; + +/** Remove a body length inherited from response staging before transport drops the stream tag. */ +function stripStreamedHtmlContentLength(response: Response): Response { + if ( + (response as PagesStreamedHtmlResponse).__vinextStreamedHtmlResponse !== true || + !response.headers.has("Content-Length") + ) { + return response; + } + try { + response.headers.delete("Content-Length"); + return response; + } catch { + const headers = new Headers(response.headers); + headers.delete("Content-Length"); + const stripped = preserveFullyBufferedBodyMetadata( + response, + new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }), + ) as PagesStreamedHtmlResponse; + stripped.__vinextStreamedHtmlResponse = true; + return stripped; + } +} + +export async function renderPagesResponse( + request: Request, + env: PagesWorkerEnv | undefined, + platformCtx: PagesWorkerExecutionContext | undefined, + props: WorkerResponseStageProps, + stagedHeaders?: Headers, + dispatchRequestStage?: VinextRequestStageTransport, + dispatchOptions: VinextResponseStageDispatchOptions = { cache: "bypass" }, +): Promise { + if (!isPagesResponseStageProps(props)) { + return new Response("Invalid vinext Pages response stage", { status: 400 }); + } + if (props.buildId !== pagesEntry.buildId) { + return new Response("Vinext Pages response stage deployment mismatch", { status: 409 }); + } + if (props.requestHost !== new URL(request.url).host) { + return new Response("Invalid vinext Pages response stage", { status: 400 }); + } + + registerConfiguredImageOptimizer(env); + const renderHeaders = new Headers(stagedHeaders ?? props.stagedHeaders ?? []); + const initialResponseHeaders = new Headers(renderHeaders); + // Legacy/local callers may supply policy metadata without a staged snapshot. + // Keep that fallback while treating policy as admission provenance rather + // than the transport for the response state visible to Pages user code. + if (props.stagedHeaders === null && stagedHeaders === undefined) { + applyResponseStagePolicyHeaders(initialResponseHeaders, props.cacheability.policyHeaders); + applyResponseStagePolicyHeaders(renderHeaders, props.cacheability.policyHeaders); + } + let ctx = createWorkerRevalidationContext( + platformCtx, + (internalRequest) => { + if (!dispatchRequestStage) { + throw new Error("Pages response stage requires a request-stage dispatcher"); + } + return dispatchRequestStage(internalRequest); + }, + "node", + ); + if (props.kind === "pages-prerender-discovery") { + ctx = { ...ctx, isPrerenderPathDiscovery: true }; + } + const handle = async (cacheabilityContext: ExecutionContextLike): Promise => { + if (props.kind === "pages-prerender-discovery") { + const readinessResponse = createWorkerPrerenderReadinessResponse( + cacheabilityContext, + request, + ); + if (readinessResponse) { + return (await validateCdnRequest(request)) ?? readinessResponse; + } + const { handleAppPrerenderEndpoint } = await import("./app-prerender-endpoints.js"); + const response = await runWithExecutionContext(cacheabilityContext, () => + handleAppPrerenderEndpoint(request, { + isPrerenderEnabled: () => true, + loadPagesRoutes: async () => pagesEntry.pageRoutes, + pathname: new URL(request.url).pathname, + staticParamsMap: {}, + }), + ); + return response ?? new Response("This page could not be found", { status: 404 }); + } + if (props.kind === "pages-api") { + if (typeof pagesEntry.handleApiRoute !== "function") { + return new Response("This page could not be found", { status: 404 }); + } + return runWithExecutionContext(cacheabilityContext, () => { + beginRouteCacheability("pages-api", props.cacheability.resolvedRoutePathname); + return pagesEntry.handleApiRoute( + request, + props.apiUrl, + cacheabilityContext, + new URL(request.url).origin, + cacheabilityContext.hostRuntime ?? "node", + initialResponseHeaders, + ); + }); + } + if (typeof pagesEntry.renderPage !== "function") { + return new Response("This page could not be found", { status: 404 }); + } + return stripStreamedHtmlContentLength( + await pagesEntry.renderPage( + request, + props.resolvedUrl, + null, + cacheabilityContext, + renderHeaders, + props.renderOptions ?? undefined, + initialResponseHeaders, + ), + ); + }; + const response = await withResponseStageCacheability( + { + buildId: pagesEntry.buildId, + cache: props.kind === "pages-prerender-discovery" ? "bypass" : dispatchOptions.cache, + context: ctx, + policyHeaders: props.cacheability.policyHeaders, + policyHeadersAppliedBeforeRender: true, + probeMode: props.cacheability.probeMode, + rawManifest: __cacheabilityManifest, + registerCacheAdapters: () => registerConfiguredCacheAdapters(env), + request, + representation: props.cacheability.representation, + resolvedRoutePathname: props.cacheability.resolvedRoutePathname, + }, + handle, + ); + if (props.kind !== "pages-page") return response; + + const runtimeDataKind = pagesEntry.getRuntimePageDataKind(props.resolvedUrl, request); + const headers = new Headers(response.headers); + headers.set( + PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER, + runtimeDataKind === "server" || runtimeDataKind === "initial" ? "request-time" : "static", + ); + return preserveFullyBufferedBodyMetadata( + response, + new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }), + ); +} + +export function handleResponseStage( + request: Request, + env: PagesWorkerEnv | undefined, + ctx: PagesWorkerExecutionContext | undefined, + props: WorkerResponseStageProps, + dispatchRequestStage: VinextRequestStageTransport, + options: VinextResponseStageDispatchOptions, +): Promise { + return renderPagesResponse(request, env, ctx, props, undefined, dispatchRequestStage, options); +} diff --git a/packages/vinext/src/server/pages-response-stage.ts b/packages/vinext/src/server/pages-response-stage.ts new file mode 100644 index 000000000..e26ca33a3 --- /dev/null +++ b/packages/vinext/src/server/pages-response-stage.ts @@ -0,0 +1,101 @@ +import { PRERENDER_REVALIDATE_HEADER } from "../utils/protocol-headers.js"; +import type { VinextResponseStageDispatchOptions } from "./multi-stage.js"; +import { getScriptNonceFromHeaderSources } from "./csp.js"; +import { MIDDLEWARE_SET_COOKIE_HEADER } from "./headers.js"; +import type { PagesRouteDataKind } from "./pages-request-pipeline.js"; + +const PREVIEW_COOKIE_NAMES = new Set(["__prerender_bypass", "__next_preview_data"]); +const BYPASS_CACHE_CONTROL_DIRECTIVES = new Set(["no-cache", "no-store"]); + +export function hasPagesPreviewCookie(cookieHeader: string | null): boolean { + if (!cookieHeader) return false; + for (const pair of cookieHeader.split(";")) { + const separator = pair.indexOf("="); + const name = (separator === -1 ? pair : pair.slice(0, separator)).trim(); + if (PREVIEW_COOKIE_NAMES.has(name)) return true; + } + return false; +} + +function hasCacheBypassDirective( + cacheControl: string | null, + directives = BYPASS_CACHE_CONTROL_DIRECTIVES, +): boolean { + if (!cacheControl) return false; + return cacheControl.split(",").some((directive) => { + const separator = directive.indexOf("="); + const name = (separator === -1 ? directive : directive.slice(0, separator)) + .trim() + .toLowerCase(); + return directives.has(name); + }); +} + +function hasStagedCacheBypass(stagedHeaders: Headers | undefined): boolean { + // Middleware cookie overlays affect the same-request render and are not part + // of the shared artifact identity. Pure response headers (Cache-Control, + // Vary, Set-Cookie) remain outer composition and must not disable reuse of + // the underlying ISR artifact, matching Next's cache layering. + return stagedHeaders?.has(MIDDLEWARE_SET_COOKIE_HEADER) === true; +} + +export type PagesResponseStageDispatchOptions = { + authorizeOnDemandRevalidate?: (headerValue: string | null) => boolean; + /** A custom Document getInitialProps can observe the request/response pair. */ + hasRequestAwareDocument?: boolean; + request: Request; + /** Middleware changed the request headers visible to the response stage. */ + requestHeadersChanged?: boolean; + routeDataKind?: PagesRouteDataKind; + stagedHeaders?: Headers; +}; + +export type PagesResponseStageCacheDisposition = VinextResponseStageDispatchOptions["cache"]; + +/** + * Whether a Pages response may be delegated to a shared response stage. + * + * Next.js does not construct a static response-cache key in draft mode and + * handles authenticated on-demand revalidation outside ordinary cache reuse. + * Request cache bypass directives and non-idempotent methods likewise need to + * reach the renderer, while CSP nonces are embedded in the rendered body. + * + * @see https://github.com/vercel/next.js/blob/canary/packages/next/src/server/route-modules/pages/pages-handler.ts + * @see https://github.com/vercel/next.js/blob/canary/packages/next/src/server/base-server.ts + */ +export function shouldDispatchPagesResponseStage({ + authorizeOnDemandRevalidate, + hasRequestAwareDocument, + request, + requestHeadersChanged, + routeDataKind, + stagedHeaders, +}: PagesResponseStageDispatchOptions): boolean { + const method = request.method.toUpperCase(); + if (method !== "GET" && method !== "HEAD") return false; + + // Next.js supplies req/res to `_document.getInitialProps` for getStaticProps + // renders (`isAutoExport` is false). That makes the ostensibly static render + // request-aware, so it cannot sit below request-stage middleware/config state. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/render.tsx + if (hasRequestAwareDocument && routeDataKind === "static") return false; + + if (hasPagesPreviewCookie(request.headers.get("cookie"))) return false; + if (hasCacheBypassDirective(request.headers.get("cache-control"))) return false; + if (requestHeadersChanged) return false; + if (hasStagedCacheBypass(stagedHeaders)) return false; + + const revalidateHeader = request.headers.get(PRERENDER_REVALIDATE_HEADER); + if (authorizeOnDemandRevalidate?.(revalidateHeader) === true) return false; + + return getScriptNonceFromHeaderSources(request.headers, stagedHeaders) === undefined; +} + +/** Decide whether a host response-stage transport may share the rendered response. */ +export function getPagesResponseStageCacheDisposition( + options: PagesResponseStageDispatchOptions, +): PagesResponseStageCacheDisposition { + const revalidateHeader = options.request.headers.get(PRERENDER_REVALIDATE_HEADER); + if (options.authorizeOnDemandRevalidate?.(revalidateHeader) === true) return "bypass"; + return shouldDispatchPagesResponseStage(options) ? "shared" : "bypass"; +} diff --git a/packages/vinext/src/server/pages-revalidate.ts b/packages/vinext/src/server/pages-revalidate.ts index e6326a307..10b063437 100644 --- a/packages/vinext/src/server/pages-revalidate.ts +++ b/packages/vinext/src/server/pages-revalidate.ts @@ -23,8 +23,13 @@ import { getRevalidateSecret, isOnDemandRevalidateRequest, } from "./isr-cache.js"; -import { NEXTJS_CACHE_HEADER, VINEXT_REVALIDATE_HOST_HEADER } from "./headers.js"; +import { + NEXTJS_CACHE_HEADER, + VINEXT_REVALIDATED_CACHE_TAG_HEADER, + VINEXT_REVALIDATE_HOST_HEADER, +} from "./headers.js"; import { getRequestExecutionContext } from "vinext/shims/request-context"; +import { getCdnCacheAdapter } from "vinext/shims/cdn-cache"; import { normalizeDomainHostname } from "../utils/domain-locale.js"; export type RevalidateOptions = { @@ -102,6 +107,14 @@ export async function performOnDemandRevalidate( if (!ok) { throw new Error(`Failed to revalidate ${urlPath}: ${res.status}`); } + + // The regenerated Pages artifact has already been written to the origin + // cache at this point. Propagate the same implicit path invalidation to a + // fronting CDN so its next ordinary request cannot replay the old response. + // Do not invalidate the data-cache tag here: that would delete the fresh + // artifact that the authenticated HEAD request just produced. + const regeneratedTag = res.headers.get(VINEXT_REVALIDATED_CACHE_TAG_HEADER); + if (regeneratedTag) await getCdnCacheAdapter().revalidateTag(regeneratedTag); } function readSourceHeader(source: IncomingMessage | Headers, key: string): string | undefined { diff --git a/packages/vinext/src/server/pages-route-data-kind.ts b/packages/vinext/src/server/pages-route-data-kind.ts new file mode 100644 index 000000000..9c57484bd --- /dev/null +++ b/packages/vinext/src/server/pages-route-data-kind.ts @@ -0,0 +1,29 @@ +export type RuntimePagesDataKind = "initial" | "none" | "server" | "static"; + +type PagesModule = { + default?: { getInitialProps?: unknown }; + getServerSideProps?: unknown; + getStaticProps?: unknown; +}; + +type AppComponent = { + getInitialProps?: unknown; + origGetInitialProps?: unknown; +} | null; + +/** Classify loaded Pages modules after HOCs and re-exports have been evaluated. */ +export function getRuntimePagesDataKind( + pageModule: PagesModule, + appComponent: AppComponent, +): RuntimePagesDataKind { + if (typeof pageModule.getStaticProps === "function") return "static"; + if (typeof pageModule.getServerSideProps === "function") return "server"; + if (typeof pageModule.default?.getInitialProps === "function") return "initial"; + if ( + typeof appComponent?.getInitialProps === "function" && + appComponent.getInitialProps !== appComponent.origGetInitialProps + ) { + return "initial"; + } + return "none"; +} diff --git a/packages/vinext/src/server/pages-router-entry.ts b/packages/vinext/src/server/pages-router-entry.ts index 807bd360a..acffcd235 100644 --- a/packages/vinext/src/server/pages-router-entry.ts +++ b/packages/vinext/src/server/pages-router-entry.ts @@ -1,342 +1,109 @@ -/** - * Router-specific Cloudflare Worker entry point for vinext Pages Router. - * - * New projects should usually use the router-selected entry in wrangler.jsonc: - * "main": "vinext/server/fetch-handler" - * - * This Pages Router entry remains available for existing configs and for custom - * workers that need to opt into the Pages Router handler explicitly: - * "main": "vinext/server/pages-router-entry" - * - * Or import and delegate to it from a custom worker: - * import handler from "vinext/server/pages-router-entry"; - * return handler.fetch(request, env, ctx); - */ +/** Router-specific single-entry Worker facade for vinext Pages Router. */ import { - fetchWorkerFilesystemRoute, - runPagesRequest, - wrapMiddlewareWithBasePath, -} from "./pages-request-pipeline.js"; -import type { PagesPipelineDeps } from "./pages-request-pipeline.js"; -import { - DEFAULT_DEVICE_SIZES, - DEFAULT_IMAGE_SIZES, - handleConfiguredImageOptimization, - isImageOptimizationPath, -} from "./image-optimization.js"; -import type { ImageConfig } from "./image-optimization.js"; -import { - cloneRequestWithHeaders, - cloneRequestWithUrl, - filterInternalHeaders, - isOpenRedirectShaped, -} from "./request-pipeline.js"; -import { notFoundStaticAssetResponse } from "./http-error-responses.js"; -import { finalizeMissingStaticAssetResponse } from "./worker-utils.js"; -import { assetPrefixPathname, isNextStaticPath } from "../utils/asset-prefix.js"; -import { hasBasePath, stripBasePath } from "../utils/base-path.js"; -import { createWorkerRevalidationContext } from "./worker-revalidation-context.js"; -import { - VINEXT_CACHEABILITY_PROBE_HEADER, - VINEXT_CACHEABILITY_PROBE_QUERY_PARAM, - VINEXT_PRERENDER_SECRET_HEADER, - VINEXT_REVALIDATE_HOST_HEADER, -} from "./headers.js"; -import { runWithExecutionContext, type ExecutionContextLike } from "vinext/shims/request-context"; + handleRequestStageLocally, + pagesRequestStageBuildId, + pagesRequestStagePrerenderSecret, + type PagesWorkerEnv, + type PagesWorkerExecutionContext, +} from "./pages-request-stage-entry.js"; +import { renderPagesResponse } from "./pages-response-stage-entry.js"; import { getCdnCacheAdapter } from "vinext/shims/cdn-cache"; -import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; +import { createWorkerRevalidationContext } from "./worker-revalidation-context.js"; import { createWorkerPrerenderDiscoveryContext, createWorkerPrerenderReadinessResponse, - isWorkerPrerenderDiscoveryPath, } from "./worker-prerender-discovery.js"; +import { + VINEXT_CACHEABILITY_PROBE_HEADER, + VINEXT_CACHEABILITY_PROBE_QUERY_PARAM, +} from "./headers.js"; +import { cloneRequestWithHeaders, cloneRequestWithUrl } from "./request-pipeline.js"; +import { validateCdnRequest } from "./cache-control.js"; // @ts-expect-error -- virtual module resolved by vinext at build time import { registerConfiguredCacheAdapters } from "virtual:vinext-cache-adapters"; -import { applyCdnResponseIdentityHeaders, validateCdnRequest } from "./cache-control.js"; -// @ts-expect-error -- virtual module resolved by vinext at build time -import { registerConfiguredImageOptimizer } from "virtual:vinext-image-adapters"; -// @ts-expect-error -- virtual module resolved by vinext at build time -import * as pagesEntry from "virtual:vinext-server-entry"; // @ts-expect-error -- virtual module resolved by vinext at build time import __cacheabilityManifest from "virtual:vinext-cacheability-manifest"; -type AssetFetcher = { - fetch(request: Request): Promise | Response; -}; - -type PagesWorkerEnv = { - ASSETS?: AssetFetcher; -} & Record; - -type PagesWorkerExecutionContext = { - waitUntil?(promise: Promise): void; - passThroughOnException?(): void; - cache?: unknown; -}; - -const { - authorizeOnDemandRevalidate, - handleApiRoute, - hasMiddleware, - matchApiRoute, - matchPageRoute, - normalizeDataRequest, - publicFiles, - renderPage, - runMiddleware, - vinextConfig, -} = pagesEntry; - -const basePath: string = vinextConfig?.basePath ?? ""; -const assetPathPrefix: string = assetPrefixPathname(vinextConfig?.assetPrefix ?? ""); -const trailingSlash: boolean = vinextConfig?.trailingSlash ?? false; -const i18nConfig = vinextConfig?.i18n ?? null; -const configRedirects = vinextConfig?.redirects ?? []; -const configRewrites = vinextConfig?.rewrites ?? { - beforeFiles: [], - afterFiles: [], - fallback: [], -}; -const configHeaders = vinextConfig?.headers ?? []; -const imageConfig: ImageConfig | undefined = vinextConfig?.images - ? { - qualities: vinextConfig.images.qualities, - dangerouslyAllowSVG: vinextConfig.images.dangerouslyAllowSVG, - dangerouslyAllowLocalIP: vinextConfig.images.dangerouslyAllowLocalIP, - contentDispositionType: vinextConfig.images.contentDispositionType, - contentSecurityPolicy: vinextConfig.images.contentSecurityPolicy, - } - : undefined; - -export default { - async fetch( - request: Request, - env?: PagesWorkerEnv, - ctx?: PagesWorkerExecutionContext, - ): Promise { - return applyCdnResponseIdentityHeaders(await handleRequest(request, env, ctx), request); - }, -}; - -async function handleRequest( +async function handleSingleStageRequest( request: Request, env: PagesWorkerEnv | undefined, - platformCtx: PagesWorkerExecutionContext | ExecutionContextLike | undefined, + platformCtx: PagesWorkerExecutionContext | undefined, ): Promise { - const requestCtx = createWorkerRevalidationContext(platformCtx, (internalRequest, internalCtx) => - handleRequest(internalRequest, env, internalCtx), + const ctxWithRevalidation = createWorkerRevalidationContext( + platformCtx, + (internalRequest, internalCtx) => + handleSingleStageRequest(internalRequest, env, internalCtx as PagesWorkerExecutionContext), + "worker", ); - // Registration must precede admission setup: the active adapter declares - // whether public response headers require a completed-response proof even - // when this build has no embedded two-stage manifest. registerConfiguredCacheAdapters(env); - const cdnCacheAdapter = getCdnCacheAdapter(); - let ctx = createWorkerPrerenderDiscoveryContext(requestCtx, request, pagesEntry.prerenderSecret); + const adapter = getCdnCacheAdapter(); + let ctx = createWorkerPrerenderDiscoveryContext( + ctxWithRevalidation, + request, + pagesRequestStagePrerenderSecret, + ); const readinessResponse = createWorkerPrerenderReadinessResponse(ctx, request); if (readinessResponse) { return (await validateCdnRequest(request)) ?? readinessResponse; } - let finalizeCacheabilityResponse: - | ((response: Response, ctx: ExecutionContextLike) => Promise) - | undefined; + + let finalize: ((response: Response, context: typeof ctx) => Promise) | undefined; if (request.headers.has(VINEXT_CACHEABILITY_PROBE_HEADER)) { const cacheability = await import("./cacheability-request.js"); const probeContext = cacheability.createWorkerCacheabilityContext( ctx, request, - pagesEntry.prerenderSecret, - cdnCacheAdapter.responseVary, + pagesRequestStagePrerenderSecret, + adapter.responseVary, ); if (probeContext !== ctx) { ctx = probeContext; - finalizeCacheabilityResponse = cacheability.finalizeWorkerCacheabilityResponse; + finalize = cacheability.finalizeWorkerCacheabilityResponse; + const probeUrl = new URL(request.url); - if (probeUrl.searchParams.has(VINEXT_CACHEABILITY_PROBE_QUERY_PARAM)) { - probeUrl.searchParams.delete(VINEXT_CACHEABILITY_PROBE_QUERY_PARAM); - request = new Request(probeUrl, request); - } + probeUrl.searchParams.delete(VINEXT_CACHEABILITY_PROBE_QUERY_PARAM); + request = cloneRequestWithUrl(request, probeUrl.toString()); + const headers = new Headers(request.headers); + headers.delete(VINEXT_CACHEABILITY_PROBE_HEADER); + request = cloneRequestWithHeaders(request, headers); } } - const requiresCompletedResponseAdmission = - cdnCacheAdapter.requiresCompletedResponseAdmission === true; - if ( - !finalizeCacheabilityResponse && - (__cacheabilityManifest || requiresCompletedResponseAdmission) - ) { + + const requiresCompletedResponseAdmission = adapter.requiresCompletedResponseAdmission === true; + if (!finalize && (__cacheabilityManifest || requiresCompletedResponseAdmission)) { const cacheability = await import("./cacheability-request.js"); const admissionContext = cacheability.createWorkerCacheabilityAdmissionContext( ctx, request, __cacheabilityManifest, - pagesEntry.buildId, + pagesRequestStageBuildId, requiresCompletedResponseAdmission, - cdnCacheAdapter.responseVary, + adapter.responseVary, ); if (admissionContext !== ctx) { ctx = admissionContext; - finalizeCacheabilityResponse = cacheability.finalizeWorkerCacheabilityResponse; + finalize = cacheability.finalizeWorkerCacheabilityResponse; } } - const finalize = (response: Response): Promise => - finalizeCacheabilityResponse - ? finalizeCacheabilityResponse(response, ctx) - : Promise.resolve(response); - - // Cache adapters were registered above because admission depends on them. - // Register the image adapter before request handling begins. - registerConfiguredImageOptimizer(env); - - try { - const cdnValidationResponse = await validateCdnRequest(request); - if (cdnValidationResponse) return finalize(cdnValidationResponse); - - const url = new URL(request.url); - let pathname = url.pathname; - - if (ctx.isPrerenderPathDiscovery && isWorkerPrerenderDiscoveryPath(pathname)) { - // This App Router runtime is only needed by authenticated staged discovery. - // Keep it out of the ordinary Pages Router startup and request path. - const { handleAppPrerenderEndpoint } = await import("./app-prerender-endpoints.js"); - const response = await runWithExecutionContext(ctx, () => - handleAppPrerenderEndpoint(request, { - isPrerenderEnabled: () => true, - loadPagesRoutes: async () => pagesEntry.pageRoutes, - pathname, - staticParamsMap: {}, - }), - ); - if (response) return finalize(response); - } - - // Block protocol-relative URL open redirects in all shapes: - // literal //evil.com, /\\evil.com - // encoded /%5Cevil.com, /%2F/evil.com - // Browsers normalize backslash to forward slash, and percent-decode - // Location headers, so encoded variants must be rejected before any - // downstream redirect can echo them. - if (isOpenRedirectShaped(pathname)) { - return finalize(new Response("This page could not be found", { status: 404 })); - } - try { - normalizePathnameForRouteMatchStrict(pathname); - } catch { - return finalize(new Response("Bad Request", { status: 400 })); - } - - // Valid assets are served by Cloudflare's ASSETS binding before the worker - // is invoked. Missing asset-shaped requests still need to reach middleware - // so it can rewrite/respond; a final 404 is converted back below. - const missingBuildAsset = isNextStaticPath(pathname, basePath, assetPathPrefix); - - // Strip internal headers from inbound requests so callers cannot forge - // framework state. Request.headers is immutable in Workers. - const filteredHeaders = ctx.isInternalPagesRevalidation - ? new Headers(request.headers) - : filterInternalHeaders(request.headers); - filteredHeaders.delete(VINEXT_PRERENDER_SECRET_HEADER); - filteredHeaders.delete(VINEXT_REVALIDATE_HOST_HEADER); - request = cloneRequestWithHeaders(request, filteredHeaders); - - // Track basePath presence on the original request so matcher gating can - // distinguish requests inside basePath from requests outside it. - const hadBasePath = !basePath || hasBasePath(pathname, basePath); - { - const stripped = stripBasePath(pathname, basePath); - if (stripped !== pathname) { - const strippedUrl = new URL(request.url); - strippedUrl.pathname = stripped; - request = cloneRequestWithUrl(request, strippedUrl.toString()); - pathname = stripped; - } - } - - const middlewareRequest = request; - const dataNorm = normalizeDataRequest(request); - if (dataNorm.notFoundResponse && !vinextConfig?.skipProxyUrlNormalize) { - return finalize(dataNorm.notFoundResponse); - } - const isDataReq = dataNorm.isDataReq; - if (isDataReq && dataNorm.normalizedPathname) { - request = dataNorm.request; - pathname = dataNorm.normalizedPathname; - } - - const deps: PagesPipelineDeps = { - basePath, - trailingSlash, - i18nConfig, - configRedirects, - configRewrites, - configHeaders, - hadBasePath, - isDataReq, - isDataRequest: isDataReq, - hasMiddleware, - ctx, - middlewareRequest: - isDataReq && vinextConfig?.skipProxyUrlNormalize ? middlewareRequest : undefined, - dataNotFoundResponse: vinextConfig?.skipProxyUrlNormalize ? dataNorm.notFoundResponse : null, - authorizeOnDemandRevalidate: - typeof authorizeOnDemandRevalidate === "function" ? authorizeOnDemandRevalidate : undefined, - matchApiRoute: typeof matchApiRoute === "function" ? matchApiRoute : null, - matchPageRoute: typeof matchPageRoute === "function" ? matchPageRoute : null, - runMiddleware: - typeof runMiddleware === "function" - ? wrapMiddlewareWithBasePath(runMiddleware, basePath, hadBasePath) - : null, - renderPage: - typeof renderPage === "function" - ? (req, resolvedUrl, options, stagedHeaders) => - renderPage(req, resolvedUrl, null, ctx, stagedHeaders, options) - : null, - handleApi: - typeof handleApiRoute === "function" - ? (req, apiUrl) => handleApiRoute(req, apiUrl, ctx, new URL(req.url).origin, "worker") - : null, - serveFilesystemRoute: async (requestPathname, _stagedHeaders, phase, resolvedUrl) => { - if (!env?.ASSETS) return false; - if (isImageOptimizationPath(requestPathname)) { - const imageUrl = new URL(resolvedUrl, request.url); - const imageRequest = new Request(imageUrl, request); - const allowedWidths = [ - ...(vinextConfig?.images?.deviceSizes ?? DEFAULT_DEVICE_SIZES), - ...(vinextConfig?.images?.imageSizes ?? DEFAULT_IMAGE_SIZES), - ]; - return handleConfiguredImageOptimization( - imageRequest, - (assetPath) => - Promise.resolve(env.ASSETS!.fetch(new Request(new URL(assetPath, request.url)))), - allowedWidths, - imageConfig, - ); - } - return fetchWorkerFilesystemRoute( - request, - requestPathname, - phase, - (assetRequest) => Promise.resolve(env.ASSETS!.fetch(assetRequest)), - publicFiles, - missingBuildAsset, - ); - }, - }; - - const result = await runPagesRequest(request, deps); - if (result.type === "response") { - return finalize(finalizeMissingStaticAssetResponse(result.response, missingBuildAsset)); - } - // Should not reach here for a production Worker because all callbacks are - // supplied by virtual:vinext-server-entry. - return finalize( - missingBuildAsset - ? notFoundStaticAssetResponse() - : new Response("This page could not be found", { status: 404 }), - ); - } catch (error) { - console.error("[vinext] Worker error:", error); - return finalize(new Response("Internal Server Error", { status: 500 })); - } + const response = await handleRequestStageLocally( + request, + env, + ctx as PagesWorkerExecutionContext, + (stageRequest, stageEnv, stageCtx, props) => + renderPagesResponse(stageRequest, stageEnv, stageCtx, props), + ); + return finalize ? finalize(response, ctx) : response; } + +export default { + fetch( + request: Request, + env?: PagesWorkerEnv, + ctx?: PagesWorkerExecutionContext, + ): Promise { + return handleSingleStageRequest(request, env, ctx); + }, +}; diff --git a/packages/vinext/src/server/pregenerated-concrete-paths.ts b/packages/vinext/src/server/pregenerated-concrete-paths.ts index 587a692fa..b11a148bd 100644 --- a/packages/vinext/src/server/pregenerated-concrete-paths.ts +++ b/packages/vinext/src/server/pregenerated-concrete-paths.ts @@ -5,6 +5,9 @@ declare global { var __VINEXT_PREGENERATED_CONCRETE_PATHS: unknown; } +/** Stable post-build module populated after prerendering completes. */ +export const PREGENERATED_CONCRETE_PATHS_MODULE = "__vinext_pregenerated_concrete_paths.js"; + export function normalizePregeneratedPathname(pathname: string): string { return normalizePath(normalizePathnameForRouteMatch(pathname)); } diff --git a/packages/vinext/src/server/prerender-route-params.ts b/packages/vinext/src/server/prerender-route-params.ts index 4ef7d8e09..ab4b4867c 100644 --- a/packages/vinext/src/server/prerender-route-params.ts +++ b/packages/vinext/src/server/prerender-route-params.ts @@ -1,4 +1,8 @@ -import { VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, VINEXT_PRERENDER_SECRET_HEADER } from "./headers.js"; +import { + VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, + VINEXT_PRERENDER_SECRET_HEADER, + VINEXT_PRERENDER_SPECULATIVE_HEADER, +} from "./headers.js"; import { isUnknownRecord } from "../utils/record.js"; export type PrerenderRouteParams = Record; @@ -9,6 +13,12 @@ export type PrerenderRouteParamsPayload = { routePattern: string; }; +/** Prerender-only request state authenticated at the public request boundary. */ +export type TrustedPrerenderState = { + routeParams: PrerenderRouteParamsPayload | null; + speculative: boolean; +}; + type PrerenderRouteParamsRouteMatch = | { kind: "exact"; @@ -55,6 +65,17 @@ function isPrerenderRouteParamsPayload(value: unknown): value is PrerenderRouteP ); } +export function isTrustedPrerenderState(value: unknown): value is TrustedPrerenderState { + if (!isUnknownRecord(value)) return false; + const keys = Object.keys(value); + return ( + keys.length === 2 && + keys.every((key) => key === "routeParams" || key === "speculative") && + (value.routeParams === null || isPrerenderRouteParamsPayload(value.routeParams)) && + typeof value.speculative === "boolean" + ); +} + // A payload with no dynamic params serializes to `null`, which is // indistinguishable from an absent header on the read side. This is intentional: // the only producer, `encodePrerenderRouteParams`, already returns `null` for @@ -95,6 +116,20 @@ export function readTrustedPrerenderRouteParamsFromHeaders( return params; } +/** Authenticate prerender params and mode once before crossing a stage transport. */ +export function readTrustedPrerenderStateFromHeaders( + headers: Headers, + expectedSecret: string, +): TrustedPrerenderState | null { + if (process.env.VINEXT_PRERENDER !== "1") return null; + const secret = headers.get(VINEXT_PRERENDER_SECRET_HEADER); + if (!expectedSecret || secret === null || secret !== expectedSecret) return null; + return { + routeParams: readTrustedPrerenderRouteParamsFromHeaders(headers, expectedSecret), + speculative: headers.get(VINEXT_PRERENDER_SPECULATIVE_HEADER) === "1", + }; +} + // Convenience wrapper for reads that happen AFTER the prerender secret has // already been verified at the trust boundary. The only entry point that // receives raw external input, `prod-server`'s `nodeToWebRequest`, calls diff --git a/packages/vinext/src/server/request-pipeline.ts b/packages/vinext/src/server/request-pipeline.ts index fd53fccc3..3ffb278d7 100644 --- a/packages/vinext/src/server/request-pipeline.ts +++ b/packages/vinext/src/server/request-pipeline.ts @@ -525,6 +525,11 @@ function getRequestCf(request: Request): unknown { * must restore it explicitly. */ export function attachRequestCfMetadata(target: Request, source: Request): Request { + const ownDescriptor = Object.getOwnPropertyDescriptor(source, "cf"); + if (ownDescriptor) { + Object.defineProperty(target, "cf", ownDescriptor); + return target; + } const cf = getRequestCf(source); if (cf !== undefined) { Object.defineProperty(target, "cf", { diff --git a/packages/vinext/src/server/request-stage.ts b/packages/vinext/src/server/request-stage.ts new file mode 100644 index 000000000..49f14d42b --- /dev/null +++ b/packages/vinext/src/server/request-stage.ts @@ -0,0 +1,11 @@ +// oxlint-disable-next-line typescript/triple-slash-reference -- loads virtual-module types without a runtime import +/// + +import type { VinextRequestStageModule } from "./multi-stage.js"; + +/** Lazily load the router-specific request stage selected by vinext. */ +export function loadVinextRequestStage(): Promise< + VinextRequestStageModule +> { + return import("virtual:vinext-request-stage"); +} diff --git a/packages/vinext/src/server/response-stage-cacheability.ts b/packages/vinext/src/server/response-stage-cacheability.ts new file mode 100644 index 000000000..3dc208f8f --- /dev/null +++ b/packages/vinext/src/server/response-stage-cacheability.ts @@ -0,0 +1,84 @@ +import type { ExecutionContextLike } from "vinext/shims/request-context"; +import { getCdnCacheAdapter } from "vinext/shims/cdn-cache"; +import type { VinextResponseStageDispatchOptions } from "./multi-stage.js"; +import type { WorkerCacheabilityProbeMode } from "./cacheability-request.js"; +import type { CacheabilityRepresentation } from "./cacheability-manifest.js"; + +export type ResponseStageCacheabilityOptions = { + buildId: string | null | undefined; + cache: VinextResponseStageDispatchOptions["cache"]; + context: ExecutionContextLike; + probeMode?: WorkerCacheabilityProbeMode | null; + policyHeaders?: ReadonlyArray | null; + /** The renderer receives policy before user Pages code and applies it itself. */ + policyHeadersAppliedBeforeRender?: boolean; + rawManifest: string | null | undefined; + /** The trusted route target after request-stage rewrites. */ + resolvedRoutePathname?: string; + /** Trusted representation retained when request-stage normalization changes the URL shape. */ + representation?: CacheabilityRepresentation; + /** Generated adapter registration, deferred until the response stage executes. */ + registerCacheAdapters(): void; + request: Request; +}; + +/** + * Run a response-stage render behind completed-response cache admission. + * + * Adapter registration and cacheability state live here so a shared transport + * hit can avoid loading the response stage and its application graph entirely. + */ +export async function withResponseStageCacheability( + options: ResponseStageCacheabilityOptions, + render: (context: ExecutionContextLike) => Promise, +): Promise { + options.registerCacheAdapters(); + const adapter = getCdnCacheAdapter(); + + let context = options.context; + if (options.probeMode) { + const cacheability = await import("./cacheability-request.js"); + context = cacheability.createWorkerCacheabilityProbeContext( + context, + options.probeMode, + adapter.responseVary, + options.resolvedRoutePathname, + ); + if (options.policyHeadersAppliedBeforeRender) { + cacheability.recordResponseStageCachePolicy(context, options.policyHeaders); + } + const rendered = await render(context); + const response = options.policyHeadersAppliedBeforeRender + ? rendered + : cacheability.applyResponseStageCachePolicy(rendered, context, options.policyHeaders); + return cacheability.finalizeWorkerCacheabilityResponse(response, context); + } + + const requiresAdmission = + options.cache === "shared" && + (options.rawManifest != null || adapter.requiresCompletedResponseAdmission === true); + if (requiresAdmission) { + const cacheability = await import("./cacheability-request.js"); + context = cacheability.createWorkerCacheabilityAdmissionContext( + context, + options.request, + options.rawManifest, + options.buildId, + adapter.requiresCompletedResponseAdmission === true, + adapter.responseVary, + options.resolvedRoutePathname, + options.representation, + { applyCompletedResponsePolicy: true }, + ); + if (options.policyHeadersAppliedBeforeRender) { + cacheability.recordResponseStageCachePolicy(context, options.policyHeaders); + } + const rendered = await render(context); + const response = options.policyHeadersAppliedBeforeRender + ? rendered + : cacheability.applyResponseStageCachePolicy(rendered, context, options.policyHeaders); + return cacheability.finalizeWorkerCacheabilityResponse(response, context); + } + + return render(context); +} diff --git a/packages/vinext/src/server/response-stage-policy.ts b/packages/vinext/src/server/response-stage-policy.ts new file mode 100644 index 000000000..b83dd2bdd --- /dev/null +++ b/packages/vinext/src/server/response-stage-policy.ts @@ -0,0 +1,61 @@ +import { mergeVaryHeader } from "./middleware-response-headers.js"; +import { preserveFullyBufferedBodyMetadata } from "vinext/shims/unified-request-context"; +import { + PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER, + type PagesResponseStagePolicyOwner, +} from "./worker-stages.js"; + +/** Apply request-stage cache policy before response-stage admission. */ +export function applyResponseStagePolicyHeaders( + headers: Headers, + policyHeaders: ReadonlyArray | null | undefined, +): void { + for (const [name, value] of policyHeaders ?? []) { + if (name.toLowerCase() === "vary") { + mergeVaryHeader(headers, value); + } else { + headers.set(name, value); + } + } +} + +/** Replace transported Vary fields with the effective request-stage value. */ +export function withResponseStageVary( + policyHeaders: ReadonlyArray | null | undefined, + vary: string | null | undefined, +): Array<[string, string]> | null { + const result: Array<[string, string]> = []; + const varyHeaders = new Headers(); + for (const [name, value] of policyHeaders ?? []) { + if (name.toLowerCase() === "vary") mergeVaryHeader(varyHeaders, value); + else result.push([name, value]); + } + if (vary) mergeVaryHeader(varyHeaders, vary); + const effectiveVary = varyHeaders.get("Vary"); + if (effectiveVary) result.push(["Vary", effectiveVary]); + return result.length > 0 ? result : null; +} + +/** Consume trusted Pages response-stage policy ownership before public egress. */ +export function consumePagesResponseStagePolicyOwner(response: Response): { + owner: PagesResponseStagePolicyOwner | null; + response: Response; +} { + const value = response.headers.get(PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER); + const owner = value === "request-time" || value === "static" ? value : null; + if (value === null) return { owner, response }; + + const headers = new Headers(response.headers); + headers.delete(PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER); + return { + owner, + response: preserveFullyBufferedBodyMetadata( + response, + new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }), + ), + }; +} diff --git a/packages/vinext/src/server/response-stage.ts b/packages/vinext/src/server/response-stage.ts new file mode 100644 index 000000000..6e0ee1b0b --- /dev/null +++ b/packages/vinext/src/server/response-stage.ts @@ -0,0 +1,11 @@ +// oxlint-disable-next-line typescript/triple-slash-reference -- loads virtual-module types without a runtime import +/// + +import type { VinextResponseStageModule } from "./multi-stage.js"; + +/** Lazily load the router-specific response stage selected by vinext. */ +export function loadVinextResponseStage(): Promise< + VinextResponseStageModule +> { + return import("virtual:vinext-response-stage"); +} diff --git a/packages/vinext/src/server/revalidation-host.ts b/packages/vinext/src/server/revalidation-host.ts index a08d71542..a99b1f873 100644 --- a/packages/vinext/src/server/revalidation-host.ts +++ b/packages/vinext/src/server/revalidation-host.ts @@ -1,7 +1,10 @@ import type { NextI18nConfig } from "../config/next-config.js"; import { normalizeDomainHostname } from "../utils/domain-locale.js"; import { VINEXT_REVALIDATE_HOST_HEADER } from "./headers.js"; -import { isOnDemandRevalidateRequest, PRERENDER_REVALIDATE_HEADER } from "./isr-cache.js"; +import { + isOnDemandRevalidateRequest, + PRERENDER_REVALIDATE_HEADER, +} from "./revalidation-request.js"; /** * Read the logical request hostname carried by a server-pinned revalidation diff --git a/packages/vinext/src/server/revalidation-request.ts b/packages/vinext/src/server/revalidation-request.ts new file mode 100644 index 000000000..dfbf16a77 --- /dev/null +++ b/packages/vinext/src/server/revalidation-request.ts @@ -0,0 +1,47 @@ +import { PRERENDER_REVALIDATE_HEADER } from "../utils/protocol-headers.js"; + +export { PRERENDER_REVALIDATE_HEADER }; + +/** + * Request-only on-demand revalidation authentication. + * + * Keep this module independent from ISR storage and renderer code so a routing + * stage can authorize a reverse revalidation call without evaluating React. + */ +const DEV_REVALIDATE_SECRET_KEY = Symbol.for("vinext.isrCache.devRevalidateSecret"); + +function getRevalidateSecret(): string { + const baked = process.env.__VINEXT_REVALIDATE_SECRET; + if (baked) return baked; + + const globals = globalThis as unknown as Record; + const existing = globals[DEV_REVALIDATE_SECRET_KEY]; + if (typeof existing === "string") return existing; + + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + const secret = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + globals[DEV_REVALIDATE_SECRET_KEY] = secret; + return secret; +} + +function safeEqual(first: string, second: string): boolean { + if (first.length !== second.length) return false; + let mismatch = 0; + for (let index = 0; index < first.length; index++) { + mismatch |= first.charCodeAt(index) ^ second.charCodeAt(index); + } + return mismatch === 0; +} + +function isRevalidateSecret(value: string | null | undefined): boolean { + if (typeof value !== "string" || value.length === 0) return false; + return safeEqual(value, getRevalidateSecret()); +} + +/** Match Next.js: the header value must equal the preview/revalidation secret. */ +export function isOnDemandRevalidateRequest( + headerValue: string | string[] | null | undefined, +): boolean { + return typeof headerValue === "string" && isRevalidateSecret(headerValue); +} diff --git a/packages/vinext/src/server/static-file-signal.ts b/packages/vinext/src/server/static-file-signal.ts index 27e04f5fd..5d514b5ae 100644 --- a/packages/vinext/src/server/static-file-signal.ts +++ b/packages/vinext/src/server/static-file-signal.ts @@ -1,4 +1,11 @@ const STATIC_FILE_SIGNAL = Symbol.for("vinext.static-file-signal"); +const STATIC_FILE_SIGNAL_TRANSPORT_HEADER = "x-vinext-stage-static-file"; +const STATIC_FILE_REPRESENTATION_HEADERS = [ + "content-encoding", + "content-length", + "content-type", + "transfer-encoding", +] as const; export type StaticFileSignalContext = { headers: Headers | null; @@ -13,12 +20,37 @@ export type StaticFileSignalContext = { * remain ordinary metadata and cannot alter framework control flow. */ function markStaticFileSignal(response: Response, pathname: string): Response { + return markEncodedStaticFileSignal(response, encodeURIComponent(pathname)); +} + +function markEncodedStaticFileSignal(response: Response, encodedPathname: string): Response { Object.defineProperty(response, STATIC_FILE_SIGNAL, { - value: encodeURIComponent(pathname), + value: encodedPathname, }); return response; } +function withoutTransportHeader(response: Response): Response { + if (!response.headers.has(STATIC_FILE_SIGNAL_TRANSPORT_HEADER)) return response; + const headers = new Headers(response.headers); + headers.delete(STATIC_FILE_SIGNAL_TRANSPORT_HEADER); + if (response.status < 200 || response.status > 599) { + // Non-standard responses such as Worker WebSocket upgrades cannot be + // reconstructed with the standard Response constructor. They can never be + // static-file signals, so leave the untrusted header inert. + return response; + } + const body = + response.status === 204 || response.status === 205 || response.status === 304 + ? null + : response.body; + return new Response(body, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + /** Create the only response shape that host runtimes may resolve as an asset. */ export function createStaticFileSignal( pathname: string, @@ -49,3 +81,32 @@ export function readStaticFileSignal(response: Response): string | null { const signal = Reflect.get(response, STATIC_FILE_SIGNAL); return typeof signal === "string" ? signal : null; } + +/** Encode a framework-authenticated signal for a standards-only stage transport. */ +export function serializeStaticFileSignalForTransport(response: Response, token: string): Response { + const signal = readStaticFileSignal(response); + if (signal === null) return response; + const headers = new Headers(response.headers); + for (const name of STATIC_FILE_REPRESENTATION_HEADERS) headers.delete(name); + headers.set(STATIC_FILE_SIGNAL_TRANSPORT_HEADER, `${token}:${signal}`); + return new Response(null, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + +/** Restore and consume a signal returned by the trusted response-stage wrapper. */ +export function restoreStaticFileSignalFromTransport(response: Response, token: string): Response { + const transported = response.headers.get(STATIC_FILE_SIGNAL_TRANSPORT_HEADER); + const cleaned = withoutTransportHeader(response); + const prefix = `${token}:`; + if (transported === null || !transported.startsWith(prefix)) return cleaned; + const encodedPathname = transported.slice(prefix.length); + try { + if (!decodeURIComponent(encodedPathname).startsWith("/")) return cleaned; + } catch { + return cleaned; + } + return markEncodedStaticFileSignal(cleaned, encodedPathname); +} diff --git a/packages/vinext/src/server/worker-revalidation-context.ts b/packages/vinext/src/server/worker-revalidation-context.ts index 1bdfc953a..13dbc7e33 100644 --- a/packages/vinext/src/server/worker-revalidation-context.ts +++ b/packages/vinext/src/server/worker-revalidation-context.ts @@ -6,6 +6,7 @@ function deriveExecutionContext( base: PlatformExecutionContext | undefined, dispatchPagesRevalidate: (request: Request) => Promise, isInternalPagesRevalidation: boolean, + defaultHostRuntime: "node" | "worker", ): ExecutionContextLike { return { waitUntil(promise) { @@ -22,7 +23,7 @@ function deriveExecutionContext( }, } : {}), - hostRuntime: "worker", + hostRuntime: base?.hostRuntime ?? defaultHostRuntime, ...(base?.cache === undefined ? {} : { cache: base.cache }), ...(base?.trustedRevalidateOrigin === undefined ? {} @@ -41,13 +42,17 @@ function deriveExecutionContext( export function createWorkerRevalidationContext( base: PlatformExecutionContext | undefined, handleInternalRequest: (request: Request, ctx: ExecutionContextLike) => Promise, + defaultHostRuntime: "node" | "worker" = "worker", ): ExecutionContextLike { if (typeof base?.dispatchPagesRevalidate === "function") { return base as ExecutionContextLike; } const dispatchPagesRevalidate = (request: Request): Promise => - handleInternalRequest(request, deriveExecutionContext(base, dispatchPagesRevalidate, true)); + handleInternalRequest( + request, + deriveExecutionContext(base, dispatchPagesRevalidate, true, defaultHostRuntime), + ); - return deriveExecutionContext(base, dispatchPagesRevalidate, false); + return deriveExecutionContext(base, dispatchPagesRevalidate, false, defaultHostRuntime); } diff --git a/packages/vinext/src/server/worker-stages.ts b/packages/vinext/src/server/worker-stages.ts new file mode 100644 index 000000000..e642634fe --- /dev/null +++ b/packages/vinext/src/server/worker-stages.ts @@ -0,0 +1,132 @@ +import type { PagesRenderOptions } from "./pages-request-pipeline.js"; +import type { + VinextResponseStageCacheability, + VinextResponseStageDispatchOptions, + VinextResponseStageTransport, +} from "./multi-stage.js"; + +export const PAGES_RESPONSE_STAGE_PROTOCOL_VERSION = 4; +export const PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER = + "x-vinext-pages-response-stage-policy-owner"; +export type PagesResponseStagePolicyOwner = "request-time" | "static"; + +type PagesResponseStageEnvelope = { + buildId: string | null; + cacheability: VinextResponseStageCacheability; + protocolVersion: typeof PAGES_RESPONSE_STAGE_PROTOCOL_VERSION; + /** Host is explicit because multi-tenant/domain-i18n renders vary by it. */ + requestHost: string; + /** Complete response-header snapshot installed before Pages user code runs. */ + stagedHeaders: Array<[string, string]> | null; +}; + +/** Serializable Pages page render delegated to a cacheable Worker stage. */ +type PagesPageResponseStageProps = PagesResponseStageEnvelope & { + kind: "pages-page"; + renderOptions: PagesRenderOptions | null; + resolvedUrl: string; +}; + +/** Serializable Pages API dispatch delegated to a cacheable Worker stage. */ +type PagesApiResponseStageProps = PagesResponseStageEnvelope & { + apiUrl: string; + kind: "pages-api"; +}; + +/** Authenticated staged-worker path discovery delegated outside the shared cache. */ +type PagesPrerenderDiscoveryStageProps = PagesResponseStageEnvelope & { + kind: "pages-prerender-discovery"; +}; + +/** Serializable description of work delegated to a cacheable Worker stage. */ +export type WorkerResponseStageProps = + | PagesApiResponseStageProps + | PagesPageResponseStageProps + | PagesPrerenderDiscoveryStageProps; + +/** + * Host-owned transport for invoking a response stage. + * + * Core request pipelines decide what may be shared and provide the complete + * serialized render identity here. Response-only middleware/config state is + * carried in the props so Pages user code observes the same pre-handler + * response as Next.js. + */ +export type DispatchWorkerResponseStage = VinextResponseStageTransport; + +/** Normalize a shared App page HEAD request onto its method-invariant GET representation. */ +export function prepareSharedAppPageDispatch( + request: Request, + cache: VinextResponseStageDispatchOptions["cache"], +): Request { + return cache !== "bypass" && request.method.toUpperCase() === "HEAD" + ? new Request(request, { method: "GET" }) + : request; +} + +function isSerializedHeaders(value: unknown): value is Array<[string, string]> { + return ( + Array.isArray(value) && + value.every( + (entry) => + Array.isArray(entry) && + entry.length === 2 && + typeof entry[0] === "string" && + typeof entry[1] === "string", + ) + ); +} + +function isResponseStageCacheability(value: unknown): value is VinextResponseStageCacheability { + if (!value || typeof value !== "object") return false; + const cacheability = value as Partial; + return ( + (cacheability.probeMode === null || + cacheability.probeMode === "probe" || + cacheability.probeMode === "identity") && + (cacheability.policyHeaders === null || + (Array.isArray(cacheability.policyHeaders) && + cacheability.policyHeaders.every( + (entry) => + Array.isArray(entry) && + entry.length === 2 && + typeof entry[0] === "string" && + typeof entry[1] === "string", + ))) && + (cacheability.representation === undefined || + cacheability.representation === "app-route" || + cacheability.representation === "html" || + cacheability.representation === "pages-data" || + cacheability.representation === "rsc-full" || + cacheability.representation === "rsc-loading-shell") && + typeof cacheability.resolvedRoutePathname === "string" && + cacheability.resolvedRoutePathname.startsWith("/") + ); +} + +export function isPagesResponseStageProps(value: unknown): value is WorkerResponseStageProps { + if (!value || typeof value !== "object") return false; + const props = value as Partial; + if ( + props.protocolVersion !== PAGES_RESPONSE_STAGE_PROTOCOL_VERSION || + (props.buildId !== null && typeof props.buildId !== "string") || + !isResponseStageCacheability(props.cacheability) || + typeof props.requestHost !== "string" || + props.requestHost.length === 0 || + (props.stagedHeaders !== null && !isSerializedHeaders(props.stagedHeaders)) + ) { + return false; + } + if (props.kind === "pages-api") return typeof props.apiUrl === "string"; + if (props.kind === "pages-prerender-discovery") return true; + if (props.kind !== "pages-page" || typeof props.resolvedUrl !== "string") return false; + if (props.renderOptions === null) return true; + if (!props.renderOptions || typeof props.renderOptions !== "object") return false; + const options = props.renderOptions; + return ( + (options.isDataReq === undefined || typeof options.isDataReq === "boolean") && + (options.renderErrorPageOnMiss === undefined || + typeof options.renderErrorPageOnMiss === "boolean") && + (options.originalUrl === undefined || typeof options.originalUrl === "string") + ); +} diff --git a/packages/vinext/src/server/worker-utils.ts b/packages/vinext/src/server/worker-utils.ts index d8c04384d..059de499d 100644 --- a/packages/vinext/src/server/worker-utils.ts +++ b/packages/vinext/src/server/worker-utils.ts @@ -141,6 +141,7 @@ export async function resolveStaticAssetSignal( "content-encoding", "content-length", "content-type", + "transfer-encoding", ]); cancelResponseBody(signalResponse); diff --git a/packages/vinext/src/shims/cache-handler.ts b/packages/vinext/src/shims/cache-handler.ts index 56195a12c..71353e684 100644 --- a/packages/vinext/src/shims/cache-handler.ts +++ b/packages/vinext/src/shims/cache-handler.ts @@ -373,6 +373,8 @@ export class MemoryCacheHandler implements CacheHandler { } const HANDLER_KEY = Symbol.for("vinext.cacheHandler"); +const CONFIGURED_HANDLER_KEY = Symbol.for("vinext.configuredCacheHandler"); +const EXPLICIT_HANDLER_KEY = Symbol.for("vinext.explicitCacheHandler"); const globalHandlers = globalThis as unknown as Record; function getActiveHandler(): CacheHandler { @@ -387,6 +389,29 @@ export function configureMemoryCacheHandler(options?: MemoryCacheHandlerOptions) export function setDataCacheHandler(handler: CacheHandler): void { globalHandlers[HANDLER_KEY] = handler; + globalHandlers[EXPLICIT_HANDLER_KEY] = handler; +} + +/** + * Lazily keep the first declaratively configured handler shared across + * duplicated stage modules. Imperative setters remain able to replace it. + */ +export function registerDataCacheHandler(factory: () => CacheHandler): void { + if ( + globalHandlers[EXPLICIT_HANDLER_KEY] !== undefined || + globalHandlers[CONFIGURED_HANDLER_KEY] !== undefined + ) { + return; + } + const handler = factory(); + if ( + globalHandlers[EXPLICIT_HANDLER_KEY] !== undefined || + globalHandlers[CONFIGURED_HANDLER_KEY] !== undefined + ) { + return; + } + globalHandlers[HANDLER_KEY] = handler; + globalHandlers[CONFIGURED_HANDLER_KEY] = handler; } export function getDataCacheHandler(): CacheHandler { diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts index 566a04c30..9d8cd4926 100644 --- a/packages/vinext/src/shims/cache.ts +++ b/packages/vinext/src/shims/cache.ts @@ -15,8 +15,7 @@ * vinext({ cache: { data: kvDataAdapter({ binding: 'VINEXT_KV_CACHE' }) } }) * * The imperative `setCacheHandler` / `setDataCacheHandler` setters are - * deprecated for consumers and retained only as the internal registration - * target used by the generated cache-adapter module. + * deprecated for consumers and retained for backwards compatibility. */ import { diff --git a/packages/vinext/src/shims/cacheability-classification.ts b/packages/vinext/src/shims/cacheability-classification.ts index ce4e4195d..0c07c295c 100644 --- a/packages/vinext/src/shims/cacheability-classification.ts +++ b/packages/vinext/src/shims/cacheability-classification.ts @@ -2,13 +2,8 @@ import { getRequestExecutionContext } from "./request-context.js"; export const CACHEABILITY_REQUEST_STATE = Symbol.for("vinext.cacheabilityRequestState"); -export const CACHEABILITY_POLICY_HEADERS = [ - "cache-control", - "cdn-cache-control", - "cloudflare-cdn-cache-control", -] as const; - -type CacheabilityPolicyHeader = (typeof CACHEABILITY_POLICY_HEADERS)[number]; +/** Cache-policy response headers owned by core itself. */ +export const CACHEABILITY_POLICY_HEADERS = ["cache-control"] as const; export type RouteCacheabilityOutcome = { cacheControl?: string; @@ -33,24 +28,30 @@ export type RouteCacheabilityState = { complete?: (outcome: RouteCacheabilityOutcome) => void; completion?: Promise; completedResponseBody?: boolean; + /** Whether admission must translate a completed response through the active adapter. */ + applyCompletedResponsePolicy?: boolean; explicitConfigCachePolicy?: boolean; explicitResponseCachePolicy?: boolean; finalResponseVetoReason?: string; forcedDynamicReason?: string; /** A route-config decision that applies to every concrete identity for this pattern. */ patternDynamicReason?: string; - frameworkResponseCachePolicy?: Partial>; + frameworkResponseCachePolicy?: Partial>; mode: "admit" | "identity" | "probe"; outcome?: RouteCacheabilityOutcome; preserveResponseCachePolicy?: boolean; /** Cache-key behavior declared by the active CDN adapter. */ responseVary?: "verbatim"; + /** Concrete pathname resolved by the trusted request stage before rendering. */ + resolvedRoutePathname?: string; + /** Lowercase cache-policy names owned by core and the active CDN adapter. */ + responsePolicyHeaderNames?: readonly string[]; probeBailout?: { kind: "private-cache"; outcome: RouteCacheabilityOutcome; }; route?: { - kind: "app-page" | "app-route" | "pages-page"; + kind: "app-page" | "app-route" | "pages-api" | "pages-page"; pattern: string; }; }; @@ -72,7 +73,7 @@ export function readRouteCacheabilityState(): RouteCacheabilityState | null { } export function beginRouteCacheability( - kind: "app-page" | "app-route" | "pages-page", + kind: "app-page" | "app-route" | "pages-api" | "pages-page", pattern: string, ): boolean { const state = readRouteCacheabilityState(); @@ -150,8 +151,8 @@ export function captureRouteCacheabilityResponsePolicy(headers: Headers): void { const state = readRouteCacheabilityState(); if (!state || state.mode !== "admit") return; - const policy: Partial> = {}; - for (const name of CACHEABILITY_POLICY_HEADERS) { + const policy: Partial> = {}; + for (const name of state.responsePolicyHeaderNames ?? CACHEABILITY_POLICY_HEADERS) { const value = headers.get(name); if (value !== null) policy[name] = value; } diff --git a/packages/vinext/src/shims/cdn-cache-state.ts b/packages/vinext/src/shims/cdn-cache-state.ts new file mode 100644 index 000000000..1a5e320f3 --- /dev/null +++ b/packages/vinext/src/shims/cdn-cache-state.ts @@ -0,0 +1,28 @@ +import type { CdnCacheAdapter } from "./cdn-cache.js"; + +const CDN_CACHE_ADAPTER_KEY = Symbol.for("vinext.cdnCacheAdapter"); +const globals = globalThis as unknown as Record; + +/** Register an adapter without loading the origin cache implementation. */ +export function setCdnCacheAdapter(adapter: CdnCacheAdapter): void { + globals[CDN_CACHE_ADAPTER_KEY] = adapter; +} + +/** + * Lazily keep the first declaratively configured adapter shared across + * duplicated stage modules. Failed factories remain retryable so a later + * entrypoint with the required runtime bindings can register successfully. + */ +export function registerCdnCacheAdapter(factory: () => CdnCacheAdapter): void { + if (globals[CDN_CACHE_ADAPTER_KEY] !== undefined) return; + const adapter = factory(); + // Preserve an imperative adapter installed re-entrantly by the factory. + if (globals[CDN_CACHE_ADAPTER_KEY] === undefined) { + globals[CDN_CACHE_ADAPTER_KEY] = adapter; + } +} + +/** Read only an explicitly registered adapter; defaults belong to cdn-cache. */ +export function getExplicitCdnCacheAdapter(): CdnCacheAdapter | null { + return (globals[CDN_CACHE_ADAPTER_KEY] as CdnCacheAdapter | undefined) ?? null; +} diff --git a/packages/vinext/src/shims/cdn-cache.ts b/packages/vinext/src/shims/cdn-cache.ts index a06c00ec6..6dd4e8e47 100644 --- a/packages/vinext/src/shims/cdn-cache.ts +++ b/packages/vinext/src/shims/cdn-cache.ts @@ -25,11 +25,9 @@ * pre-split implementation. */ -import { - getDataCacheHandler, - type CacheHandlerValue, - type IncrementalCacheValue, -} from "./cache-handler.js"; +import type { CacheHandlerValue, IncrementalCacheValue } from "./cache-handler.js"; +import { getExplicitCdnCacheAdapter } from "./cdn-cache-state.js"; +export { setCdnCacheAdapter } from "./cdn-cache-state.js"; /** A map of response header name -> value the adapter wants applied or removed. */ export type CdnResponseHeaders = Record; @@ -106,6 +104,9 @@ export type CdnCacheAdapter = { */ readonly responseVary?: "verbatim"; + /** Provider-specific response headers whose values control shared caching. */ + readonly responsePolicyHeaderNames?: readonly string[]; + /** * Fresh App Page responses must reach clean EOF before this adapter may emit * shared-cache headers. Used by edge adapters whose cache sits in front of @@ -203,6 +204,7 @@ export class DefaultCdnCacheAdapter implements CdnCacheAdapter { readonly ownsBackgroundRevalidation = true; async get(key: string, ctx?: Record): Promise { + const { getDataCacheHandler } = await import("./cache-handler.js"); return getDataCacheHandler().get(key, ctx); } @@ -211,6 +213,7 @@ export class DefaultCdnCacheAdapter implements CdnCacheAdapter { data: IncrementalCacheValue | null, ctx?: Record, ): Promise { + const { getDataCacheHandler } = await import("./cache-handler.js"); await getDataCacheHandler().set(key, data, ctx); } @@ -241,9 +244,6 @@ export class DefaultCdnCacheAdapter implements CdnCacheAdapter { // 2. Otherwise, the origin-managed DefaultCdnCacheAdapter. // --------------------------------------------------------------------------- -const _CDN_KEY = Symbol.for("vinext.cdnCacheAdapter"); -const _gCdn = globalThis as unknown as Record; - let _defaultAdapter: DefaultCdnCacheAdapter | null = null; /** @@ -265,20 +265,15 @@ let _defaultAdapter: DefaultCdnCacheAdapter | null = null; * ``` * * The plugin registers the adapter across every runtime/router entry, so you - * don't have to call this from a worker entry. This setter remains as the - * internal registration target and for backwards compatibility, but is not the - * recommended consumer API. + * don't have to call this from a worker entry. This setter remains for + * backwards compatibility, but is not the recommended consumer API. */ -export function setCdnCacheAdapter(adapter: CdnCacheAdapter): void { - _gCdn[_CDN_KEY] = adapter; -} - /** * Get the active CDN cache adapter. An explicitly configured adapter wins; * otherwise the origin-managed {@link DefaultCdnCacheAdapter} is used. */ export function getCdnCacheAdapter(): CdnCacheAdapter { - const active = _gCdn[_CDN_KEY] as CdnCacheAdapter | undefined; + const active = getExplicitCdnCacheAdapter(); if (active) return active; return (_defaultAdapter ??= new DefaultCdnCacheAdapter()); diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts index 92a4d4774..8bbe41713 100644 --- a/packages/vinext/src/shims/headers.ts +++ b/packages/vinext/src/shims/headers.ts @@ -556,6 +556,57 @@ export function getHeadersContext(): HeadersContext | null { return _getState().headersContext; } +/** Serialize the effective request-cookie view, including middleware Set-Cookie overlays. */ +export function getEffectiveRequestCookieHeader(): string | null { + const context = getHeadersContext(); + if (!context || context.cookies.size === 0) return null; + return [...context.cookies] + .map(([name, value]) => `${name}=${encodeURIComponent(value)}`) + .join("; "); +} + +export function hasEffectiveRequestCookieChanges(cookieHeader: string | null): boolean { + const effective = getHeadersContext()?.cookies; + if (!effective) return false; + const original = parseEdgeRequestCookieHeader(cookieHeader ?? ""); + if (effective.size !== original.size) return true; + for (const [name, value] of effective) { + if (original.get(name) !== value) return true; + } + return false; +} + +/** + * Replace only the request-cookie view for the active request scope. + * + * Middleware Set-Cookie mutations are visible through `cookies()` during the + * same request in Next.js, but they do not rewrite the raw `Cookie` value + * returned by `headers()`. The staged App renderer uses this seam to restore + * that split view without changing the request Headers object. + */ +export function applyEffectiveRequestCookieHeader(cookieHeader: string): void { + const state = _getState(); + const context = state.headersContext; + if (!context) return; + rebuildCookiesFromHeader(context, cookieHeader); + context.readonlyCookies = undefined; + context.mutableCookies = undefined; +} + +/** Restore a middleware draft-mode transition inside a staged render scope. */ +export function restoreDraftModeTransition(cookieHeader: string): void { + const state = _getState(); + const context = state.headersContext; + if (!context) return; + const entry = setCookieNameValue(cookieHeader); + if (!entry || entry.name !== DRAFT_MODE_COOKIE) return; + context.cookies.set(entry.name, entry.value); + context.readonlyCookies = undefined; + context.mutableCookies = undefined; + context.draftModeEnabled = entry.value === validateDraftModeSecret(context.draftModeSecret ?? ""); + state.draftModeCookieHeader = cookieHeader; +} + export function setHeadersContext(ctx: HeadersContext | null): void { const state = _getState(); if (ctx !== null) { diff --git a/packages/vinext/src/shims/navigation-context-accessors.ts b/packages/vinext/src/shims/navigation-context-accessors.ts new file mode 100644 index 000000000..ce51819de --- /dev/null +++ b/packages/vinext/src/shims/navigation-context-accessors.ts @@ -0,0 +1,35 @@ +export type NavigationContext = { + pathname: string; + searchParams: URLSearchParams; + params: Record; +}; + +type NavigationStateAccessors = { + setServerContext: (context: NavigationContext | null) => void; +}; + +const GLOBAL_ACCESSORS_KEY = Symbol.for("vinext.navigation.globalAccessors"); +const NAVIGATION_FALLBACK_STATE_KEY = Symbol.for("vinext.navigation.fallback"); + +type NavigationStateGlobal = typeof globalThis & { + [GLOBAL_ACCESSORS_KEY]?: NavigationStateAccessors; + [NAVIGATION_FALLBACK_STATE_KEY]?: { + serverContext: NavigationContext | null; + serverInsertedHTMLCallbacks: Array<() => unknown>; + }; +}; + +/** Lightweight server context setter for request-only runtimes such as middleware. */ +export function setNavigationContext(context: NavigationContext | null): void { + const globalState = globalThis as NavigationStateGlobal; + const accessors = globalState[GLOBAL_ACCESSORS_KEY]; + if (accessors) { + accessors.setServerContext(context); + return; + } + const fallback = (globalState[NAVIGATION_FALLBACK_STATE_KEY] ??= { + serverContext: null, + serverInsertedHTMLCallbacks: [], + }); + fallback.serverContext = context; +} diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index 3982d2598..a1180c0e7 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -138,13 +138,18 @@ export class NextRequest extends Request { // the source request to stay readable must branch it themselves and // cancel the branch they do not consume. super(input, requestInit); - const cf = Reflect.get(input, "cf"); - if (cf !== undefined) { - Object.defineProperty(this, "cf", { - value: cf, - enumerable: true, - configurable: true, - }); + const cfDescriptor = Reflect.getOwnPropertyDescriptor(input, "cf"); + if (cfDescriptor) { + Object.defineProperty(this, "cf", cfDescriptor); + } else { + const cf = Reflect.get(input, "cf"); + if (cf !== undefined) { + Object.defineProperty(this, "cf", { + value: cf, + enumerable: true, + configurable: true, + }); + } } } else { super(input, requestInit); diff --git a/packages/vinext/src/utils/middleware-request-headers.ts b/packages/vinext/src/utils/middleware-request-headers.ts index 4985ff9cb..b6c508ce6 100644 --- a/packages/vinext/src/utils/middleware-request-headers.ts +++ b/packages/vinext/src/utils/middleware-request-headers.ts @@ -69,6 +69,17 @@ export function getUnconsumedMiddlewareRequestHeaders( return unconsumedHeaders; } +/** Whether middleware changed the headers seen by downstream application code. */ +export function hasMiddlewareRequestHeaderOverrides( + source: MiddlewareHeaderSource | null, +): boolean { + if (!source) return false; + return ( + Boolean(getMiddlewareHeaderValue(source, MIDDLEWARE_OVERRIDE_HEADERS)) || + getUnconsumedMiddlewareRequestHeaders(source).size > 0 + ); +} + export function encodeMiddlewareRequestHeaders( targetHeaders: Headers, requestHeaders: Headers, diff --git a/packages/vinext/src/virtual-vinext-multi-stage.d.ts b/packages/vinext/src/virtual-vinext-multi-stage.d.ts new file mode 100644 index 000000000..a81416575 --- /dev/null +++ b/packages/vinext/src/virtual-vinext-multi-stage.d.ts @@ -0,0 +1,7 @@ +declare module "virtual:vinext-request-stage" { + export const handleRequestStage: import("./server/multi-stage.js").VinextRequestStageHandler; +} + +declare module "virtual:vinext-response-stage" { + export const handleResponseStage: import("./server/multi-stage.js").VinextResponseStageHandler; +} diff --git a/packages/vinext/src/virtual-vinext-rsc-entry.d.ts b/packages/vinext/src/virtual-vinext-rsc-entry.d.ts index 0d18f804c..2861a8e79 100644 --- a/packages/vinext/src/virtual-vinext-rsc-entry.d.ts +++ b/packages/vinext/src/virtual-vinext-rsc-entry.d.ts @@ -9,10 +9,7 @@ * See `entries/app-rsc-entry.ts` for the generator that emits these. */ declare module "virtual:vinext-rsc-entry" { - const rscHandler: ( - request: Request, - ctx?: unknown, - ) => Promise; + const rscHandler: import("./server/app-rsc-combined-handler.js").AppRscHandler; export default rscHandler; export const __assetPrefix: string; export const __basePath: string; @@ -28,3 +25,46 @@ declare module "virtual:vinext-rsc-entry" { contentSecurityPolicy?: string; }; } + +declare module "virtual:vinext-app-request-entry" { + type DispatchAppWorkerResponseStage = + import("./server/app-worker-stages.js").DispatchAppWorkerResponseStage; + const requestHandler: ( + request: Request, + ctx: unknown, + dispatchResponseStage: DispatchAppWorkerResponseStage, + probeMode?: import("./server/multi-stage.js").VinextCacheabilityProbeMode | null, + prerenderDiscovery?: boolean, + trustedPrerenderState?: + | import("./server/prerender-route-params.js").TrustedPrerenderState + | null, + ) => Promise; + export default requestHandler; + export const __assetPrefix: string; + export const __basePath: string; + export const __imageAllowedWidths: number[]; + export const __prerenderSecret: string; + export const __imageConfig: { + qualities?: number[]; + dangerouslyAllowSVG?: boolean; + dangerouslyAllowLocalIP?: boolean; + contentDispositionType?: "inline" | "attachment"; + contentSecurityPolicy?: string; + }; +} + +declare module "virtual:vinext-app-response-entry" { + import type { AppWorkerResponseStageProps } from "vinext/server/app-worker-stages"; + import type { VinextResponseStageDispatchOptions } from "vinext/server/multi-stage"; + + const handler: { + handleResponseStage( + request: Request, + ctx: unknown, + props: AppWorkerResponseStageProps, + options?: VinextResponseStageDispatchOptions, + ): Promise; + }; + export const __cacheabilityManifest: string | null; + export default handler; +} diff --git a/tests/after-deploy.test.ts b/tests/after-deploy.test.ts index 8939f2318..c2c1ceb7c 100644 --- a/tests/after-deploy.test.ts +++ b/tests/after-deploy.test.ts @@ -13,7 +13,7 @@ import { describe, expect, it } from "vite-plus/test"; import fs from "node:fs"; import path from "node:path"; -import { readPagesRouterEntrySource } from "./worker-entry-source.js"; +import { readPagesResponseStageEntrySource } from "./worker-entry-source.js"; type ExecutionContextLike = { waitUntil(promise: Promise): void; @@ -141,19 +141,20 @@ describe("after() in deploy mode — Pages Router worker entry", () => { it("forwards ctx to handleApiRoute so api routes can call after()", () => { // Regression for #1365: handleApiRoute previously ignored ctx, leaving // after() inside Pages Router api routes without a way to call - // ctx.waitUntil(). The generated worker entry must thread ctx through. - // - // After #1336 item 3 the dispatch URL is `apiLookupUrl` (the locale- - // stripped form of `resolvedUrl`), but `ctx` is still threaded through. - const content = readPagesRouterEntrySource(); - expect(content).toContain( - 'handleApiRoute(req, apiUrl, ctx, new URL(req.url).origin, "worker")', + // ctx.waitUntil(). Rendering now lives in the response stage, which must + // preserve that context in the cacheability wrapper it passes to the + // generated Pages entry. + const content = readPagesResponseStageEntrySource(); + expect(content).toMatch( + /pagesEntry\.handleApiRoute\(\s*request,\s*props\.apiUrl,\s*cacheabilityContext,\s*new URL\(request\.url\)\.origin,/, ); }); it("forwards ctx and staged middleware headers to renderPage so page renders can call after() and apply CSP nonces", () => { - const content = readPagesRouterEntrySource(); - expect(content).toContain("renderPage(req, resolvedUrl, null, ctx, stagedHeaders, options)"); + const content = readPagesResponseStageEntrySource(); + expect(content).toMatch( + /pagesEntry\.renderPage\(\s*request,\s*props\.resolvedUrl,\s*null,\s*cacheabilityContext,\s*renderHeaders,\s*props\.renderOptions/, + ); }); }); diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index b6dc07bb2..e6d395f73 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -398,6 +398,7 @@ describe("AppElementsWire", () => { const allowed = new Set([ path.join(sourceRoot, "routing/app-route-graph.ts"), path.join(sourceRoot, "server/app-elements-wire.ts"), + path.join(sourceRoot, "server/app-elements-wire-key.ts"), ]); const rawWireConstruction = /`(?:route|page|layout|template):\$\{|`slot:\$\{|["'](?:route|page|layout|template):["']\s*\+|["']slot:["']\s*\+|\.startsWith\(["'](?:slot|layout|page|route|template):["']\)/; diff --git a/tests/app-pages-bridge.test.ts b/tests/app-pages-bridge.test.ts index 4685e0027..89916bc41 100644 --- a/tests/app-pages-bridge.test.ts +++ b/tests/app-pages-bridge.test.ts @@ -107,6 +107,54 @@ describe("renderPagesFallback", () => { return { encodedBody, observedRuntimes, response }; } + it("passes staged response headers to Pages API and GSSP user code", async () => { + // Ported from Next.js: + // test/e2e/middleware-custom-matchers/app/pages/index.js + // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-custom-matchers/app/pages/index.js + const initialResponseHeaders = new Headers({ + "x-config-variant": "preview", + "x-from-middleware": "present", + }); + const apiRequest = new Request("http://localhost/api/headers"); + const handleApiRoute = vi.fn>( + (_request, _url, _ctx, _origin, _runtime, headers) => { + expect(headers).toEqual(initialResponseHeaders); + return new Response("api"); + }, + ); + await renderPagesFallback( + { + initialResponseHeaders, + isRscRequest: false, + middlewareContext: { headers: null, requestHeaders: null, status: null }, + request: apiRequest, + url: new URL(apiRequest.url), + }, + { ...defaultDeps, loadPagesEntry: () => ({ handleApiRoute }) }, + ); + + const pageRequest = new Request("http://localhost/page"); + const renderPage = vi.fn>( + (_request, _url, _query, _parsedUrl, _middlewareHeaders, _options, headers) => { + expect(headers).toEqual(initialResponseHeaders); + return new Response("page"); + }, + ); + await renderPagesFallback( + { + initialResponseHeaders, + isRscRequest: false, + middlewareContext: { headers: null, requestHeaders: null, status: null }, + request: pageRequest, + url: new URL(pageRequest.url), + }, + { ...defaultDeps, loadPagesEntry: () => ({ renderPage }) }, + ); + + expect(handleApiRoute).toHaveBeenCalledOnce(); + expect(renderPage).toHaveBeenCalledOnce(); + }); + it("returns null for RSC requests and does not call the Pages loader", async () => { const loadPagesEntry = vi.fn(() => ({}) as PagesEntry); const res = await renderPagesFallback( diff --git a/tests/app-request-stage-dispatch.test.ts b/tests/app-request-stage-dispatch.test.ts new file mode 100644 index 000000000..1227612db --- /dev/null +++ b/tests/app-request-stage-dispatch.test.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + appRequestUsesFullResponseGraph, + dispatchAppRequestStage, + type AppRequestStageDispatchOptions, +} from "../packages/vinext/src/server/app-request-stage-dispatch.js"; +import type { AppRscRequestHandler } from "../packages/vinext/src/server/app-rsc-handler.js"; +import { APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION } from "../packages/vinext/src/server/app-worker-stages.js"; +import { + createStaticFileSignal, + isStaticFileSignal, + readStaticFileSignal, + serializeStaticFileSignalForTransport, +} from "../packages/vinext/src/server/static-file-signal.js"; + +function createOptions( + overrides: Partial = {}, +): AppRequestStageDispatchOptions { + return { + basePath: "/docs", + buildId: "build-1", + draftModeSecret: "draft-secret", + handleRequest: async () => new Response("request stage"), + prerenderDiscovery: false, + probeMode: null, + ...overrides, + }; +} + +describe("App request-stage dispatch", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + { + name: "non-read method", + request: new Request("https://example.test/docs/page", { method: "POST" }), + }, + { + name: "websocket upgrade", + request: new Request("https://example.test/docs/page", { + headers: { Upgrade: "h2c, WebSocket" }, + }), + }, + { + name: "valid draft mode cookie", + request: new Request("https://example.test/docs/page", { + headers: { Cookie: "__prerender_bypass=draft-secret" }, + }), + }, + { + name: "trusted prerender route params", + request: new Request("https://example.test/docs/page", { + headers: { "x-vinext-prerender-route-params": "payload" }, + }), + }, + { + name: "request cache bypass", + request: new Request("https://example.test/docs/page", { + headers: { "Cache-Control": "max-age=0, NO-STORE" }, + }), + }, + { + name: "CSP nonce", + request: new Request("https://example.test/docs/page", { + headers: { "Content-Security-Policy": "script-src 'nonce-request-stage'" }, + }), + }, + { + name: "route-tree prefetch", + request: new Request("https://example.test/docs/page", { + headers: { + RSC: "1", + "Next-Router-Prefetch": "1", + "Next-Router-Segment-Prefetch": "/_tree", + }, + }), + }, + { + name: "internal path below the base path", + request: new Request("https://example.test/docs/__vinext/revalidate"), + }, + ])("uses the complete graph for a $name", ({ request }) => { + expect(appRequestUsesFullResponseGraph(request, createOptions())).toBe(true); + }); + + it("uses the complete graph during prerender execution", () => { + vi.stubEnv("VINEXT_PRERENDER", "1"); + + expect( + appRequestUsesFullResponseGraph( + new Request("https://example.test/docs/page"), + createOptions(), + ), + ).toBe(true); + }); + + it("uses the complete graph for authenticated transported prerender state", () => { + expect( + appRequestUsesFullResponseGraph( + new Request("https://example.test/docs/page"), + createOptions({ + trustedPrerenderState: { routeParams: null, speculative: true }, + }), + ), + ).toBe(true); + }); + + it("keeps ordinary GET/HEAD requests in the request-only graph", () => { + expect( + appRequestUsesFullResponseGraph( + new Request("https://example.test/docs/page"), + createOptions(), + ), + ).toBe(false); + expect( + appRequestUsesFullResponseGraph( + new Request("https://example.test/docs/page", { method: "HEAD" }), + createOptions(), + ), + ).toBe(false); + }); + + it("allows an authenticated cacheability probe to classify a no-cache request", () => { + const request = new Request("https://example.test/docs/page", { + headers: { "Cache-Control": "no-cache" }, + }); + + expect(appRequestUsesFullResponseGraph(request, createOptions({ probeMode: "probe" }))).toBe( + false, + ); + expect(appRequestUsesFullResponseGraph(request, createOptions({ probeMode: "identity" }))).toBe( + false, + ); + }); + + it("delegates ordinary requests to the request-only handler", async () => { + const request = new Request("https://example.test/docs/page"); + const ctx = { waitUntil() {} }; + const dispatchResponseStage = vi.fn(async () => new Response("response stage")); + const handleRequest = vi.fn(async () => new Response("request stage")); + + const response = await dispatchAppRequestStage(request, ctx, dispatchResponseStage, { + ...createOptions(), + handleRequest, + probeMode: "identity", + }); + + await expect(response.text()).resolves.toBe("request stage"); + expect(handleRequest).toHaveBeenCalledWith( + request, + ctx, + false, + dispatchResponseStage, + "identity", + ); + expect(dispatchResponseStage).not.toHaveBeenCalled(); + }); + + it("builds a bypass envelope for full requests and restores static-file signals", async () => { + const request = new Request("https://example.test/docs/upload?view=1", { method: "POST" }); + const handleRequest = vi.fn(); + const dispatchResponseStage = vi.fn(async (_request, props) => { + if (props.kind !== "app-full-request") throw new Error("unexpected stage kind"); + return serializeStaticFileSignalForTransport( + createStaticFileSignal("/public/logo.svg", { headers: null, status: 200 }), + props.staticFileSignalToken, + ); + }); + + const response = await dispatchAppRequestStage(request, null, dispatchResponseStage, { + ...createOptions(), + handleRequest, + prerenderDiscovery: true, + probeMode: "probe", + trustedPrerenderState: { + routeParams: { params: { slug: "hello" }, routePattern: "/docs/:slug" }, + speculative: true, + }, + }); + + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(dispatchResponseStage).toHaveBeenCalledWith( + request, + { + buildId: "build-1", + cacheability: { + policyHeaders: null, + probeMode: "probe", + resolvedRoutePathname: "/docs/upload", + }, + draftModeCookie: null, + kind: "app-full-request", + middlewareCookieOverlay: null, + prerenderDiscovery: true, + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: "https://example.test", + scriptNonce: null, + staticFileSignalToken: expect.any(String), + trustedPrerenderState: { + routeParams: { params: { slug: "hello" }, routePattern: "/docs/:slug" }, + speculative: true, + }, + }, + { cache: "bypass" }, + ); + expect(handleRequest).not.toHaveBeenCalled(); + expect(isStaticFileSignal(response)).toBe(true); + expect(readStaticFileSignal(response)).toBe(encodeURIComponent("/public/logo.svg")); + expect(response.headers.has("x-vinext-stage-static-file")).toBe(false); + }); + + it("requires the adapter-owned response-stage dispatcher", async () => { + await expect( + dispatchAppRequestStage( + new Request("https://example.test/docs/page"), + null, + undefined, + createOptions(), + ), + ).rejects.toThrow("App request stage requires a response-stage dispatcher"); + }); +}); diff --git a/tests/app-route-handler-execution.test.ts b/tests/app-route-handler-execution.test.ts index 27b855cee..d75be9779 100644 --- a/tests/app-route-handler-execution.test.ts +++ b/tests/app-route-handler-execution.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { consumeDynamicUsage, cookies, @@ -34,6 +34,11 @@ import { CACHEABILITY_REQUEST_STATE, type RouteCacheabilityState, } from "../packages/vinext/src/shims/cacheability-classification.js"; +import { + DefaultCdnCacheAdapter, + setCdnCacheAdapter, + type CdnCacheAdapter, +} from "../packages/vinext/src/shims/cdn-cache.js"; // The fetch-cache shim captures `originalFetch` from globalThis at import // time, so stub fetch BEFORE importing it (same pattern as @@ -48,6 +53,8 @@ vi.stubGlobal("fetch", fetchMock); const { withFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js"); const { revalidateTag } = await import("../packages/vinext/src/shims/cache.js"); +afterEach(() => setCdnCacheAdapter(new DefaultCdnCacheAdapter())); + function createDynamicUsageState(): { consumeDynamicUsage: () => boolean; markDynamicUsage: () => void; @@ -306,6 +313,20 @@ describe("app route handler execution helpers", () => { it.each(["CDN-Cache-Control", "Cloudflare-CDN-Cache-Control"])( "preserves handler-owned %s instead of applying framework revalidation", async (policyHeader) => { + const adapter: CdnCacheAdapter = { + buildResponseHeaders: ({ cacheControl }) => ({ "Cache-Control": cacheControl }), + async get() { + return null; + }, + hasExplicitNonCacheableResponsePolicy(headers) { + return headers.get(policyHeader)?.includes("no-store") === true; + }, + ownsBackgroundRevalidation: false, + async revalidateTag() {}, + responsePolicyHeaderNames: [policyHeader], + async set() {}, + }; + setCdnCacheAdapter(adapter); const dynamicUsage = createDynamicUsageState(); const isrSet = vi.fn(); const response = await executeAppRouteHandler({ diff --git a/tests/app-route-handler-runtime.test.ts b/tests/app-route-handler-runtime.test.ts index c142da6ae..fc019f643 100644 --- a/tests/app-route-handler-runtime.test.ts +++ b/tests/app-route-handler-runtime.test.ts @@ -328,6 +328,30 @@ describe("app route handler runtime helpers", () => { expect(() => Reflect.get(dynamicError.request, "cf")).toThrow("dynamic access: request.cf"); }); + it("does not read a lazy request.cf accessor until userland accesses it", () => { + const request = new Request("https://example.com/demo"); + let reads = 0; + Object.defineProperty(request, "cf", { + configurable: true, + enumerable: true, + get() { + reads += 1; + return { country: "AU" }; + }, + }); + const accesses: string[] = []; + const tracked = createTrackedAppRouteRequest(request, { + onDynamicAccess(access) { + accesses.push(access); + }, + }); + + expect(reads).toBe(0); + expect(Reflect.get(tracked.request, "cf")).toEqual({ country: "AU" }); + expect(reads).toBe(1); + expect(accesses).toEqual(["request.cf"]); + }); + it("preserves Workers cf metadata when cloning tracked requests", () => { const request = new Request("https://example.com/demo"); const cf = { country: "AU" }; diff --git a/tests/app-router-worker-entry.test.ts b/tests/app-router-worker-entry.test.ts index 30f2c5f24..1001b40c4 100644 --- a/tests/app-router-worker-entry.test.ts +++ b/tests/app-router-worker-entry.test.ts @@ -3,8 +3,21 @@ import os from "node:os"; import path from "node:path"; import { createServer, type Plugin } from "vite"; import { describe, expect, it, vi } from "vite-plus/test"; +import { + DefaultCdnCacheAdapter, + setCdnCacheAdapter, + type CdnCacheAdapter, +} from "../packages/vinext/src/shims/cdn-cache.js"; +import { + VINEXT_CACHEABILITY_PROBE_HEADER, + VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER, + VINEXT_PRERENDER_SECRET_HEADER, +} from "../packages/vinext/src/server/headers.js"; +import { serializeWorkerCacheabilityProbeRoute } from "../packages/vinext/src/server/cacheability-request.js"; const CAPTURE_RSC_REQUEST = "__vinextCaptureWorkerRscRequest"; +const CAPTURE_PRERENDER_STATE = "__vinextCaptureWorkerPrerenderState"; +const REGISTER_CDN_ADAPTER = "__vinextRegisterWorkerCdnAdapter"; function workerEntryVirtualModules(): Plugin { const modules = new Map([ @@ -22,7 +35,38 @@ export default async function rscHandler(request) { } `, ], - ["virtual:vinext-cache-adapters", "export function registerConfiguredCacheAdapters() {}"], + [ + "virtual:vinext-app-request-entry", + ` +export const __assetPrefix = ""; +export const __basePath = ""; +export const __imageAllowedWidths = []; +export const __imageConfig = {}; +export const __prerenderSecret = "worker-prerender-secret"; +export default async function rscHandler(request, _ctx, dispatchResponseStage, _probeMode, _prerenderDiscovery, trustedPrerenderState) { + globalThis.${CAPTURE_PRERENDER_STATE}?.(trustedPrerenderState); + if (new URL(request.url).pathname === "/__vinext/prerender/readiness") { + return dispatchResponseStage(request, { kind: "readiness-test" }, { cache: "bypass" }); + } + if (new URL(request.url).pathname === "/middleware-terminal") { + globalThis.${CAPTURE_RSC_REQUEST}(request); + return Response.redirect("https://example.com/login", 307); + } + globalThis.${CAPTURE_RSC_REQUEST}(request); + return new Response("ok"); +} +`, + ], + [ + "virtual:vinext-cache-adapters", + `export function registerConfiguredCacheAdapters(env) { + globalThis.${REGISTER_CDN_ADAPTER}?.(env); +}`, + ], + [ + "virtual:vinext-cdn-cache-adapter", + `export { registerConfiguredCacheAdapters } from "virtual:vinext-cache-adapters";`, + ], ["virtual:vinext-image-adapters", "export function registerConfiguredImageOptimizer() {}"], ]); @@ -38,6 +82,276 @@ export default async function rscHandler(request) { } describe("App Router Production server worker entry compatibility", () => { + it("authenticates prerender state in a Worker request-stage context", async () => { + const capturedStates: unknown[] = []; + Reflect.set(globalThis, CAPTURE_RSC_REQUEST, () => {}); + Reflect.set(globalThis, CAPTURE_PRERENDER_STATE, (state: unknown) => { + capturedStates.push(state); + }); + const previousPrerender = process.env.VINEXT_PRERENDER; + process.env.VINEXT_PRERENDER = "1"; + let server: Awaited> | undefined; + try { + server = await createServer({ + appType: "custom", + configFile: false, + logLevel: "silent", + plugins: [workerEntryVirtualModules()], + resolve: { + alias: { + "vinext/shims": path.resolve(import.meta.dirname, "../packages/vinext/src/shims"), + }, + }, + server: { middlewareMode: true }, + }); + const entry = (await server.ssrLoadModule( + path.resolve( + import.meta.dirname, + "../packages/vinext/src/server/app-request-stage-independent-entry.ts", + ), + )) as { + handleRequestStage( + request: Request, + env: unknown, + ctx: { hostRuntime: "worker"; waitUntil(): void }, + dispatchResponseStage: () => Promise, + ): Promise; + }; + const routeParams = encodeURIComponent( + JSON.stringify({ params: { slug: "hello" }, routePattern: "/blog/:slug" }), + ); + const request = (secret: string) => + new Request("https://example.com/blog/hello", { + headers: { + "x-vinext-prerender-route-params": routeParams, + "x-vinext-prerender-secret": secret, + "x-vinext-prerender-speculative": "1", + }, + }); + const ctx = { hostRuntime: "worker" as const, waitUntil() {} }; + + await entry.handleRequestStage( + request("worker-prerender-secret"), + undefined, + ctx, + async () => new Response("unused"), + ); + await entry.handleRequestStage( + request("wrong-secret"), + undefined, + ctx, + async () => new Response("unused"), + ); + + expect(capturedStates).toEqual([ + { + routeParams: { params: { slug: "hello" }, routePattern: "/blog/:slug" }, + speculative: true, + }, + null, + ]); + } finally { + await server?.close(); + Reflect.deleteProperty(globalThis, CAPTURE_RSC_REQUEST); + Reflect.deleteProperty(globalThis, CAPTURE_PRERENDER_STATE); + if (previousPrerender === undefined) delete process.env.VINEXT_PRERENDER; + else process.env.VINEXT_PRERENDER = previousPrerender; + } + }); + + it("classifies a terminal middleware probe without dispatching the response stage", async () => { + const capturedRequests: Request[] = []; + Reflect.set(globalThis, CAPTURE_RSC_REQUEST, (request: Request) => { + capturedRequests.push(request); + }); + const dispatch = vi.fn(async () => new Response("unused")); + + let server: Awaited> | undefined; + try { + server = await createServer({ + appType: "custom", + configFile: false, + logLevel: "silent", + plugins: [workerEntryVirtualModules()], + resolve: { + alias: { + "vinext/shims": path.resolve(import.meta.dirname, "../packages/vinext/src/shims"), + }, + }, + server: { middlewareMode: true }, + }); + const entry = (await server.ssrLoadModule( + path.resolve( + import.meta.dirname, + "../packages/vinext/src/server/app-request-stage-independent-entry.ts", + ), + )) as { + handleRequestStage( + request: Request, + env: unknown, + ctx: undefined, + dispatchResponseStage: typeof dispatch, + ): Promise; + }; + const response = await entry.handleRequestStage( + new Request("https://example.com/middleware-terminal?__vinext_cacheability_probe=one", { + headers: { + [VINEXT_CACHEABILITY_PROBE_HEADER]: "1", + [VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER]: serializeWorkerCacheabilityProbeRoute({ + kind: "app-page", + pattern: "/middleware-terminal", + }), + [VINEXT_PRERENDER_SECRET_HEADER]: "worker-prerender-secret", + }, + }), + undefined, + undefined, + dispatch, + ); + + expect(dispatch).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + kind: "app-page", + pattern: "/middleware-terminal", + scope: "identity", + state: "dynamic", + status: 307, + version: 1, + }); + expect(capturedRequests).toHaveLength(1); + expect(capturedRequests[0]?.headers.get(VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER)).toBeNull(); + } finally { + await server?.close(); + Reflect.deleteProperty(globalThis, CAPTURE_RSC_REQUEST); + } + }); + + it("validates CDN routing and stamps build identity at the request-stage boundary", async () => { + // No Next.js test port applies: CDN adapter validation and staged Worker + // boundaries are vinext deployment contracts. + const capturedRequests: Request[] = []; + const capturedEnvs: unknown[] = []; + Reflect.set(globalThis, CAPTURE_RSC_REQUEST, (request: Request) => { + capturedRequests.push(request); + }); + const adapter: CdnCacheAdapter = { + ownsBackgroundRevalidation: false, + async get() { + return null; + }, + async set() {}, + buildResponseHeaders() { + return {}; + }, + buildResponseIdentityHeaders() { + return { "X-Test-Build-Identity": "build-a" }; + }, + validateRequest(request) { + return request.headers.has("X-Reject-Stage") + ? new Response("retry", { status: 503 }) + : null; + }, + async revalidateTag() {}, + }; + Reflect.set(globalThis, REGISTER_CDN_ADAPTER, (env: unknown) => { + capturedEnvs.push(env); + setCdnCacheAdapter(adapter); + }); + + let server: Awaited> | undefined; + try { + server = await createServer({ + appType: "custom", + configFile: false, + logLevel: "silent", + plugins: [workerEntryVirtualModules()], + resolve: { + alias: { + "vinext/shims": path.resolve(import.meta.dirname, "../packages/vinext/src/shims"), + }, + }, + server: { middlewareMode: true }, + }); + const entry = (await server.ssrLoadModule( + path.resolve( + import.meta.dirname, + "../packages/vinext/src/server/app-request-stage-independent-entry.ts", + ), + )) as { + handleRequestStage( + request: Request, + env: unknown, + ctx: undefined, + dispatchResponseStage: ( + request: Request, + props: unknown, + options: unknown, + ) => Promise, + ): Promise; + }; + const env = { binding: "value" }; + const rejected = await entry.handleRequestStage( + new Request("https://example.com/rejected", { + headers: { Accept: "text/html", "X-Reject-Stage": "1" }, + }), + env, + undefined, + async () => new Response("unused"), + ); + const accepted = await entry.handleRequestStage( + new Request("https://example.com/accepted", { headers: { Accept: "text/html" } }), + env, + undefined, + async () => new Response("unused"), + ); + const readinessDispatch = vi.fn< + (request: Request, props: unknown, options: unknown) => Promise + >( + async () => + new Response(null, { + status: 204, + headers: { "Cache-Control": "no-store", "X-Vinext-Prerender-Readiness": "1" }, + }), + ); + const readiness = await entry.handleRequestStage( + new Request("https://example.com/__vinext/prerender/readiness?attempt=request-stage", { + headers: { + "X-Vinext-Expected-Worker-Version": "version-a", + "X-Vinext-Prerender-Secret": "worker-prerender-secret", + }, + }), + env, + undefined, + readinessDispatch, + ); + + expect(rejected.status).toBe(503); + expect(await rejected.text()).toBe("retry"); + expect(rejected.headers.get("X-Test-Build-Identity")).toBe("build-a"); + expect(await accepted.text()).toBe("ok"); + expect(accepted.headers.get("X-Test-Build-Identity")).toBe("build-a"); + expect(readiness.status).toBe(204); + expect(readinessDispatch).toHaveBeenCalledWith( + expect.any(Request), + { kind: "readiness-test" }, + { cache: "bypass" }, + ); + const readinessStageRequest = readinessDispatch.mock.calls[0]![0] as Request; + expect(readinessStageRequest.headers.get("X-Vinext-Expected-Worker-Version")).toBe( + "version-a", + ); + expect(readinessStageRequest.headers.get("X-Vinext-Prerender-Secret")).toBeNull(); + expect(capturedRequests).toHaveLength(1); + expect(capturedEnvs).toEqual([env, env, env]); + } finally { + await server?.close(); + setCdnCacheAdapter(new DefaultCdnCacheAdapter()); + Reflect.deleteProperty(globalThis, CAPTURE_RSC_REQUEST); + Reflect.deleteProperty(globalThis, REGISTER_CDN_ADAPTER); + } + }); + it("restores prerender route params only for the server-owned Node context", async () => { // No Next.js test port applies: these headers and this Worker boundary are vinext-specific. const capturedRequests: Request[] = []; diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 8389c2976..95b751a27 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -1,6 +1,6 @@ import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { computeRscCacheBustingSearchParam, createRscRequestHeaders, @@ -8,7 +8,13 @@ import { VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM, VINEXT_RSC_VARY_HEADER, } from "../packages/vinext/src/server/app-rsc-cache-busting.js"; -import { createAppRscHandler } from "../packages/vinext/src/server/app-rsc-handler.js"; +import { createAppRscHandler } from "../packages/vinext/src/server/app-rsc-combined-handler.js"; +import { + APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + type AppWorkerResponseStageProps, + type DispatchAppWorkerResponseStage, +} from "../packages/vinext/src/server/app-worker-stages.js"; +import { PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER } from "../packages/vinext/src/server/worker-stages.js"; import { createAppRscRouteMatcher } from "../packages/vinext/src/server/app-rsc-route-matching.js"; import type { AppRouteTreePrefetchRoute } from "../packages/vinext/src/server/app-route-tree-prefetch.js"; import { createArtifactCompatibilityEnvelope } from "../packages/vinext/src/server/artifact-compatibility.js"; @@ -23,6 +29,8 @@ import { VINEXT_CLIENT_REUSE_MANIFEST_HEADER, VINEXT_INTERCEPTION_ID_HEADER, VINEXT_MW_CTX_HEADER, + VINEXT_PARAMS_HEADER, + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, } from "../packages/vinext/src/server/headers.js"; import { applyAppMiddleware } from "../packages/vinext/src/server/app-middleware.js"; import type { NextRequest } from "../packages/vinext/src/shims/server.js"; @@ -34,146 +42,1343 @@ import { import type { MiddlewareModule } from "../packages/vinext/src/server/middleware-runtime.js"; import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js"; import { + getRevalidateSecret, + PRERENDER_REVALIDATE_HEADER, + PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER, +} from "../packages/vinext/src/server/isr-cache.js"; +import { + cookies as requestCookies, + draftMode, getHeadersContext, headers as requestHeaders, } from "../packages/vinext/src/shims/headers.js"; import { readStaticFileSignal } from "../packages/vinext/src/server/static-file-signal.js"; -import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js"; +import { + runWithExecutionContext, + type ExecutionContextLike, +} from "../packages/vinext/src/shims/request-context.js"; import { CACHEABILITY_REQUEST_STATE, type RouteCacheabilityState, } from "../packages/vinext/src/shims/cacheability-classification.js"; +import { + DefaultCdnCacheAdapter, + setCdnCacheAdapter, + type CdnCacheAdapter, +} from "../packages/vinext/src/shims/cdn-cache.js"; + +type TestRoute = { + __loadPage?: unknown; + __loadRouteHandler?: unknown; + isDynamic: boolean; + layouts?: readonly unknown[]; + layoutTreePositions?: readonly number[]; + params?: readonly string[]; + page?: { default?: unknown } | null; + pattern: string; + rootParamNames?: readonly string[]; + routeHandler?: { GET?: () => Response; runtime?: string } | null; + routeSegments: readonly string[]; + slots?: AppRouteTreePrefetchRoute["slots"]; +}; + +type HandlerOptions = Parameters>[0]; +type TestHandlerOptions = HandlerOptions & { + metadataRoutes?: readonly MetadataRuntimeRoute[]; + middlewareFilePath?: string | null; + isMiddlewareProxy?: boolean; + middlewareModule?: MiddlewareModule | null; +}; +type DispatchMatchedRouteHandler = HandlerOptions["dispatchMatchedRouteHandler"]; + +function createPageRoute(overrides: Partial = {}): TestRoute { + return { + __loadPage() {}, + isDynamic: false, + page: { default() {} }, + pattern: "/about", + routeSegments: ["about"], + ...overrides, + }; +} + +function createHandler(overrides: Partial = {}) { + const route = createPageRoute(); + + return createAppRscHandler({ + basePath: "/docs", + buildId: overrides.buildId ?? "build-id", + clearRequestContext: overrides.clearRequestContext ?? (() => {}), + configHeaders: overrides.configHeaders ?? [ + { + source: "/about", + headers: [{ key: "x-test-header", value: "applied" }], + }, + ], + configRedirects: overrides.configRedirects ?? [], + configRewrites: overrides.configRewrites ?? { + afterFiles: [], + beforeFiles: [], + fallback: [], + }, + draftModeSecret: overrides.draftModeSecret ?? "test-draft-secret", + dispatchMatchedPage: + overrides.dispatchMatchedPage ?? + (async () => new Response("page", { status: 200, headers: { "x-from-dispatch": "page" } })), + dispatchMatchedRouteHandler: + overrides.dispatchMatchedRouteHandler ?? (async () => new Response("route", { status: 200 })), + ensureInstrumentation: overrides.ensureInstrumentation, + handleProgressiveActionRequest: + "handleProgressiveActionRequest" in overrides + ? overrides.handleProgressiveActionRequest + : async () => null, + handleMetadataRouteRequest: + overrides.handleMetadataRouteRequest ?? + (overrides.metadataRoutes + ? (cleanPathname) => + handleMetadataRouteRequest({ + metadataRoutes: overrides.metadataRoutes!, + cleanPathname, + makeThenableParams, + }) + : undefined), + handleServerActionRequest: + "handleServerActionRequest" in overrides + ? overrides.handleServerActionRequest + : async () => null, + isMetadataRoutePath: + overrides.isMetadataRoutePath ?? + (overrides.metadataRoutes + ? (cleanPathname) => isMetadataRouteRequestPath(overrides.metadataRoutes!, cleanPathname) + : undefined), + i18nConfig: overrides.i18nConfig ?? null, + imageConfig: overrides.imageConfig, + isMetadataRoute: overrides.isMetadataRoute, + isDev: overrides.isDev ?? true, + hasInterceptionId: overrides.hasInterceptionId ?? (() => false), + matchInterceptRoute: overrides.matchInterceptRoute, + matchRoute: + overrides.matchRoute ?? + ((pathname: string) => + pathname === "/about" + ? { + params: {}, + route, + } + : null), + matchRequestRoute: overrides.matchRequestRoute, + runMiddleware: + overrides.runMiddleware ?? + (overrides.middlewareModule + ? (options) => + applyAppMiddleware({ + basePath: "/docs", + ...options, + filePath: overrides.middlewareFilePath ?? undefined, + i18nConfig: overrides.i18nConfig ?? null, + isProxy: overrides.isMiddlewareProxy ?? false, + module: overrides.middlewareModule!, + trailingSlash: overrides.trailingSlash ?? false, + }) + : undefined), + publicFiles: overrides.publicFiles ?? new Set(), + registerCacheAdapters: () => {}, + renderNotFound: overrides.renderNotFound ?? (async () => null), + renderPagesFallback: overrides.renderPagesFallback, + renderResponseStageLocally: overrides.renderResponseStageLocally, + rootParamNamesByPattern: overrides.rootParamNamesByPattern, + setNavigationContext: overrides.setNavigationContext ?? (() => {}), + staticParamsMap: overrides.staticParamsMap ?? {}, + trailingSlash: overrides.trailingSlash ?? false, + validateDevRequestOrigin: overrides.validateDevRequestOrigin ?? (() => null), + }); +} + +function prerenderRouteParamsHeader(payload: unknown): string { + return encodeURIComponent(JSON.stringify(payload)); +} + +function cacheabilityContext(state: RouteCacheabilityState): ExecutionContextLike { + const context: ExecutionContextLike = { waitUntil() {} }; + Reflect.set(context, CACHEABILITY_REQUEST_STATE, state); + return context; +} + +function useSplitPolicyAdapter(): void { + setCdnCacheAdapter({ + buildResponseHeaders: ({ cacheControl }) => ({ "Cache-Control": cacheControl }), + ownsBackgroundRevalidation: false, + responsePolicyHeaderNames: ["CDN-Cache-Control"], + async get() { + return null; + }, + async revalidateTag() {}, + async set() {}, + }); +} + +afterEach(() => setCdnCacheAdapter(new DefaultCdnCacheAdapter())); + +describe("createAppRscHandler", () => { + it("dispatches a matched GET through the App response stage and composes request-stage headers", async () => { + const dispatchResponseStage = vi.fn(async (_request, props) => { + expect(props).toMatchObject({ + kind: "app-page", + buildId: "build-id", + bypassInterceptionContextCache: false, + interceptionId: null, + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: "https://example.test", + routePattern: "/about", + routePathname: "/about", + }); + return new Response("response-stage", { headers: { "x-response-stage": "yes" } }); + }); + const handler = createHandler(); + + const response = await handler( + new Request("https://example.test/docs/about"), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + expect(response.status).toBe(200); + expect(response.headers.get("x-test-header")).toBe("applied"); + expect(response.headers.get("x-response-stage")).toBe("yes"); + expect(await response.text()).toBe("response-stage"); + }); + + it("shares the method-invariant App page representation for HEAD and strips its body", async () => { + const dispatchResponseStage = vi.fn(async () => + Promise.resolve(new Response("page-body", { headers: { "x-generation": "one" } })), + ); + const handler = createHandler({ configHeaders: [] }); + + const response = await handler( + new Request("https://example.test/docs/about", { method: "HEAD" }), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage.mock.calls[0]?.[0].method).toBe("GET"); + expect(dispatchResponseStage.mock.calls[0]?.[1].kind).toBe("app-page"); + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + expect(response.headers.get("x-generation")).toBe("one"); + expect(response.body).toBeNull(); + }); + + it("preserves HEAD for App route handlers and strips their response body", async () => { + const route = createPageRoute({ + __loadPage: undefined, + __loadRouteHandler() {}, + page: null, + pattern: "/route", + routeHandler: { GET: () => new Response("get") }, + routeSegments: ["route"], + }); + const dispatchResponseStage = vi.fn(async () => + Promise.resolve(new Response("head-handler-body", { headers: { "x-handler": "head" } })), + ); + const handler = createHandler({ + configHeaders: [], + matchRoute: (pathname) => (pathname === "/route" ? { params: {}, route } : null), + }); + + const response = await handler( + new Request("https://example.test/docs/route", { method: "HEAD" }), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage.mock.calls[0]?.[0].method).toBe("HEAD"); + expect(dispatchResponseStage.mock.calls[0]?.[1].kind).toBe("app-route-handler"); + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + expect(response.headers.get("x-handler")).toBe("head"); + expect(response.body).toBeNull(); + }); + + it("bypasses the shared response stage for valid draft mode requests", async () => { + const dispatchResponseStage = vi.fn(async () => + Promise.resolve(new Response("draft")), + ); + const handler = createHandler({ configHeaders: [] }); + + await handler( + new Request("https://example.test/docs/about", { + headers: { Cookie: "__prerender_bypass=test-draft-secret" }, + }), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + }); + + it("does not treat a forged App bypass cookie as valid draft mode", async () => { + const dispatchResponseStage = vi.fn(async () => + Promise.resolve(new Response("app")), + ); + const handler = createHandler({ configHeaders: [] }); + + await handler( + new Request("https://example.test/docs/about", { + headers: { Cookie: "__prerender_bypass=forged" }, + }), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + }); + + it("bypasses the shared response stage for middleware draft transitions", async () => { + const dispatchResponseStage = vi.fn(async () => + Promise.resolve(new Response("draft")), + ); + const handler = createHandler({ + configHeaders: [], + middlewareModule: { + async default() { + (await draftMode()).enable(); + return new Response(null, { headers: { "x-middleware-next": "1" } }); + }, + }, + }); + + await handler( + new Request("https://example.test/docs/about"), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + }); + + it("transports authenticated probe intent outside the shared cache", async () => { + const dispatchResponseStage = vi.fn(async () => + Promise.resolve(new Response("probe")), + ); + const handler = createHandler({ configHeaders: [] }); + + await handler( + new Request("https://example.test/docs/about"), + null, + false, + dispatchResponseStage, + "probe", + ); + + expect(dispatchResponseStage.mock.calls[0]?.[1].cacheability).toMatchObject({ + policyHeaders: null, + probeMode: "probe", + resolvedRoutePathname: "/about", + }); + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + }); + + it("transports matched positive config cache policy in stage identity", async () => { + useSplitPolicyAdapter(); + const dispatchResponseStage = vi.fn(async () => + Promise.resolve( + new Response("stage", { + headers: { "Cache-Control": "public, max-age=0, must-revalidate" }, + }), + ), + ); + const handler = createHandler({ + configHeaders: [ + { + source: "/about", + headers: [ + { key: "Cache-Control", value: "public, s-maxage=60" }, + { key: "Vary", value: "x-visitor" }, + ], + }, + { + source: "/about", + has: [{ type: "cookie", key: "preview", value: "1" }], + headers: [{ key: "CDN-Cache-Control", value: "public, s-maxage=120" }], + }, + ], + }); + + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + mode: "admit", + }; + const response = await runWithExecutionContext(cacheabilityContext(state), () => + handler( + new Request("https://example.test/docs/about", { + headers: { Cookie: "preview=1" }, + }), + null, + false, + dispatchResponseStage, + ), + ); + + expect(dispatchResponseStage.mock.calls[0]?.[1].cacheability.policyHeaders).toEqual([ + ["Cache-Control", "public, s-maxage=60"], + ["CDN-Cache-Control", "public, s-maxage=120"], + ["Vary", "x-visitor"], + ]); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=0, must-revalidate"); + expect(state.forcedDynamicReason).toBeUndefined(); + }); + + it("transports matched config cache policy to a hybrid Pages response stage", async () => { + useSplitPolicyAdapter(); + const dispatchResponseStage = vi.fn(async () => + Promise.resolve(new Response("pages-stage")), + ); + const handler = createHandler({ + configHeaders: [ + { + source: "/pages", + headers: [ + { key: "Cache-Control", value: "public, s-maxage=36" }, + { key: "Vary", value: "x-visitor" }, + ], + }, + { + source: "/pages", + has: [{ type: "cookie", key: "preview", value: "1" }], + headers: [ + { key: "CDN-Cache-Control", value: "public, s-maxage=120" }, + { key: "x-config-variant", value: "preview" }, + ], + }, + ], + matchRequestRoute: () => null, + matchRoute: () => null, + renderPagesFallback: async (options) => + options.dispatchPagesResponseStage?.(options.request, "page") ?? null, + }); + + await handler( + new Request("https://example.test/docs/pages", { + headers: { Cookie: "preview=1" }, + }), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(dispatchResponseStage.mock.calls[0]?.[1]).toMatchObject({ + kind: "hybrid-pages", + cacheability: { + policyHeaders: [ + ["Cache-Control", "public, s-maxage=36"], + ["CDN-Cache-Control", "public, s-maxage=120"], + ["Vary", "x-visitor"], + ], + }, + preHandlerHeaders: [ + ["cache-control", "public, s-maxage=36"], + ["cdn-cache-control", "public, s-maxage=120"], + ["vary", "x-visitor"], + ["x-config-variant", "preview"], + ], + }); + }); + + it("keeps hybrid static Pages renders with request-aware Documents outside the shared stage", async () => { + // Next.js supplies req/res to custom _document.getInitialProps for GSP/ISR + // pages, so their HTML can remain request-specific despite static data. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/render.tsx + const dispatchResponseStage = vi.fn(async () => + Promise.resolve(new Response("pages-stage")), + ); + const handler = createHandler({ + configHeaders: [], + matchRequestRoute: () => null, + matchRoute: () => null, + renderPagesFallback: async (options) => + options.dispatchPagesResponseStage?.(options.request, "page", "static", true) ?? null, + }); + + await handler( + new Request("https://example.test/docs/pages"), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(dispatchResponseStage.mock.calls[0]?.[1]).toMatchObject({ + kind: "hybrid-pages", + preHandlerHeaders: [], + }); + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + }); + + it.each(["__prerender_bypass", "__next_preview_data"])( + "keeps hybrid Pages requests carrying the %s preview cookie outside the shared stage", + async (cookieName) => { + const dispatchResponseStage = vi.fn(async () => + Promise.resolve(new Response("pages-stage")), + ); + const handler = createHandler({ + configHeaders: [], + matchRequestRoute: () => null, + matchRoute: () => null, + renderPagesFallback: async (options) => + options.dispatchPagesResponseStage?.(options.request, "page", "static") ?? null, + }); + + await handler( + new Request("https://example.test/docs/pages", { + headers: { Cookie: `${cookieName}=stale-or-invalid` }, + }), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + }, + ); + + it("clears shared Pages stage metadata when outer config makes the response private", async () => { + const adapter: CdnCacheAdapter = { + ownsBackgroundRevalidation: false, + responsePolicyHeaderNames: ["CDN-Cache-Control"], + async get() { + return null; + }, + async set() {}, + buildResponseHeaders({ cacheControl }) { + return { + "Cache-Control": cacheControl, + "CDN-Cache-Control": null, + "Cache-Tag": null, + }; + }, + async revalidateTag() {}, + }; + setCdnCacheAdapter(adapter); + try { + const dispatchResponseStage = vi.fn(async () => + Promise.resolve( + new Response("pages-stage", { + headers: { + "Cache-Control": "public, max-age=0, must-revalidate", + "CDN-Cache-Control": "public, max-age=60", + "Cache-Tag": "pages", + }, + }), + ), + ); + const handler = createHandler({ + configHeaders: [ + { + source: "/pages", + headers: [{ key: "Cache-Control", value: "private, no-store" }], + }, + ], + matchRequestRoute: () => null, + matchRoute: () => null, + renderPagesFallback: async (options) => + options.dispatchPagesResponseStage?.(options.request, "page") ?? null, + }); + + const response = await handler( + new Request("https://example.test/docs/pages"), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(response.headers.get("cdn-cache-control")).toBeNull(); + expect(response.headers.get("cache-tag")).toBeNull(); + } finally { + setCdnCacheAdapter(new DefaultCdnCacheAdapter()); + } + }); + + it("lets hybrid getServerSideProps override an earlier private config policy", async () => { + const dispatchResponseStage = vi.fn(async () => + Promise.resolve( + new Response("pages-stage", { + headers: { "Cache-Control": "public, s-maxage=30" }, + }), + ), + ); + const handler = createHandler({ + configHeaders: [ + { + source: "/pages", + headers: [{ key: "Cache-Control", value: "private, no-store" }], + }, + ], + matchRequestRoute: () => null, + matchRoute: () => null, + renderPagesFallback: async (options) => + options.dispatchPagesResponseStage?.(options.request, "page", "server") ?? null, + }); + + const response = await handler( + new Request("https://example.test/docs/pages"), + null, + false, + dispatchResponseStage, + ); + + expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=30"); + }); + + it.each([ + ["private, no-store", "public, s-maxage=30"], + ["public, s-maxage=60", "private, no-store"], + ])( + "lets hybrid getInitialProps replace config policy %s with %s", + async (configPolicy, renderedPolicy) => { + // Ported from Next.js routing order: custom-route headers run before + // Pages rendering and getInitialProps can replace them on the response. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-server.ts + const dispatchResponseStage = vi.fn(async () => + Promise.resolve( + new Response("pages-stage", { + headers: { + "Cache-Control": renderedPolicy, + [PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER]: "request-time", + }, + }), + ), + ); + const handler = createHandler({ + configHeaders: [ + { + source: "/pages", + headers: [{ key: "Cache-Control", value: configPolicy }], + }, + ], + matchRequestRoute: () => null, + matchRoute: () => null, + renderPagesFallback: async (options) => + options.dispatchPagesResponseStage?.(options.request, "page", "none") ?? null, + }); + + const response = await handler( + new Request("https://example.test/docs/pages"), + null, + false, + dispatchResponseStage, + ); + + expect(response.headers.get("Cache-Control")).toBe(renderedPolicy); + expect(response.headers.has(PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER)).toBe(false); + }, + ); + + it("bypasses the staged cache for an unverified interception context", async () => { + const targetRoute = createPageRoute({ pattern: "/photos/1", routeSegments: ["photos", "1"] }); + const dispatchResponseStage = vi.fn( + async () => + new Response("response-stage", { headers: { "Cache-Control": "public, max-age=3600" } }), + ); + const handler = createHandler({ + configHeaders: [], + matchInterceptRoute: () => null, + matchRoute: (pathname: string) => + pathname === "/photos/1" ? { params: {}, route: targetRoute } : null, + }); + const headers = createRscRequestHeaders({ interceptionContext: "/attacker-selected" }); + const rscUrl = await createRscRequestUrl("/docs/photos/1", headers); + + const response = await handler( + new Request(`https://example.test${rscUrl}`, { headers }), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(dispatchResponseStage.mock.calls[0]?.[1]).toMatchObject({ + bypassInterceptionContextCache: true, + interceptionContext: "/attacker-selected", + interceptionId: null, + }); + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + it("dispatches authenticated hybrid Pages revalidation outside the shared cache", async () => { + const dispatchResponseStage = vi.fn( + async () => new Response(null), + ); + const handler = createHandler({ + configHeaders: [], + matchRequestRoute: () => null, + matchRoute: () => null, + renderPagesFallback: async (options) => + options.dispatchPagesResponseStage?.(options.request, "page") ?? null, + }); + + await handler( + new Request("https://example.test/docs/pages", { + headers: { + [PRERENDER_REVALIDATE_HEADER]: getRevalidateSecret(), + [PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER]: "1", + }, + method: "HEAD", + }), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(dispatchResponseStage.mock.calls[0]?.[0].method).toBe("HEAD"); + expect( + dispatchResponseStage.mock.calls[0]?.[0].headers.get( + PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER, + ), + ).toBe("1"); + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + }); + + it("dispatches authenticated hybrid Pages probes outside the shared cache", async () => { + const dispatchResponseStage = vi.fn(async () => + Promise.resolve(new Response("probe")), + ); + const handler = createHandler({ + configHeaders: [], + matchRequestRoute: () => null, + matchRoute: () => null, + renderPagesFallback: async (options) => + options.dispatchPagesResponseStage?.(options.request, "page") ?? null, + }); + + await handler( + new Request("https://example.test/docs/pages"), + null, + false, + dispatchResponseStage, + "probe", + ); + + expect(dispatchResponseStage.mock.calls[0]?.[1]).toMatchObject({ + kind: "hybrid-pages", + cacheability: { probeMode: "probe" }, + }); + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + }); + + it("carries hybrid Pages data and API identity in response-stage props", async () => { + const dispatchResponseStage = vi.fn( + async () => new Response("stage"), + ); + const dataHandler = createHandler({ + configHeaders: [], + matchRequestRoute: () => null, + matchRoute: () => null, + renderPagesFallback: async (options) => + options.dispatchPagesResponseStage?.(options.pagesDataRequest ?? options.request, "page") ?? + null, + }); + + await dataHandler( + new Request("https://example.test/docs/_next/data/build-id/pages.json"), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage.mock.calls[0]?.[1]).toMatchObject({ isDataRequest: true }); + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + + dispatchResponseStage.mockClear(); + const apiHandler = createHandler({ + configHeaders: [], + matchRequestRoute: () => null, + matchRoute: () => null, + renderPagesFallback: async (options) => + options.dispatchPagesResponseStage?.(options.request, "api") ?? null, + }); + await apiHandler( + new Request("https://example.test/docs/api/pages"), + null, + false, + dispatchResponseStage, + ); + expect(dispatchResponseStage.mock.calls[0]?.[1]).toMatchObject({ resourceKind: "api" }); + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + }); + + // Ported from Next.js cross-router res.revalidate() coverage: + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-static/pages/api/revalidate.js + it("dispatches authenticated App route revalidation outside the shared cache", async () => { + const dispatchResponseStage = vi.fn( + async () => new Response(null), + ); + const handler = createHandler({ configHeaders: [] }); + + await handler( + new Request("https://example.test/docs/about", { + headers: { + [PRERENDER_REVALIDATE_HEADER]: getRevalidateSecret(), + [PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER]: "1", + }, + method: "HEAD", + }), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(dispatchResponseStage.mock.calls[0]?.[0].method).toBe("HEAD"); + expect( + dispatchResponseStage.mock.calls[0]?.[0].headers.get( + PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER, + ), + ).toBe("1"); + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + }); + + it("composes routed params and rendered URL above a shared RSC cache hit", async () => { + const route = createPageRoute({ + isDynamic: true, + params: ["slug"], + pattern: "/items/:slug", + routeSegments: ["items", "[slug]"], + }); + const handler = createHandler({ + configHeaders: [], + matchRoute: (pathname) => + pathname === "/items/one" ? { params: { slug: "one" }, route } : null, + }); + const headers = createRscRequestHeaders(); + const url = await createRscRequestUrl("/docs/items/one?tab=current", headers); + + const response = await handler( + new Request(`https://example.test${url}`, { headers }), + null, + false, + async () => + new Response("cached-rsc", { + headers: { "X-Vinext-Cache": "HIT" }, + }), + ); + + expect(JSON.parse(decodeURIComponent(response.headers.get(VINEXT_PARAMS_HEADER)!))).toEqual({ + slug: "one", + }); + expect(decodeURIComponent(response.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)!)).toBe( + "/items/one?tab=current", + ); + }); + + it("preserves the raw Cookie header when middleware does not change cookies", async () => { + const rawCookie = "encoded=hello%20world; spaced=value"; + const dispatchResponseStage = vi.fn(async (stageRequest: Request) => { + expect(stageRequest.headers.get("cookie")).toBe(rawCookie); + return new Response("response-stage"); + }); + const handler = createHandler(); + + await handler( + new Request("https://example.test/docs/about", { + headers: { Cookie: rawCookie }, + }), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + }); + + it("normalizes the root route identity before response-stage dispatch", async () => { + const rootRoute = createPageRoute({ pattern: "/", routeSegments: [] }); + const dispatchResponseStage = vi.fn( + async (_request: Request, _props: AppWorkerResponseStageProps) => new Response("root-stage"), + ); + const handler = createHandler({ + matchRequestRoute: (pathname) => + pathname === "/" || pathname === "" ? { params: {}, route: rootRoute } : null, + matchRoute: (pathname) => + pathname === "/" || pathname === "" ? { params: {}, route: rootRoute } : null, + }); + + const response = await handler( + new Request("https://example.test/docs/"), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(dispatchResponseStage.mock.calls[0]?.[1]).toMatchObject({ + kind: "app-page", + routePathname: "/", + }); + expect(await response.text()).toBe("root-stage"); + }); + + it.each([ + ["Cache-Control", "private, no-store"], + ["CDN-Cache-Control", "no-cache"], + ["Cloudflare-CDN-Cache-Control", "private"], + ])("keeps staged App rendering reusable beneath config %s: %s", async (name, value) => { + const dispatchMatchedPage = vi.fn(async () => new Response("local-page")); + const dispatchResponseStage = vi.fn(async () => + Promise.resolve(new Response("shared-stage")), + ); + const handler = createHandler({ + configHeaders: [{ source: "/about", headers: [{ key: name, value }] }], + dispatchMatchedPage, + }); + + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + mode: "admit", + }; + const response = await runWithExecutionContext(cacheabilityContext(state), () => + handler(new Request("https://example.test/docs/about"), null, false, dispatchResponseStage), + ); + + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(dispatchMatchedPage).not.toHaveBeenCalled(); + expect(state.finalResponseVetoReason).toBeUndefined(); + expect(response.headers.get(name)).toBe(value); + expect(await response.text()).toBe("shared-stage"); + }); + + it("keeps staged App rendering reusable beneath request-dependent middleware Vary", async () => { + const dispatchMatchedPage = vi.fn(async () => new Response("local-page")); + const dispatchResponseStage = vi.fn(async () => + Promise.resolve(new Response("shared-stage")), + ); + const handler = createHandler({ + configHeaders: [], + dispatchMatchedPage, + middlewareModule: { + default() { + return new Response(null, { + headers: { "x-middleware-next": "1", Vary: "Cookie" }, + }); + }, + }, + }); + + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + mode: "admit", + }; + const response = await runWithExecutionContext(cacheabilityContext(state), () => + handler(new Request("https://example.test/docs/about"), null, false, dispatchResponseStage), + ); + + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(dispatchMatchedPage).not.toHaveBeenCalled(); + expect(dispatchResponseStage.mock.calls[0]?.[1].cacheability.policyHeaders).toEqual([ + ["Vary", "Cookie"], + ]); + expect(state.forcedDynamicReason).toBeUndefined(); + expect(response.headers.get("Vary")).toContain("Cookie"); + expect(await response.text()).toBe("shared-stage"); + }); + + it("bypasses shared App rendering when middleware changes downstream request headers", async () => { + // Ported from Next.js: + // test/e2e/middleware-request-header-overrides/test/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-request-header-overrides/test/index.test.ts + const dispatchResponseStage = vi.fn(async (request) => + Response.json({ visitor: request.headers.get("x-visitor") }), + ); + const handler = createHandler({ + configHeaders: [], + middlewareModule: { + default(request: NextRequest) { + const headers = new Headers(request.headers); + headers.set("x-visitor", request.headers.get("x-original-visitor") ?? "anonymous"); + return new Response(null, { + headers: { + "x-middleware-next": "1", + "x-middleware-override-headers": [...headers.keys()].join(","), + ...Object.fromEntries( + [...headers].map(([name, value]) => [`x-middleware-request-${name}`, value]), + ), + }, + }); + }, + }, + }); + + const response = await handler( + new Request("https://example.test/docs/about", { + headers: { "x-original-visitor": "visitor-a" }, + }), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + await expect(response.json()).resolves.toEqual({ visitor: "visitor-a" }); + }); + + it("dispatches middleware cookie overlays with cache bypass and preserves raw headers", async () => { + const dispatchMatchedPage = vi.fn(async () => { + expect((await requestCookies()).get("session")?.value).toBe("middleware-value"); + expect((await requestHeaders()).get("cookie")).toBe("session=original"); + return new Response("cookie-render"); + }); + const responseHandler = createHandler({ dispatchMatchedPage }); + const renderResponseStageLocally = vi.fn((stageRequest, props) => + responseHandler.handleResponseStage(stageRequest, null, props), + ); + const requestHandler = createHandler({ + configHeaders: [], + middlewareModule: { + default() { + return new Response(null, { + headers: { + "x-middleware-next": "1", + "x-middleware-set-cookie": "session=middleware-value; Path=/", + }, + }); + }, + }, + renderResponseStageLocally, + }); + const dispatchResponseStage = vi.fn((stageRequest, props) => + responseHandler.handleResponseStage(stageRequest, null, props), + ); + + const response = await requestHandler( + new Request("https://example.test/docs/about", { + headers: { Cookie: "session=original" }, + }), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + expect(renderResponseStageLocally).not.toHaveBeenCalled(); + expect(dispatchMatchedPage).toHaveBeenCalledOnce(); + expect(await response.text()).toBe("cookie-render"); + }); + + it("carries a middleware CSP nonce into a bypassed response-stage render", async () => { + const dispatchMatchedPage = vi.fn(async (options) => { + expect(options.scriptNonce).toBe("stage-nonce"); + return new Response("nonce-render"); + }); + const responseHandler = createHandler({ dispatchMatchedPage }); + const renderResponseStageLocally = vi.fn((stageRequest, props) => + responseHandler.handleResponseStage(stageRequest, null, props), + ); + const requestHandler = createHandler({ + configHeaders: [], + middlewareModule: { + default() { + return new Response(null, { + headers: { + "content-security-policy": "script-src 'nonce-stage-nonce'", + "x-middleware-next": "1", + }, + }); + }, + }, + renderResponseStageLocally, + }); + const dispatchResponseStage = vi.fn((stageRequest, props) => + responseHandler.handleResponseStage(stageRequest, null, props), + ); + + const response = await requestHandler( + new Request("https://example.test/docs/about"), + null, + false, + dispatchResponseStage, + ); -type TestRoute = { - __loadPage?: unknown; - __loadRouteHandler?: unknown; - isDynamic: boolean; - layouts?: readonly unknown[]; - layoutTreePositions?: readonly number[]; - params?: readonly string[]; - page?: { default?: unknown } | null; - pattern: string; - rootParamNames?: readonly string[]; - routeHandler?: { GET?: () => Response; runtime?: string } | null; - routeSegments: readonly string[]; - slots?: AppRouteTreePrefetchRoute["slots"]; -}; + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + expect(renderResponseStageLocally).not.toHaveBeenCalled(); + expect(dispatchMatchedPage).toHaveBeenCalledOnce(); + expect(await response.text()).toBe("nonce-render"); + }); -type HandlerOptions = Parameters>[0]; -type TestHandlerOptions = HandlerOptions & { - metadataRoutes?: readonly MetadataRuntimeRoute[]; - middlewareFilePath?: string | null; - isMiddlewareProxy?: boolean; - middlewareModule?: MiddlewareModule | null; -}; -type DispatchMatchedRouteHandler = HandlerOptions["dispatchMatchedRouteHandler"]; + it("restores hybrid Pages pre-handler headers inside the response stage", async () => { + // Next.js exposes middleware response headers through `res.getHeader()` in + // getServerSideProps. Keep the same response snapshot across the App to + // Pages stage boundary. + // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-custom-matchers/app/pages/index.js + const renderPagesFallback = vi.fn(async (options) => { + expect(options.initialResponseHeaders?.get("x-config-variant")).toBe("preview"); + expect(options.initialResponseHeaders?.get("x-from-middleware")).toBe("present"); + return new Response("pages"); + }); + const handler = createHandler({ + matchRequestRoute: () => null, + matchRoute: () => null, + renderPagesFallback, + }); -function createPageRoute(overrides: Partial = {}): TestRoute { - return { - __loadPage() {}, - isDynamic: false, - page: { default() {} }, - pattern: "/about", - routeSegments: ["about"], - ...overrides, - }; -} + const response = await handler.handleResponseStage( + new Request("https://example.test/docs/pages"), + null, + { + allowRscDocumentFallback: false, + appRouteMatch: null, + buildId: "build-id", + cacheability: { policyHeaders: null, probeMode: null, resolvedRoutePathname: "/pages" }, + canonicalPathname: "/pages", + cleanPathname: "/pages", + draftModeCookie: null, + isDataRequest: false, + isRscRequest: false, + kind: "hybrid-pages", + matchKind: "static", + middlewareCookieOverlay: null, + preHandlerHeaders: [ + ["x-config-variant", "preview"], + ["x-from-middleware", "present"], + ], + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: "https://example.test", + requestUrl: "https://example.test/docs/pages", + resolvedUrl: "/pages", + resourceKind: "page", + scriptNonce: null, + }, + { cache: "shared" }, + ); -function createHandler(overrides: Partial = {}) { - const route = createPageRoute(); + expect(response.status).toBe(200); + expect(renderPagesFallback).toHaveBeenCalledOnce(); + }); - return createAppRscHandler({ - basePath: "/docs", - buildId: overrides.buildId ?? "build-id", - clearRequestContext: overrides.clearRequestContext ?? (() => {}), - configHeaders: overrides.configHeaders ?? [ + it("rejects stale and rematched App response-stage envelopes", async () => { + const dispatchMatchedPage = vi.fn(async () => new Response("page")); + const handler = createHandler({ dispatchMatchedPage }); + const request = new Request("https://example.test/docs/about"); + const props: AppWorkerResponseStageProps = { + kind: "app-page" as const, + buildId: "stale-build", + cacheability: { policyHeaders: null, probeMode: null, resolvedRoutePathname: "/about" }, + bypassInterceptionContextCache: false, + canonicalPathname: "/about", + cleanPathname: "/about", + draftModeCookie: null, + interceptionContext: null, + interceptionId: null, + isRscRequest: false, + matchKind: "resolved" as const, + middlewareCookieOverlay: null, + mountedSlotsHeader: null, + params: {}, + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: "https://example.test", + renderMode: "navigation" as const, + resolvedUrl: "/about", + routePattern: "/about", + routePathname: "/about", + scriptNonce: null, + }; + + const staleResponse = await handler.handleResponseStage(request, null, props); + expect(staleResponse.status).toBe(409); + + const invalidRouteResponse = await handler.handleResponseStage(request, null, { + ...props, + buildId: "build-id", + routePattern: "/other", + }); + expect(invalidRouteResponse.status).toBe(400); + expect(dispatchMatchedPage).not.toHaveBeenCalled(); + }); + + it("keeps trusted response-stage classification when middleware overrides live headers", async () => { + const dispatchMatchedPage = vi.fn(async (options) => { + expect(options.isRscRequest).toBe(true); + expect(options.request.headers.get(RSC_HEADER)).toBe("0"); + return new Response("classified"); + }); + const handler = createHandler({ dispatchMatchedPage }); + const response = await handler.handleResponseStage( + new Request("https://example.test/docs/about", { headers: { [RSC_HEADER]: "0" } }), + null, { - source: "/about", - headers: [{ key: "x-test-header", value: "applied" }], + kind: "app-page", + buildId: "build-id", + cacheability: { policyHeaders: null, probeMode: null, resolvedRoutePathname: "/about" }, + bypassInterceptionContextCache: false, + canonicalPathname: "/about", + cleanPathname: "/about", + draftModeCookie: null, + interceptionContext: null, + interceptionId: null, + isRscRequest: true, + matchKind: "resolved", + middlewareCookieOverlay: null, + mountedSlotsHeader: null, + params: {}, + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: "https://example.test", + renderMode: "navigation", + resolvedUrl: "/about", + routePattern: "/about", + routePathname: "/about", + scriptNonce: null, }, - ], - configRedirects: overrides.configRedirects ?? [], - configRewrites: overrides.configRewrites ?? { - afterFiles: [], - beforeFiles: [], - fallback: [], - }, - draftModeSecret: overrides.draftModeSecret ?? "test-draft-secret", - dispatchMatchedPage: - overrides.dispatchMatchedPage ?? - (async () => new Response("page", { status: 200, headers: { "x-from-dispatch": "page" } })), - dispatchMatchedRouteHandler: - overrides.dispatchMatchedRouteHandler ?? (async () => new Response("route", { status: 200 })), - ensureInstrumentation: overrides.ensureInstrumentation, - handleProgressiveActionRequest: - "handleProgressiveActionRequest" in overrides - ? overrides.handleProgressiveActionRequest - : async () => null, - handleMetadataRouteRequest: - overrides.handleMetadataRouteRequest ?? - (overrides.metadataRoutes - ? (cleanPathname) => - handleMetadataRouteRequest({ - metadataRoutes: overrides.metadataRoutes!, - cleanPathname, - makeThenableParams, - }) - : undefined), - handleServerActionRequest: - "handleServerActionRequest" in overrides - ? overrides.handleServerActionRequest - : async () => null, - isMetadataRoutePath: - overrides.isMetadataRoutePath ?? - (overrides.metadataRoutes - ? (cleanPathname) => isMetadataRouteRequestPath(overrides.metadataRoutes!, cleanPathname) - : undefined), - i18nConfig: overrides.i18nConfig ?? null, - imageConfig: overrides.imageConfig, - isDev: overrides.isDev ?? true, - hasInterceptionId: overrides.hasInterceptionId ?? (() => false), - matchInterceptRoute: overrides.matchInterceptRoute, - matchRoute: - overrides.matchRoute ?? - ((pathname: string) => - pathname === "/about" - ? { - params: {}, - route, - } - : null), - matchRequestRoute: overrides.matchRequestRoute, - runMiddleware: - overrides.runMiddleware ?? - (overrides.middlewareModule - ? (options) => - applyAppMiddleware({ - basePath: "/docs", - ...options, - filePath: overrides.middlewareFilePath ?? undefined, - i18nConfig: overrides.i18nConfig ?? null, - isProxy: overrides.isMiddlewareProxy ?? false, - module: overrides.middlewareModule!, - trailingSlash: overrides.trailingSlash ?? false, - }) - : undefined), - publicFiles: overrides.publicFiles ?? new Set(), - registerCacheAdapters: () => {}, - renderNotFound: overrides.renderNotFound ?? (async () => null), - renderPagesFallback: overrides.renderPagesFallback, - rootParamNamesByPattern: overrides.rootParamNamesByPattern, - setNavigationContext: overrides.setNavigationContext ?? (() => {}), - staticParamsMap: overrides.staticParamsMap ?? {}, - trailingSlash: overrides.trailingSlash ?? false, - validateDevRequestOrigin: overrides.validateDevRequestOrigin ?? (() => null), + { cache: "shared" }, + ); + + expect(response.status).toBe(200); + expect(dispatchMatchedPage).toHaveBeenCalledOnce(); }); -} -function prerenderRouteParamsHeader(payload: unknown): string { - return encodeURIComponent(JSON.stringify(payload)); -} + it("renders staged App not-found responses without rerunning middleware", async () => { + const middleware = vi.fn( + () => + new Response(null, { + headers: { + "x-middleware-next": "1", + "x-response-header": "visitor-specific", + }, + }), + ); + const renderNotFound = vi.fn(async () => new Response("styled-not-found", { status: 404 })); + const setNavigationContext = vi.fn(); + const handler = createHandler({ + configHeaders: [], + middlewareModule: { default: middleware }, + renderNotFound, + setNavigationContext, + }); + const dispatchResponseStage = vi.fn( + (stageRequest: Request, props: AppWorkerResponseStageProps) => + handler.handleResponseStage(stageRequest, null, props), + ); + + const response = await handler( + new Request("https://example.test/docs/missing?from=rewrite"), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(dispatchResponseStage.mock.calls[0]?.[1]).toMatchObject({ + kind: "app-not-found", + canonicalPathname: "/missing", + cleanPathname: "/missing", + }); + expect(middleware).toHaveBeenCalledOnce(); + expect(renderNotFound).toHaveBeenCalledOnce(); + expect(setNavigationContext).toHaveBeenLastCalledWith({ + pathname: "/missing", + searchParams: new URLSearchParams("from=rewrite"), + params: {}, + }); + expect(response.status).toBe(404); + expect(response.headers.get("x-response-header")).toBe("visitor-specific"); + expect(await response.text()).toBe("styled-not-found"); + }); + + it("renders metadata reached by a rewrite in the response stage", async () => { + const metadataHandler = vi.fn( + async () => + new Response("User-agent: *\nDisallow: /private", { + headers: { "content-type": "text/plain" }, + }), + ); + const responseHandler = createHandler({ handleMetadataRouteRequest: metadataHandler }); + const requestLocalMetadataHandler = vi.fn(async () => new Response("wrong-local-handler")); + const requestHandler = createHandler({ + configRewrites: { + beforeFiles: [], + afterFiles: [{ source: "/robots-alias", destination: "/robots.txt" }], + fallback: [], + }, + handleMetadataRouteRequest: requestLocalMetadataHandler, + isMetadataRoute: (pathname) => pathname === "/robots.txt", + }); + const dispatchResponseStage = vi.fn( + (stageRequest: Request, props: AppWorkerResponseStageProps) => + responseHandler.handleResponseStage(stageRequest, null, props), + ); + + const response = await requestHandler( + new Request("https://example.test/docs/robots-alias"), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage.mock.calls[0]?.[1]).toMatchObject({ + kind: "app-metadata", + canonicalPathname: "/robots-alias", + cleanPathname: "/robots.txt", + }); + expect(metadataHandler).toHaveBeenCalledOnce(); + expect(requestLocalMetadataHandler).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/plain"); + expect(await response.text()).toContain("Disallow: /private"); + }); + + it("continues to an App page when staged metadata overclassification has no match", async () => { + const pageRoute = createPageRoute({ + pattern: "/icon/:id", + routeSegments: ["icon", "[id]"], + }); + const dispatchMatchedPage = vi.fn(async () => new Response("icon-page")); + const responseHandler = createHandler({ + dispatchMatchedPage, + handleMetadataRouteRequest: async () => null, + matchRoute: (pathname) => + pathname === "/icon/foo" ? { params: { id: "foo" }, route: pageRoute } : null, + matchRequestRoute: (pathname) => + pathname === "/icon/foo" ? { params: { id: "foo" }, route: pageRoute } : null, + }); + const requestHandler = createHandler({ + isMetadataRoute: (pathname) => pathname.startsWith("/icon/"), + matchRoute: (pathname) => + pathname === "/icon/foo" ? { params: { id: "foo" }, route: pageRoute } : null, + matchRequestRoute: (pathname) => + pathname === "/icon/foo" ? { params: { id: "foo" }, route: pageRoute } : null, + }); + const dispatchResponseStage = vi.fn( + (stageRequest: Request, props: AppWorkerResponseStageProps) => + responseHandler.handleResponseStage(stageRequest, null, props), + ); + + const response = await requestHandler( + new Request("https://example.test/docs/icon/foo"), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage.mock.calls.map((call) => call[1].kind)).toEqual([ + "app-metadata", + "app-page", + ]); + expect(dispatchMatchedPage).toHaveBeenCalledOnce(); + expect(await response.text()).toBe("icon-page"); + }); -describe("createAppRscHandler", () => { // Ported from Next.js: test/e2e/app-dir/app-basepath/index.test.ts // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/app-basepath/index.test.ts it("applies basePath: false rewrites outside the App Router basePath", async () => { @@ -731,6 +1936,7 @@ describe("createAppRscHandler", () => { : new Response(null, { headers: { "x-middleware-next": "1" } }); }); let dispatchRedirectTargetRequest: ((request: Request) => Promise) | undefined; + const dispatchResponseStage = vi.fn(async () => new Response("target response stage")); const handler = createHandler({ configHeaders: [], handleServerActionRequest: async (options) => { @@ -746,6 +1952,8 @@ describe("createAppRscHandler", () => { headers: { "next-action": "action-id", "content-type": "text/plain" }, }), null, + false, + dispatchResponseStage, ); expect(response.status).toBe(200); @@ -758,6 +1966,13 @@ describe("createAppRscHandler", () => { expect(targetResponse.status).toBe(401); expect(await targetResponse.text()).toBe("unauthorized"); expect(seenPathnames).toEqual(["/docs/about", "/docs/protected"]); + + const renderedTargetResponse = await dispatchRedirectTargetRequest!( + new Request("https://example.test/docs/about"), + ); + expect(await renderedTargetResponse.text()).toBe("target response stage"); + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(seenPathnames).toEqual(["/docs/about", "/docs/protected", "/docs/about"]); }); it.each([ @@ -2069,6 +3284,12 @@ describe("createAppRscHandler", () => { const currentHeaders = await requestHeaders(); return new Response(currentHeaders.get("x-source") ?? "missing"); }); + const dispatchResponseStage = vi.fn( + async (_request, _props, options) => { + expect(options).toEqual({ cache: "bypass" }); + return dispatchMatchedPage(); + }, + ); const handler = createHandler({ configHeaders: [], dispatchMatchedPage, @@ -2088,10 +3309,16 @@ describe("createAppRscHandler", () => { const headers = createRscRequestHeaders({ interceptionContext: "/feed" }); const rscUrl = await createRscRequestUrl("/docs/photos/1", headers); - const response = await handler(new Request(`https://example.test${rscUrl}`, { headers }), null); + const response = await handler( + new Request(`https://example.test${rscUrl}`, { headers }), + null, + false, + dispatchResponseStage, + ); expect(response.status).toBe(200); await expect(response.text()).resolves.toBe("added"); + expect(dispatchResponseStage).toHaveBeenCalledOnce(); }); it("preserves the Server Action body after authorizing an interception source", async () => { @@ -2435,6 +3662,44 @@ describe("createAppRscHandler", () => { expect(response.headers.get("vary")).toBe(VINEXT_RSC_VARY_HEADER); }); + it("keeps middleware cache headers above staged config and renderer headers", async () => { + const dispatchResponseStage = vi.fn( + async () => + new Response("staged-page", { + headers: { "Cache-Control": "public, s-maxage=120" }, + }), + ); + const handler = createHandler({ + configHeaders: [ + { + source: "/about", + headers: [{ key: "Cache-Control", value: "public, s-maxage=60" }], + }, + ], + middlewareModule: { + default() { + return new Response(null, { + headers: { + "Cache-Control": "private, no-store", + "x-middleware-next": "1", + }, + }); + }, + }, + }); + + const response = await handler( + new Request("https://example.test/docs/about"), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage).toHaveBeenCalledOnce(); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + await expect(response.text()).resolves.toBe("staged-page"); + }); + it("does not trailing-slash redirect RSC requests built from already-canonical trailingSlash paths", async () => { const headers = createRscRequestHeaders(); const requestPath = await createRscRequestUrl("/about/", headers); diff --git a/tests/app-worker-stages.test.ts b/tests/app-worker-stages.test.ts new file mode 100644 index 000000000..6eab64afc --- /dev/null +++ b/tests/app-worker-stages.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { handleResponseStage } from "../packages/vinext/src/server/app-response-stage-entry.js"; +import { + APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + isAppWorkerResponseStageProps, + type AppWorkerResponseStageProps, +} from "../packages/vinext/src/server/app-worker-stages.js"; +import { + DefaultCdnCacheAdapter, + setCdnCacheAdapter, + type CdnCacheAdapter, +} from "../packages/vinext/src/shims/cdn-cache.js"; +import { + VINEXT_EXPECTED_WORKER_VERSION_HEADER, + VINEXT_PRERENDER_READINESS_HEADER, +} from "../packages/vinext/src/server/headers.js"; + +const stages = vi.hoisted(() => ({ + renderFullRequest: vi.fn(), + registerCacheAdapters: vi.fn(), + registerImageOptimizer: vi.fn(), + renderResponse: vi.fn(), +})); + +vi.mock("virtual:vinext-cache-adapters", () => ({ + registerConfiguredCacheAdapters: stages.registerCacheAdapters, +})); + +vi.mock("virtual:vinext-image-adapters", () => ({ + registerConfiguredImageOptimizer: stages.registerImageOptimizer, +})); + +vi.mock("virtual:vinext-app-response-entry", () => ({ + __cacheabilityManifest: null, + default: { handleResponseStage: stages.renderResponse }, +})); + +vi.mock("virtual:vinext-rsc-entry", () => ({ + default: stages.renderFullRequest, +})); + +const notFoundStage = { + buildId: null, + cacheability: { policyHeaders: null, probeMode: null, resolvedRoutePathname: "/missing" }, + canonicalPathname: "/missing", + cleanPathname: "/missing", + draftModeCookie: null, + isRscRequest: false, + kind: "app-not-found" as const, + middlewareCookieOverlay: null, + mountedSlotsHeader: null, + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: "https://example.com", + renderMode: "navigation" as const, + resolvedUrl: "/missing", + scriptNonce: null, +} satisfies AppWorkerResponseStageProps; + +describe("App Worker response stage", () => { + beforeEach(() => { + setCdnCacheAdapter(new DefaultCdnCacheAdapter()); + stages.registerCacheAdapters.mockReset(); + stages.registerImageOptimizer.mockReset(); + stages.renderFullRequest.mockReset(); + stages.renderResponse.mockReset(); + }); + + it("validates readiness from inside the App response stage", async () => { + const validateRequest = vi.fn(() => null); + const adapter: CdnCacheAdapter = { + ownsBackgroundRevalidation: false, + async get() { + return null; + }, + async set() {}, + buildResponseHeaders() { + return {}; + }, + validateRequest, + async revalidateTag() {}, + }; + stages.registerCacheAdapters.mockImplementation(() => setCdnCacheAdapter(adapter)); + const request = new Request( + "https://example.com/__vinext/prerender/readiness?attempt=response-stage", + { headers: { [VINEXT_EXPECTED_WORKER_VERSION_HEADER]: "version-a" } }, + ); + const props = { + buildId: null, + cacheability: { + policyHeaders: null, + probeMode: null, + resolvedRoutePathname: "/__vinext/prerender/readiness", + }, + draftModeCookie: null, + kind: "app-full-request" as const, + middlewareCookieOverlay: null, + prerenderDiscovery: true, + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: "https://example.com", + scriptNonce: null, + staticFileSignalToken: "00000000-0000-4000-8000-000000000000", + trustedPrerenderState: null, + } satisfies AppWorkerResponseStageProps; + + const response = await handleResponseStage( + request, + { binding: "value" }, + undefined, + props, + async () => new Response("request-stage"), + { cache: "bypass" }, + ); + + expect(response.status).toBe(204); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(response.headers.get(VINEXT_PRERENDER_READINESS_HEADER)).toBe("1"); + expect(stages.registerCacheAdapters).toHaveBeenCalledWith({ binding: "value" }); + expect(validateRequest).toHaveBeenCalledWith(request); + expect(stages.renderResponse).not.toHaveBeenCalled(); + }); + + it("re-enters the request stage through the adapter-owned reverse transport", async () => { + const dispatchRequestStage = vi.fn(async () => new Response("revalidated")); + stages.renderResponse.mockImplementationOnce(async (_request, ctx) => + ctx.dispatchPagesRevalidate(new Request("https://example.com/missing")), + ); + + const response = await handleResponseStage( + new Request("https://example.com/missing"), + { binding: "value" }, + undefined, + notFoundStage, + dispatchRequestStage, + { cache: "shared" }, + ); + + await expect(response.text()).resolves.toBe("revalidated"); + expect(dispatchRequestStage).toHaveBeenCalledOnce(); + expect(stages.renderResponse).toHaveBeenCalledWith( + expect.any(Request), + expect.anything(), + notFoundStage, + { cache: "shared" }, + ); + }); + + it("rejects matched-stage payloads missing interception cache-safety fields", () => { + const matchedStage = { + ...notFoundStage, + bypassInterceptionContextCache: false, + interceptionContext: null, + interceptionId: null, + kind: "app-page" as const, + matchKind: "request" as const, + params: {}, + routePattern: "/missing", + routePathname: "/missing", + } satisfies AppWorkerResponseStageProps; + const { bypassInterceptionContextCache: _bypass, ...withoutBypassProof } = matchedStage; + const { interceptionId: _interceptionId, ...withoutInterceptionId } = matchedStage; + + expect(isAppWorkerResponseStageProps(matchedStage)).toBe(true); + expect(isAppWorkerResponseStageProps(withoutBypassProof)).toBe(false); + expect(isAppWorkerResponseStageProps(withoutInterceptionId)).toBe(false); + }); + + it.each([ + { name: "missing", requestOrigin: undefined }, + { name: "relative", requestOrigin: "example.com" }, + { name: "non-HTTP", requestOrigin: "ftp://example.com" }, + { name: "non-canonical", requestOrigin: "https://example.com/" }, + ])("rejects a $name request origin", ({ requestOrigin }) => { + expect(isAppWorkerResponseStageProps({ ...notFoundStage, requestOrigin })).toBe(false); + }); + + it.each([ + "https://second.example/missing", + "http://example.com/missing", + "https://example.com:8443/missing", + ])("rejects a response-stage origin mismatch before rendering: %s", async (requestUrl) => { + const response = await handleResponseStage( + new Request(requestUrl), + { binding: "value" }, + undefined, + notFoundStage, + async () => new Response("request-stage"), + { cache: "shared" }, + ); + + expect(response.status).toBe(400); + expect(stages.registerImageOptimizer).not.toHaveBeenCalled(); + expect(stages.registerCacheAdapters).not.toHaveBeenCalled(); + expect(stages.renderResponse).not.toHaveBeenCalled(); + }); + + it("requires a transport proof on full-request stage payloads", () => { + const fullStage = { + buildId: null, + cacheability: { policyHeaders: null, probeMode: null, resolvedRoutePathname: "/" }, + draftModeCookie: null, + kind: "app-full-request" as const, + middlewareCookieOverlay: null, + prerenderDiscovery: false, + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: "https://example.com", + scriptNonce: null, + staticFileSignalToken: "00000000-0000-4000-8000-000000000000", + trustedPrerenderState: null, + } satisfies AppWorkerResponseStageProps; + const { staticFileSignalToken: _token, ...withoutToken } = fullStage; + + expect(isAppWorkerResponseStageProps(fullStage)).toBe(true); + expect(isAppWorkerResponseStageProps(withoutToken)).toBe(false); + }); + + it("passes only authenticated prerender state into the full response graph", async () => { + stages.renderFullRequest.mockResolvedValue(new Response("rendered")); + const trustedPrerenderState = { + routeParams: { params: { slug: "hello" }, routePattern: "/post/:slug" }, + speculative: true, + } as const; + const props = { + buildId: null, + cacheability: { policyHeaders: null, probeMode: null, resolvedRoutePathname: "/post/hello" }, + draftModeCookie: null, + kind: "app-full-request" as const, + middlewareCookieOverlay: null, + prerenderDiscovery: false, + protocolVersion: APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + requestOrigin: "https://example.com", + scriptNonce: null, + staticFileSignalToken: "00000000-0000-4000-8000-000000000000", + trustedPrerenderState, + } satisfies AppWorkerResponseStageProps; + const request = new Request("https://example.com/post/hello"); + + const response = await handleResponseStage( + request, + undefined, + undefined, + props, + async () => new Response("request-stage"), + { cache: "bypass" }, + ); + + await expect(response.text()).resolves.toBe("rendered"); + expect(stages.renderFullRequest).toHaveBeenCalledWith( + request, + expect.anything(), + false, + undefined, + null, + trustedPrerenderState, + ); + }); +}); diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 2fbdfc2fa..1b2b51825 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -21,7 +21,10 @@ import { createClientManualChunks, getClientTreeshakeConfig, createRscFrameworkChunkOutputConfig, + createMultiStageCodeSplittingConfig, + createMultiStageChunkFileNames, RSC_FRAMEWORK_CHUNK_TEST, + sanitizeRscChunkFileName, isRscFrameworkModule, } from "../packages/vinext/src/build/client-build-config.js"; import { @@ -3903,6 +3906,7 @@ describe("createRscFrameworkChunkOutputConfig", () => { expect(config).not.toHaveProperty("advancedChunks"); expect(config).not.toHaveProperty("manualChunks"); expect(config).toEqual({ + sanitizeFileName: sanitizeRscChunkFileName, codeSplitting: { groups: [ { @@ -3914,6 +3918,93 @@ describe("createRscFrameworkChunkOutputConfig", () => { }, }); }); + + it("removes virtual-id markers without changing ordinary chunk names", () => { + expect( + sanitizeRscChunkFileName( + "framework~\\0virtual_vinext-response-stage~\0virtual_vinext-request-stage.js", + ), + ).toBe("framework~virtual_vinext-response-stage~_virtual_vinext-request-stage.js"); + expect(sanitizeRscChunkFileName("bad:name\\chunk/part?.js")).toBe("bad_name_chunk_part_.js"); + expect(sanitizeRscChunkFileName("framework-a1b2c3.js")).toBe("framework-a1b2c3.js"); + }); +}); + +describe("createMultiStageChunkFileNames", () => { + it("keeps router stage chunks beside the server entry", () => { + const fileName = createMultiStageChunkFileNames("_next/static", undefined); + expect(fileName({ name: "app-router-entry" } as never)).toBe("app-router-entry-[hash].js"); + expect(fileName({ name: "pages-router-entry" } as never)).toBe("pages-router-entry-[hash].js"); + expect(fileName({ name: "app-response-stage-entry" } as never)).toBe( + "app-response-stage-entry-[hash].js", + ); + expect(fileName({ name: "pages-request-stage-entry" } as never)).toBe( + "pages-request-stage-entry-[hash].js", + ); + expect(fileName({ name: "pages-response-stage-entry" } as never)).toBe( + "pages-response-stage-entry-[hash].js", + ); + expect(fileName({ name: "_virtual_vinext-rsc-entry" } as never)).toBe( + "_virtual_vinext-rsc-entry-[hash].js", + ); + expect(fileName({ name: "_virtual_vinext-response-stage" } as never)).toBe( + "_virtual_vinext-response-stage-[hash].js", + ); + expect(fileName({ name: "vinext-stage-runtime~virtual_vinext-response-stage" } as never)).toBe( + "vinext-stage-runtime~virtual_vinext-response-stage-[hash].js", + ); + expect(fileName({ name: "request-runtime" } as never)).toBe( + "_next/static/request-runtime-[hash].js", + ); + expect(fileName({ name: "runtime~\\0virtual_stage" } as never)).toBe( + "_next/static/runtime~virtual_stage-[hash].js", + ); + expect( + fileName({ + moduleIds: ["/repo/packages/vinext/src/server/app-ssr-entry.ts"], + name: "vinext-stage-runtime~index", + } as never), + ).toBe("vinext-stage-runtime~index-[hash].js"); + }); + + it("preserves a host-provided chunk filename function", () => { + let calls = 0; + const existing = (chunk: { name: string }) => { + calls += 1; + return `host/${chunk.name}.js`; + }; + const fileName = createMultiStageChunkFileNames("_next/static", existing as never); + expect(fileName({ name: "ordinary" } as never)).toBe("host/ordinary.js"); + expect(fileName({ name: "runtime~\\0virtual_stage" } as never)).toBe( + "host/runtime~virtual_stage.js", + ); + expect(calls).toBe(2); + }); +}); + +describe("createMultiStageCodeSplittingConfig", () => { + it("keeps vinext stage chunks entry-aware without dropping host groups", () => { + const existing = { groups: [{ name: "host", test: /host/ }] }; + const config = createMultiStageCodeSplittingConfig(existing); + + const stageGroup = config.groups[0]; + expect(stageGroup).toMatchObject({ + entriesAware: true, + name: "vinext-stage-runtime", + }); + expect(stageGroup?.test).toBeInstanceOf(RegExp); + const test = stageGroup!.test as RegExp; + for (const id of [ + "/repo/packages/vinext/src/server/app-elements.ts", + "/repo/packages/vinext/dist/server/app-elements.js", + "/app/node_modules/vinext/dist/server/app-elements.js", + "/app/node_modules/.pnpm/vinext@1.0.0/node_modules/vinext/dist/server/app-elements.js", + ]) { + expect(test.test(id), id).toBe(true); + } + expect(test.test("/app/node_modules/not-vinext/dist/server/app-elements.js")).toBe(false); + expect(config.groups[1]).toBe(existing.groups[0]); + }); }); // ─── RSC framework package matching (single source of truth) ────────────────── diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 79d4135b8..55c65117f 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -10,6 +10,7 @@ import path from "node:path"; import os from "node:os"; import fs from "node:fs/promises"; import { + defaultExportMayHaveRuntimeMember, hasExportedName, hasNamedExport, extractExportConstString, @@ -109,6 +110,34 @@ export function getServerSideProps() {} }); }); +describe("defaultExportMayHaveRuntimeMember", () => { + it.each([ + `export default class Document { static async getInitialProps() {} }`, + `class Document { static getInitialProps = async () => {}; } export default Document;`, + `function Document() {} Document.getInitialProps = async () => {}; export default Document;`, + `const Document = Object.assign(() => null, { getInitialProps() {} }); export default Document;`, + `const Document = () => null; Object.assign(Document, { getInitialProps() {} }); export default Document;`, + `const Document = () => null; Object.defineProperty(Document, "getInitialProps", { value() {} }); export default Document;`, + `class Document { static getInitialProps() {} } export { Document as default };`, + `import NextDocument from "next/document"; class Document extends NextDocument {} export default Document;`, + `import { default as BaseDocument } from "next/document"; export default class extends BaseDocument {}`, + `export { default } from "./document-implementation";`, + ])("detects request-aware default exports", (code) => { + expect(defaultExportMayHaveRuntimeMember(code, "getInitialProps")).toBe(true); + }); + + it.each([ + `export default function Document() { return null; }`, + `const Document = () => null; export default Document;`, + `const Document = function () { return null; }; export default Document;`, + `const Document = class {}; export default Document;`, + `class Helper { static getInitialProps() {} } export default function Document() { return null; }`, + `// getInitialProps is intentionally absent\nexport default function Document() { return null; }`, + ])("does not classify request-independent default exports", (code) => { + expect(defaultExportMayHaveRuntimeMember(code, "getInitialProps")).toBe(false); + }); +}); + // ─── extractExportConstString ───────────────────────────────────────────────── describe("extractExportConstString", () => { diff --git a/tests/build-time-classification-integration.test.ts b/tests/build-time-classification-integration.test.ts index 001c3c960..0e355fcb0 100644 --- a/tests/build-time-classification-integration.test.ts +++ b/tests/build-time-classification-integration.test.ts @@ -151,12 +151,14 @@ function extractRouteIndexByPattern(chunkSource: string): Map { } type BuiltFixtureRaw = { + classificationChunks: Array<{ fileName: string; source: string }>; chunkSource: string; }; async function buildMinimalFixtureRaw({ debug = false, -}: { debug?: boolean } = {}): Promise { + includeResponseStage = false, +}: { debug?: boolean; includeResponseStage?: boolean } = {}): Promise { const workspaceRoot = path.resolve(import.meta.dirname, ".."); const workspaceNodeModules = path.join(workspaceRoot, "node_modules"); @@ -237,7 +239,29 @@ export default function ForceStaticLayout({ children }) { const builder = await createBuilder({ root: tmpDir, configFile: false, - plugins: [vinext({ appDir: tmpDir, rscOutDir, ssrOutDir, clientOutDir })], + plugins: [ + vinext({ appDir: tmpDir, rscOutDir, ssrOutDir, clientOutDir }), + ...(includeResponseStage + ? [ + { + name: "test:multi-entry-rsc-build", + configEnvironment(name: string) { + if (name !== "rsc") return null; + return { + build: { + rolldownOptions: { + input: { + index: "virtual:vinext-rsc-entry", + response: "virtual:vinext-response-stage", + }, + }, + }, + }; + }, + }, + ] + : []), + ], logLevel: "silent", }); @@ -274,7 +298,16 @@ export default function ForceStaticLayout({ children }) { } const chunkSource = await fsp.readFile(path.join(chunkDir, chunkFile), "utf8"); - return { chunkSource }; + const classificationChunks: BuiltFixtureRaw["classificationChunks"] = []; + for (const entry of entries) { + if (!/\.m?js$/.test(entry)) continue; + const source = await fsp.readFile(path.join(chunkDir, entry), "utf8"); + if (source.includes("__buildTimeClassifications")) { + classificationChunks.push({ fileName: entry, source }); + } + } + + return { chunkSource, classificationChunks }; } async function buildMinimalFixture({ @@ -364,6 +397,24 @@ describe("build-time classification integration", () => { }); }); +describe("build-time classification integration (multi-entry RSC)", () => { + let classificationChunks: BuiltFixtureRaw["classificationChunks"]; + + beforeAll(async () => { + ({ classificationChunks } = await buildMinimalFixtureRaw({ includeResponseStage: true })); + }, 120_000); + + it("patches both the ordinary and response-stage RSC graphs", () => { + expect(classificationChunks).toHaveLength(2); + for (const { fileName, source } of classificationChunks) { + const dispatch = extractDispatch(source); + const routeIndex = extractRouteIndexByPattern(source).get("/"); + expect(routeIndex, fileName).toBeDefined(); + expect(dispatch(routeIndex!)?.get(0), fileName).toBe("static"); + } + }); +}); + /** * Recovers and evaluates the reasons dispatch from a build produced with * `VINEXT_DEBUG_CLASSIFICATION=1`. Mirrors `extractDispatch` but targets the diff --git a/tests/cache-adapters-build.test.ts b/tests/cache-adapters-build.test.ts index 78c0e9369..c9e7349bd 100644 --- a/tests/cache-adapters-build.test.ts +++ b/tests/cache-adapters-build.test.ts @@ -50,6 +50,38 @@ function readTextFilesRecursive(root: string): string { return output; } +function readStaticEntryClosure(root: string, entryKey: string): string { + const serverDir = path.join(root, "dist/server"); + const manifest = JSON.parse( + fs.readFileSync(path.join(serverDir, ".vite/manifest.json"), "utf-8"), + ) as Record; + const resolvedEntryKey = Object.keys(manifest).find( + (key) => key === entryKey || key.endsWith(entryKey), + ); + if (!resolvedEntryKey) { + throw new Error(`Missing emitted manifest entry ${JSON.stringify(entryKey)}`); + } + const pending = [resolvedEntryKey]; + const visited = new Set(); + let output = ""; + + while (pending.length > 0) { + const key = pending.pop()!; + if (visited.has(key)) continue; + visited.add(key); + const entry = manifest[key]; + if (!entry || typeof entry.file !== "string") { + throw new Error(`Missing emitted manifest entry ${JSON.stringify(key)}`); + } + output += fs.readFileSync(path.join(serverDir, entry.file), "utf-8"); + if (Array.isArray(entry.imports)) { + pending.push(...entry.imports.filter((value): value is string => typeof value === "string")); + } + } + + return output; +} + function writeCloudflareAppFixture(root: string, name: string) { fs.symlinkSync( path.resolve(import.meta.dirname, "../node_modules"), @@ -211,5 +243,101 @@ export default createAdapter; expect(assetsIgnore.split("\n").map((l) => l.trim())).toContain(".vite"); // The manifest exists on disk (the build reads it) but is now excluded. expect(fs.existsSync(path.join(root, "dist/client/.vite/manifest.json"))).toBe(true); + expect( + fs.readFileSync( + path.join(root, "dist/server/__vinext_pregenerated_concrete_paths.js"), + "utf-8", + ), + ).toContain("__VINEXT_PREGENERATED_CONCRETE_PATHS"); + }, 60_000); + + it("keeps the data adapter out of the emitted request-stage graph", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-cache-adapter-stages-")); + tmpDirs.push(root); + writeCloudflareAppFixture(root, "vinext-cache-adapter-stages"); + writeFixtureFile( + root, + "cache/my-data-adapter.ts", + `export default function createAdapter() { + return { + adapterMarker: "${LOCAL_ADAPTER_MARKER}", + async get() { return null; }, + async set() {}, + async revalidateTag() {}, + }; +} +`, + ); + writeFixtureFile( + root, + "worker/index.ts", + `import handler from ${JSON.stringify( + path + .resolve(import.meta.dirname, "../packages/vinext/src/server/fetch-handler.ts") + .replace(/\\/g, "/"), + )};\n\nexport default handler;\n`, + ); + writeFixtureFile( + root, + "cache/my-cdn-adapter.ts", + `export default function createAdapter() { + return { + ownsBackgroundRevalidation: false, + async get() { return null; }, + async set() {}, + buildResponseHeaders() { return {}; }, + async revalidateTag() {}, + }; +} +`, + ); + writeFixtureFile( + root, + "cache/stage-entry.ts", + `export const loadRequestStage = () => import("virtual:vinext-request-stage"); +export const loadResponseStage = () => import("virtual:vinext-response-stage"); +export default { + async fetch(request) { + const stage = request.url.includes("response") + ? await loadResponseStage() + : await loadRequestStage(); + return new Response(Object.keys(stage).join(",")); + }, +}; +`, + ); + const adapterAbsPath = path.join(root, "cache/my-data-adapter.ts").replace(/\\/g, "/"); + const cdnAdapterAbsPath = path.join(root, "cache/my-cdn-adapter.ts").replace(/\\/g, "/"); + const stageEntryAbsPath = path.join(root, "cache/stage-entry.ts").replace(/\\/g, "/"); + const { cloudflare } = (await import(pathToFileURL(cfPluginPath).href)) as { + cloudflare: CloudflarePluginFactory; + }; + const builder = await createBuilder({ + root, + configFile: false, + plugins: [ + vinext({ + appDir: root, + cache: { + cdn: { + adapter: cdnAdapterAbsPath, + output: { entry: stageEntryAbsPath, type: "multi-stage" }, + }, + data: { adapter: adapterAbsPath }, + }, + }), + cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } }), + ], + logLevel: "silent", + }); + + await builder.buildApp(); + + expect(readStaticEntryClosure(root, "virtual:vinext-request-stage")).not.toContain( + LOCAL_ADAPTER_MARKER, + ); + expect(readStaticEntryClosure(root, "virtual:vinext-response-stage")).toContain( + LOCAL_ADAPTER_MARKER, + ); }, 60_000); }); diff --git a/tests/cache-adapters-config.test.ts b/tests/cache-adapters-config.test.ts index ff39b0ee6..f68ff5a55 100644 --- a/tests/cache-adapters-config.test.ts +++ b/tests/cache-adapters-config.test.ts @@ -14,16 +14,24 @@ import path from "node:path"; import { describe, it, expect } from "vite-plus/test"; import { findVinextCacheConfigInPlugins, + generateCdnCacheAdapterModule, loadVinextCacheConfigFromViteConfig, generateCacheAdaptersModule, + getConfiguredCdnResponsePolicyHeaderNames, hasBuildIdentityResponseHeader, + hasUncachedRequestRouting, hasVerbatimResponseVary, VINEXT_CACHE_CONFIG_PLUGIN_PROPERTY, VIRTUAL_CACHE_ADAPTERS, + VIRTUAL_CDN_CACHE_ADAPTER, } from "../packages/vinext/src/cache/cache-adapters-virtual.js"; import { generateRscEntry } from "../packages/vinext/src/entries/app-rsc-entry.js"; import { generateServerEntry } from "../packages/vinext/src/entries/pages-server-entry.js"; -import { readAppRouterEntrySource, readPagesRouterEntrySource } from "./worker-entry-source.js"; +import { + readAppRequestStageEntrySource, + readAppRouterEntrySource, + readPagesRequestStageEntrySource, +} from "./worker-entry-source.js"; import { resolveNextConfig } from "../packages/vinext/src/config/next-config.js"; import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js"; import { kvDataAdapter } from "../packages/cloudflare/src/cache/kv-data-adapter.js"; @@ -40,36 +48,51 @@ describe("generateCacheAdaptersModule", () => { expect(VIRTUAL_CACHE_ADAPTERS).toBe("virtual:vinext-cache-adapters"); }); + it("emits a CDN-only registrar for request-stage graphs", () => { + expect(VIRTUAL_CDN_CACHE_ADAPTER).toBe("virtual:vinext-cdn-cache-adapter"); + const code = generateCdnCacheAdapterModule({ + cdn: { adapter: "my-cdn-adapter" }, + data: { adapter: "my-data-adapter" }, + }); + expect(code).toContain(`import __vinextCdnAdapterFactory from "my-cdn-adapter";`); + expect(code).not.toContain("my-data-adapter"); + expect(code).not.toContain("registerDataCacheHandler"); + }); + it("emits a no-op registrar when no adapters are configured", () => { for (const cache of [undefined, {}, { cdn: undefined, data: undefined }]) { const code = generateCacheAdaptersModule(cache); expect(code).toContain("export function registerConfiguredCacheAdapters() {}"); expect(code).not.toContain("import"); - expect(code).not.toContain("setDataCacheHandler"); - expect(code).not.toContain("setCdnCacheAdapter"); + expect(code).not.toContain("registerDataCacheHandler"); + expect(code).not.toContain("registerCdnCacheAdapter"); } }); it("wires only the data adapter when only data is configured", () => { const code = generateCacheAdaptersModule({ data: { adapter: "my-data-adapter" } }); expect(code).toContain(`import __vinextDataAdapterFactory from "my-data-adapter";`); - expect(code).toContain(`import { setDataCacheHandler } from "vinext/shims/cache-handler";`); expect(code).toContain( - "setDataCacheHandler(__vinextDataAdapterFactory({ env, options: undefined }));", + `import { registerDataCacheHandler } from "vinext/shims/cache-handler";`, + ); + expect(code).toContain( + "registerDataCacheHandler(() => __vinextDataAdapterFactory({ env, options: undefined }));", ); expect(code).not.toContain("__vinextCdnAdapterFactory"); - expect(code).not.toContain("setCdnCacheAdapter"); + expect(code).not.toContain("registerCdnCacheAdapter"); }); it("wires only the cdn adapter when only cdn is configured", () => { const code = generateCacheAdaptersModule({ cdn: { adapter: "my-cdn-adapter" } }); expect(code).toContain(`import __vinextCdnAdapterFactory from "my-cdn-adapter";`); - expect(code).toContain(`import { setCdnCacheAdapter } from "vinext/shims/cdn-cache";`); expect(code).toContain( - "setCdnCacheAdapter(__vinextCdnAdapterFactory({ env, options: undefined }));", + `import { registerCdnCacheAdapter } from "vinext/shims/cdn-cache-state";`, + ); + expect(code).toContain( + "registerCdnCacheAdapter(() => __vinextCdnAdapterFactory({ env, options: undefined }));", ); expect(code).not.toContain("__vinextDataAdapterFactory"); - expect(code).not.toContain("setDataCacheHandler"); + expect(code).not.toContain("registerDataCacheHandler"); }); it("inlines descriptor options and forwards them to the factory", () => { @@ -77,7 +100,7 @@ describe("generateCacheAdaptersModule", () => { data: { adapter: "@vinext/cloudflare/cache/kv-data-adapter", options: { binding: "MY_KV" } }, }); expect(code).toContain( - `setDataCacheHandler(__vinextDataAdapterFactory({ env, options: {"binding":"MY_KV"} }));`, + `registerDataCacheHandler(() => __vinextDataAdapterFactory({ env, options: {"binding":"MY_KV"} }));`, ); }); @@ -88,8 +111,8 @@ describe("generateCacheAdaptersModule", () => { }); expect(code).toContain(`from "@vinext/cloudflare/cache/cdn-adapter";`); expect(code).toContain(`from "@vinext/cloudflare/cache/kv-data-adapter";`); - expect(code).toContain("setDataCacheHandler(__vinextDataAdapterFactory("); - expect(code).toContain("setCdnCacheAdapter(__vinextCdnAdapterFactory("); + expect(code).toContain("registerDataCacheHandler(() => __vinextDataAdapterFactory("); + expect(code).toContain("registerCdnCacheAdapter(() => __vinextCdnAdapterFactory("); expect(code).toContain( "if (typeof process !== 'undefined' && process.env?.__VINEXT_PRERENDER_PATH_DISCOVERY === '1') return;", ); @@ -266,12 +289,18 @@ describe("registration is wired into every router/runtime entry", () => { }); it("Pages Router worker entry registers with env", () => { - const code = readPagesRouterEntrySource(); - expect(code).toContain('from "virtual:vinext-cache-adapters"'); + const code = readPagesRequestStageEntrySource(); + expect(code).toContain('from "virtual:vinext-cdn-cache-adapter"'); expect(code).toContain("registerConfiguredCacheAdapters(env)"); expect(code).toContain("await validateCdnRequest(request)"); }); + it("App request stage cannot retain the configured data adapter module", () => { + const code = readAppRequestStageEntrySource(); + expect(code).toContain('from "virtual:vinext-cdn-cache-adapter"'); + expect(code).not.toContain('from "virtual:vinext-cache-adapters"'); + }); + it("App Router worker entry validates CDN routing after registering with env", () => { const code = readAppRouterEntrySource(); expect(code).toContain("registerConfiguredCacheAdapters(env"); @@ -290,13 +319,33 @@ describe("cdnAdapter builder + factory", () => { expect(descriptor.options).toBeUndefined(); expect(descriptor.capabilities).toEqual({ buildIdentity: "response-header", + responsePolicyHeaderNames: ["CDN-Cache-Control", "Cloudflare-CDN-Cache-Control"], responseVary: "verbatim", routeCacheability: "probe-manifest", }); expect(hasBuildIdentityResponseHeader({ cdn: descriptor })).toBe(true); + expect( + hasUncachedRequestRouting({ + cdn: { + adapter: "staged-cache", + capabilities: { requestRouting: "uncached-stage" }, + }, + }), + ).toBe(true); expect(hasVerbatimResponseVary({ cdn: descriptor })).toBe(true); expect(hasBuildIdentityResponseHeader({ cdn: { adapter: "custom-cache" } })).toBe(false); + expect(hasUncachedRequestRouting({ cdn: { adapter: "url-only-cache" } })).toBe(false); expect(hasVerbatimResponseVary({ cdn: { adapter: "url-only-cache" } })).toBe(false); + expect( + getConfiguredCdnResponsePolicyHeaderNames({ + cdn: { + adapter: "custom-cache", + capabilities: { + responsePolicyHeaderNames: [" X-Example-Policy ", "CACHE-CONTROL", ""], + }, + }, + }), + ).toEqual(["cache-control", "x-example-policy"]); }); it("factory returns a CloudflareCdnCacheAdapter", () => { diff --git a/tests/cache-control.test.ts b/tests/cache-control.test.ts index a5c37d3cd..5d12da843 100644 --- a/tests/cache-control.test.ts +++ b/tests/cache-control.test.ts @@ -5,6 +5,7 @@ import { buildCachedRevalidateCacheControl, buildRevalidateCacheControl, hasExplicitNonCacheableResponsePolicy, + reconcileCdnResponseHeadersAfterOuterPolicy, shouldUseNextDeployCacheControl, validateCdnRequest, } from "../packages/vinext/src/server/cache-control.js"; @@ -79,6 +80,7 @@ describe("applyCdnResponseHeaders", () => { process.env.VINEXT_NEXT_DEPLOY_CACHE_CONTROL = "1"; const edge: CdnCacheAdapter = { ownsBackgroundRevalidation: false, + responsePolicyHeaderNames: ["CDN-Cache-Control"], async get() { return null; }, @@ -161,6 +163,75 @@ describe("applyCdnResponseHeaders", () => { expect(headers.get("X-Example-Cache-Tag")).toBeNull(); }); + it("clears an inner artifact's provider policy when outer composition turns private", () => { + const edge: CdnCacheAdapter = { + ownsBackgroundRevalidation: false, + responsePolicyHeaderNames: ["X-Example-Edge-Policy"], + async get() { + return null; + }, + async set() {}, + buildResponseHeaders(input) { + return { + "Cache-Control": input.cacheControl, + "X-Example-Edge-Policy": null, + "X-Example-Cache-Tag": null, + }; + }, + async revalidateTag() {}, + }; + setCdnCacheAdapter(edge); + const innerHeaders = new Headers({ + "Cache-Control": "public, max-age=0, must-revalidate", + "X-Example-Edge-Policy": "public, max-age=60", + "X-Example-Cache-Tag": "inner", + }); + const headers = new Headers(innerHeaders); + headers.set("X-Example-Edge-Policy", "private, no-store"); + + reconcileCdnResponseHeadersAfterOuterPolicy( + headers, + new Headers({ "X-Example-Edge-Policy": "private, no-store" }), + ); + + expect(headers.get("Cache-Control")).toBe("private, no-store"); + expect(headers.get("X-Example-Edge-Policy")).toBeNull(); + expect(headers.get("X-Example-Cache-Tag")).toBeNull(); + }); + + it("clears an inner artifact's provider policy when outer composition sets a cookie", () => { + const edge: CdnCacheAdapter = { + ownsBackgroundRevalidation: false, + responsePolicyHeaderNames: ["X-Example-Edge-Policy"], + async get() { + return null; + }, + async set() {}, + buildResponseHeaders(input) { + return { + "Cache-Control": input.cacheControl, + "X-Example-Edge-Policy": null, + "X-Example-Cache-Tag": null, + }; + }, + async revalidateTag() {}, + }; + setCdnCacheAdapter(edge); + const headers = new Headers({ + "Cache-Control": "public, max-age=0, must-revalidate", + "Set-Cookie": "session=private; Path=/; HttpOnly", + "X-Example-Cache-Tag": "inner", + "X-Example-Edge-Policy": "public, max-age=60", + }); + + reconcileCdnResponseHeadersAfterOuterPolicy(headers, new Headers()); + + expect(headers.get("Cache-Control")).toBe("no-store, must-revalidate"); + expect(headers.get("Set-Cookie")).toContain("session=private"); + expect(headers.get("X-Example-Edge-Policy")).toBeNull(); + expect(headers.get("X-Example-Cache-Tag")).toBeNull(); + }); + it("default adapter restores baseline after the edge adapter is cleared", () => { setCdnCacheAdapter(new DefaultCdnCacheAdapter()); const headers = new Headers(); diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index 7dc3af38e..a39d3fa80 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -455,6 +455,98 @@ describe("single-request cacheability admission", () => { await expect(response.text()).resolves.toBe("public"); }); + it("normalizes a completed Route Handler policy only at a shared response stage", async () => { + const previousNextDeployPolicy = process.env.VINEXT_NEXT_DEPLOY_CACHE_CONTROL; + process.env.VINEXT_NEXT_DEPLOY_CACHE_CONTROL = "1"; + setCdnCacheAdapter(new CloudflareCdnCacheAdapter()); + try { + const finalize = async (applyCompletedResponsePolicy: boolean) => { + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/api/mixed-methods", { + headers: { Accept: "application/json" }, + }), + JSON.stringify({ buildId: "build-a", routes: {}, version: 1 }), + "build-a", + true, + undefined, + undefined, + undefined, + { applyCompletedResponsePolicy }, + ); + const state = cacheabilityState(context); + state.route = { kind: "app-route", pattern: "/api/mixed-methods" }; + state.explicitResponseCachePolicy = true; + state.completedResponseBody = true; + return finalizeWorkerCacheabilityResponse( + new Response("public", { + headers: { "Cache-Control": "public, s-maxage=60" }, + }), + context, + ); + }; + + const legacyResponse = await finalize(false); + expect(legacyResponse.headers.get("Cache-Control")).toBe("public, s-maxage=60"); + expect(legacyResponse.headers.get("CDN-Cache-Control")).toBeNull(); + + const stagedResponse = await finalize(true); + expect(stagedResponse.headers.get("Cache-Control")).toBe( + "public, max-age=0, must-revalidate", + ); + expect(stagedResponse.headers.get("CDN-Cache-Control")).toBeNull(); + } finally { + setCdnCacheAdapter(new DefaultCdnCacheAdapter()); + if (previousNextDeployPolicy === undefined) { + delete process.env.VINEXT_NEXT_DEPLOY_CACHE_CONTROL; + } else { + process.env.VINEXT_NEXT_DEPLOY_CACHE_CONTROL = previousNextDeployPolicy; + } + } + }); + + it("uses the Cloudflare edge policy rather than browser policy for Pages admission", async () => { + const previousNextDeployPolicy = process.env.VINEXT_NEXT_DEPLOY_CACHE_CONTROL; + delete process.env.VINEXT_NEXT_DEPLOY_CACHE_CONTROL; + const adapter = new CloudflareCdnCacheAdapter(); + setCdnCacheAdapter(adapter); + try { + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + null, + "build-a", + true, + ); + const state = cacheabilityState(context); + state.route = { kind: "pages-page", pattern: "/page" }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("page", { + headers: { + "Cache-Control": "public, max-age=0, must-revalidate", + "CDN-Cache-Control": "public, max-age=60", + }, + }), + context, + ); + + expect(response.headers.get("Cache-Control")).toBe("public, max-age=0, must-revalidate"); + expect( + adapter.responsePolicyHeaderNames + .map((name) => response.headers.get(name)) + .find((value) => value !== null), + ).toBe("public, max-age=60"); + } finally { + setCdnCacheAdapter(new DefaultCdnCacheAdapter()); + if (previousNextDeployPolicy === undefined) { + delete process.env.VINEXT_NEXT_DEPLOY_CACHE_CONTROL; + } else { + process.env.VINEXT_NEXT_DEPLOY_CACHE_CONTROL = previousNextDeployPolicy; + } + } + }); + it("does not treat framework revalidate policy as an explicit unmanifested opt-in", async () => { const raw = JSON.stringify({ buildId: "build-a", routes: {}, version: 1 }); const context = createWorkerCacheabilityAdmissionContext( @@ -943,6 +1035,7 @@ describe("single-request cacheability admission", () => { finalHeaders: Record; initialPolicy: NonNullable; name: string; + responsePolicyHeaderNames?: readonly string[]; }> = [ { finalHeaders: { "Set-Cookie": "session=private; Path=/; HttpOnly" }, @@ -955,14 +1048,10 @@ describe("single-request cacheability admission", () => { name: "Cache-Control", }, { - finalHeaders: { "CDN-Cache-Control": "private, no-store" }, - initialPolicy: { "cdn-cache-control": "public, s-maxage=60" }, - name: "CDN-Cache-Control", - }, - { - finalHeaders: { "Cloudflare-CDN-Cache-Control": "private, no-store" }, - initialPolicy: { "cloudflare-cdn-cache-control": "public, s-maxage=60" }, - name: "Cloudflare-CDN-Cache-Control", + finalHeaders: { "X-Example-Edge-Policy": "private, no-store" }, + initialPolicy: { "x-example-edge-policy": "public, s-maxage=60" }, + name: "adapter-declared policy", + responsePolicyHeaderNames: ["cache-control", "x-example-edge-policy"], }, ]; @@ -979,6 +1068,9 @@ describe("single-request cacheability admission", () => { const state = cacheabilityState(context); state.route = { kind: "app-page", pattern: "/page" }; state.frameworkResponseCachePolicy = testCase.initialPolicy; + if (testCase.responsePolicyHeaderNames) { + state.responsePolicyHeaderNames = testCase.responsePolicyHeaderNames; + } state.outcome = { cacheable: true, cacheControl: "s-maxage=60, stale-while-revalidate=540", diff --git a/tests/cdn-cache.test.ts b/tests/cdn-cache.test.ts index 44a4db893..a7d14de4f 100644 --- a/tests/cdn-cache.test.ts +++ b/tests/cdn-cache.test.ts @@ -20,8 +20,10 @@ import { type CdnCacheableHeaderInput, type CdnResponseHeaders, } from "../packages/vinext/src/shims/cdn-cache.js"; +import { registerCdnCacheAdapter } from "../packages/vinext/src/shims/cdn-cache-state.js"; import { MemoryCacheHandler, + registerDataCacheHandler, setDataCacheHandler, setCacheHandler, getDataCacheHandler, @@ -62,11 +64,146 @@ describe("data cache handler aliases", () => { setDataCacheHandler(handler); expect(getCacheHandler()).toBe(handler); }); + + it("creates a declarative handler once while keeping failed factories retryable", () => { + const handlerKey = Symbol.for("vinext.cacheHandler"); + const configuredKey = Symbol.for("vinext.configuredCacheHandler"); + const explicitKey = Symbol.for("vinext.explicitCacheHandler"); + const globals = globalThis as unknown as Record; + const previousHandler = globals[handlerKey]; + const previousConfigured = globals[configuredKey]; + const previousExplicit = globals[explicitKey]; + delete globals[handlerKey]; + delete globals[configuredKey]; + delete globals[explicitKey]; + + try { + const failedFactory = vi.fn((): CacheHandler => { + throw new Error("missing binding"); + }); + expect(() => registerDataCacheHandler(failedFactory)).toThrow("missing binding"); + + const first = new MemoryCacheHandler(); + const duplicateFactory = vi.fn(() => new MemoryCacheHandler()); + registerDataCacheHandler(() => first); + registerDataCacheHandler(duplicateFactory); + expect(getDataCacheHandler()).toBe(first); + expect(duplicateFactory).not.toHaveBeenCalled(); + + const explicit = new MemoryCacheHandler(); + setDataCacheHandler(explicit); + expect(getDataCacheHandler()).toBe(explicit); + expect(failedFactory).toHaveBeenCalledOnce(); + } finally { + if (previousHandler === undefined) delete globals[handlerKey]; + else globals[handlerKey] = previousHandler; + if (previousConfigured === undefined) delete globals[configuredKey]; + else globals[configuredKey] = previousConfigured; + if (previousExplicit === undefined) delete globals[explicitKey]; + else globals[explicitKey] = previousExplicit; + } + }); + + it("does not evaluate a declarative factory after an imperative registration", () => { + const explicit = new MemoryCacheHandler(); + const factory = vi.fn(() => new MemoryCacheHandler()); + setDataCacheHandler(explicit); + + registerDataCacheHandler(factory); + + expect(factory).not.toHaveBeenCalled(); + expect(getDataCacheHandler()).toBe(explicit); + }); + + it("preserves an imperative handler installed by a declarative factory", () => { + const handlerKey = Symbol.for("vinext.cacheHandler"); + const configuredKey = Symbol.for("vinext.configuredCacheHandler"); + const explicitKey = Symbol.for("vinext.explicitCacheHandler"); + const globals = globalThis as unknown as Record; + const previousHandler = globals[handlerKey]; + const previousConfigured = globals[configuredKey]; + const previousExplicit = globals[explicitKey]; + delete globals[handlerKey]; + delete globals[configuredKey]; + delete globals[explicitKey]; + + try { + const explicit = new MemoryCacheHandler(); + registerDataCacheHandler(() => { + setDataCacheHandler(explicit); + return new MemoryCacheHandler(); + }); + expect(getDataCacheHandler()).toBe(explicit); + } finally { + if (previousHandler === undefined) delete globals[handlerKey]; + else globals[handlerKey] = previousHandler; + if (previousConfigured === undefined) delete globals[configuredKey]; + else globals[configuredKey] = previousConfigured; + if (previousExplicit === undefined) delete globals[explicitKey]; + else globals[explicitKey] = previousExplicit; + } + }); }); // ─── DefaultCdnCacheAdapter ────────────────────────────────────────────── describe("DefaultCdnCacheAdapter", () => { + it("keeps the first declarative registration while allowing an explicit override", () => { + const adapterKey = Symbol.for("vinext.cdnCacheAdapter"); + const globals = globalThis as unknown as Record; + const previous = globals[adapterKey]; + delete globals[adapterKey]; + + try { + const first = new DefaultCdnCacheAdapter(); + const duplicate = new DefaultCdnCacheAdapter(); + const duplicateFactory = vi.fn(() => duplicate); + registerCdnCacheAdapter(() => first); + registerCdnCacheAdapter(duplicateFactory); + expect(getCdnCacheAdapter()).toBe(first); + expect(duplicateFactory).not.toHaveBeenCalled(); + + setCdnCacheAdapter(duplicate); + expect(getCdnCacheAdapter()).toBe(duplicate); + } finally { + if (previous === undefined) delete globals[adapterKey]; + else globals[adapterKey] = previous; + } + }); + + it("does not evaluate declarative factories after an imperative registration", () => { + const explicit = new DefaultCdnCacheAdapter(); + const factory = vi.fn(() => new DefaultCdnCacheAdapter()); + setCdnCacheAdapter(explicit); + + registerCdnCacheAdapter(factory); + + expect(factory).not.toHaveBeenCalled(); + expect(getCdnCacheAdapter()).toBe(explicit); + }); + + it("retries declarative registration after a factory failure", () => { + const adapterKey = Symbol.for("vinext.cdnCacheAdapter"); + const globals = globalThis as unknown as Record; + const previous = globals[adapterKey]; + delete globals[adapterKey]; + + try { + expect(() => + registerCdnCacheAdapter(() => { + throw new Error("missing binding"); + }), + ).toThrow("missing binding"); + + const retry = new DefaultCdnCacheAdapter(); + registerCdnCacheAdapter(() => retry); + expect(getCdnCacheAdapter()).toBe(retry); + } finally { + if (previous === undefined) delete globals[adapterKey]; + else globals[adapterKey] = previous; + } + }); + it("owns background revalidation (origin-managed ISR)", () => { expect(new DefaultCdnCacheAdapter().ownsBackgroundRevalidation).toBe(true); }); diff --git a/tests/cloudflare-cdn-cache.test.ts b/tests/cloudflare-cdn-cache.test.ts index 2b4ecca05..10bdd6288 100644 --- a/tests/cloudflare-cdn-cache.test.ts +++ b/tests/cloudflare-cdn-cache.test.ts @@ -87,6 +87,13 @@ describe("CloudflareCdnCacheAdapter", () => { expect(adapter.ownsBackgroundRevalidation).toBe(false); }); + it("declares the Cloudflare response-policy headers it owns", () => { + expect(adapter.responsePolicyHeaderNames).toEqual([ + "CDN-Cache-Control", + "Cloudflare-CDN-Cache-Control", + ]); + }); + it("accepts a staged warmup only in its expected Worker version", async () => { const routedAdapter = createCloudflareCdnCacheAdapter({ env: { CF_VERSION_METADATA: { id: "version-b", tag: "", timestamp: "" } }, diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index bec8416b8..b041036e1 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -48,7 +48,11 @@ import { generateAppRouterViteConfig, generatePagesRouterViteConfig, } from "../packages/vinext/src/init-cloudflare.js"; -import { readPagesRouterEntrySource } from "./worker-entry-source.js"; +import { + readPagesResponseStageEntrySource, + readPagesRouterEntrySource, + readPagesSingleEntrySource, +} from "./worker-entry-source.js"; import { scanPublicFileRoutes } from "../packages/vinext/src/utils/public-routes.js"; import { isUnknownRecord } from "../packages/vinext/src/utils/record.js"; import { computeClientRuntimeMetadata } from "../packages/vinext/src/utils/client-runtime-metadata.js"; @@ -1479,8 +1483,8 @@ describe("scanPublicFileRoutes", () => { describe("readPagesRouterEntrySource", () => { it("renders without request-level development asset URLs", () => { - const content = readPagesRouterEntrySource(); - expect(content).toContain("renderPage(req, resolvedUrl, null, ctx, stagedHeaders, options)"); + const content = readPagesResponseStageEntrySource(); + expect(content).toContain("pagesEntry.renderPage("); expect(content).not.toContain("clientEntryUrl"); expect(content).not.toContain("clientPreambleUrl"); }); @@ -1501,9 +1505,9 @@ describe("readPagesRouterEntrySource", () => { }); it("generates valid TypeScript", () => { - const content = readPagesRouterEntrySource(); + const content = readPagesSingleEntrySource(); expect(content).toContain("export default"); - expect(content).toContain("async fetch("); + expect(content).toContain("fetch("); expect(content).toContain("env?: PagesWorkerEnv"); expect(content).toContain("ctx?: PagesWorkerExecutionContext"); expect(content).toContain("Promise"); @@ -1625,14 +1629,13 @@ describe("readPagesRouterEntrySource", () => { it("routes /api/ to handleApiRoute using resolved URL and forwards ctx", () => { const content = readPagesRouterEntrySource(); + const responseContent = readPagesResponseStageEntrySource(); // API routing (including locale prefix stripping) is now inside runPagesRequest. // Worker supplies handleApi dep that wraps handleApiRoute with ctx. // Locale stripping, /api/ prefix check, and ctx forwarding are all inside the owner. expect(content).toContain("handleApi:"); - expect(content).toContain('typeof handleApiRoute === "function"'); - expect(content).toContain( - 'handleApiRoute(req, apiUrl, ctx, new URL(req.url).origin, "worker")', - ); + expect(responseContent).toContain('typeof pagesEntry.handleApiRoute !== "function"'); + expect(responseContent).toContain("pagesEntry.handleApiRoute("); expect(content).toContain("runPagesRequest(request, deps)"); }); @@ -1671,7 +1674,7 @@ describe("readPagesRouterEntrySource", () => { it("delegates image transforms to the configured adapter", () => { const content = readPagesRouterEntrySource(); expect(content).toContain("handleConfiguredImageOptimization("); - expect(content).toContain("env.ASSETS!.fetch"); + expect(content).toContain("assets.fetch("); expect(content).not.toContain("env.IMAGES"); }); @@ -1679,7 +1682,7 @@ describe("readPagesRouterEntrySource", () => { const content = readPagesRouterEntrySource(); expect(content).toContain("serveFilesystemRoute: async"); expect(content).toContain("fetchWorkerFilesystemRoute("); - expect(content).toContain("env.ASSETS!.fetch(assetRequest)"); + expect(content).toContain("assets.fetch(assetRequest)"); expect(content).toContain("publicFiles"); }); @@ -1689,6 +1692,8 @@ describe("readPagesRouterEntrySource", () => { expect(hasPackageExport(exportsMap, "./server/fetch-handler")).toBe(true); expect(hasPackageExport(exportsMap, "./server/app-router-entry")).toBe(true); expect(hasPackageExport(exportsMap, "./server/pages-router-entry")).toBe(true); + expect(hasPackageExport(exportsMap, "./server/request-stage")).toBe(true); + expect(hasPackageExport(exportsMap, "./server/response-stage")).toBe(true); }); it("exports internal deploy dependencies consumed by @vinext/cloudflare", () => { @@ -1867,7 +1872,6 @@ describe("readPagesRouterEntrySource", () => { // now called inside runPagesRequest. The worker delegates to the pipeline. expect(content).toContain("runPagesRequest(request, deps)"); expect(content).toContain('result.type === "response"'); - expect(content).toContain("return finalize("); expect(content).toContain( "finalizeMissingStaticAssetResponse(result.response, missingBuildAsset)", ); @@ -2031,9 +2035,8 @@ describe("readPagesRouterEntrySource", () => { }); it("guards renderPage with typeof check", () => { - const content = readPagesRouterEntrySource(); - // The typeof guard is now in the adapter deps wiring. - expect(content).toContain('typeof renderPage === "function"'); + const content = readPagesResponseStageEntrySource(); + expect(content).toContain('typeof pagesEntry.renderPage !== "function"'); }); it("does not defer error page rendering for data requests", () => { diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 03cb228dc..107be05b7 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import fs from "node:fs"; import os from "node:os"; import vm from "node:vm"; +import { parseAst } from "vite"; import { describe, it, expect } from "vite-plus/test"; import { generateBrowserEntry, @@ -15,9 +16,17 @@ import { toLinkPrefetchRoutes, } from "../packages/vinext/src/entries/app-browser-entry.js"; import { buildAppRscManifestCode } from "../packages/vinext/src/entries/app-rsc-manifest.js"; -import { generateRscEntry } from "../packages/vinext/src/entries/app-rsc-entry.js"; +import { + generateAppRequestRscEntry, + generateAppResponseRscEntry, + generateRscEntry, +} from "../packages/vinext/src/entries/app-rsc-entry.js"; import { generateClientEntry } from "../packages/vinext/src/entries/pages-client-entry.js"; -import { generateServerEntry } from "../packages/vinext/src/entries/pages-server-entry.js"; +import { + generatePagesRequestEntry, + generatePagesResponseEntry, + generateServerEntry, +} from "../packages/vinext/src/entries/pages-server-entry.js"; import { resolveNextConfig } from "../packages/vinext/src/config/next-config.js"; import { buildAppRouteGraph } from "../packages/vinext/src/routing/app-route-graph.js"; import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js"; @@ -1111,6 +1120,92 @@ describe("App Router generated manifest construction", () => { // ── App Router entry template error paths ──────────────────────────── describe("App Router entry templates", () => { + it("generates a parseable module-free App request stage", () => { + const code = generateAppRequestRscEntry( + "/tmp/test/app", + minimalAppRoutes, + null, + [], + "/tmp/test/app/global-error.tsx", + "", + false, + { hasPagesDir: true }, + ); + + expect(() => parseAst(code)).not.toThrow(); + expect(code).not.toContain("/tmp/test/app/page.tsx"); + expect(code).not.toContain("/tmp/test/app/layout.tsx"); + expect(code).not.toContain("/tmp/test/app/global-error.tsx"); + expect(code).toContain( + 'import * as __pagesRequestEntry from "virtual:vinext-pages-request-entry"', + ); + expect(code).not.toContain("virtual:vinext-rsc-entry"); + expect(code).toContain( + 'import { createAppRscRequestHandler } from "vinext/server/app-rsc-handler"', + ); + expect(code).toContain('from "virtual:vinext-cdn-cache-adapter"'); + expect(code).not.toContain('from "virtual:vinext-cache-adapters"'); + expect(code).toContain('dispatchPagesResponseStage(stageRequest, "api")'); + expect(code).toContain( + 'dispatchPagesResponseStage(stageRequest, "page", dataKind, __pagesRequestEntry.hasRequestAwareDocument)', + ); + expect(code).toContain("buildId: process.env.__VINEXT_BUILD_ID ?? null"); + expect(code).toContain("return __dispatchAppRequestStage(request, ctx, dispatchResponseStage"); + expect(code).toContain("handleRequest: __requestHandler"); + expect(code).not.toContain('kind: "app-full-request"'); + expect(code).not.toContain("crypto.randomUUID()"); + expect(code).not.toContain('request.headers.get("upgrade")'); + expect(code).not.toContain("__usesFullRequestGraph"); + expect(code).not.toContain("|| __isMetadataPath(pathname)"); + }); + + it("preserves exact and generated metadata identities in the App request stage", () => { + const code = generateAppRequestRscEntry("/tmp/test/app", minimalAppRoutes, null, [ + { + type: "robots", + isDynamic: true, + filePath: "/tmp/test/app/robots.ts", + routePrefix: "", + routeSegments: [], + servedUrl: "/robots.txt", + contentType: "text/plain", + }, + { + type: "opengraph-image", + isDynamic: true, + filePath: "/tmp/test/app/blog/[slug]/opengraph-image.tsx", + routePrefix: "/blog/[slug]", + routeSegments: ["blog", "[slug]"], + servedUrl: "/blog/[slug]/opengraph-image", + contentType: "image/png", + }, + ]); + + expect(() => parseAst(code)).not.toThrow(); + expect(code).toContain('"patternParts":null,"servedUrl":"/robots.txt"'); + expect(code).toContain('"patternParts":["blog",":slug","opengraph-image"]'); + }); + + it("generates an App response graph without request handling or middleware", () => { + const code = generateAppResponseRscEntry( + "/tmp/test/app", + minimalAppRoutes, + "/tmp/test/middleware.ts", + [], + null, + "", + false, + ); + + expect(() => parseAst(code)).not.toThrow(); + expect(code).not.toContain("/tmp/test/middleware.ts"); + expect(code).not.toContain("createAppRscHandler"); + expect(code).toContain('import "virtual:vinext-pregenerated-concrete-paths";'); + expect(code).toContain("renderAppWorkerResponseStage as __renderAppWorkerResponseStage"); + expect(code).toContain("const __responseStageOptions = {"); + expect(code).toContain("__renderAppWorkerResponseStage(__responseStageOptions"); + }); + it("promotes interception-only RSC targets before not-found dispatch", () => { const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false); @@ -1230,7 +1325,9 @@ describe("App Router entry templates", () => { it("generateRscEntry delegates App Router request handling to the typed helper", () => { const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false); - expect(code).toContain('import { createAppRscHandler } from "vinext/server/app-rsc-handler";'); + expect(code).toMatch( + /import \{ createAppRscHandler \} from "[^"]*app-rsc-combined-handler\.[jt]s";/, + ); expect(code).toContain("const __appRscHandler = createAppRscHandler({"); expect(code).toContain("export default __appRscHandler;"); expect(code).not.toContain("computeRscCacheBustingSearchParam("); @@ -1711,12 +1808,106 @@ describe("Pages Router entry template", () => { expect(code).toContain('dataKind: "server"'); expect(code).toContain('pattern: "/plain",'); expect(code).toContain('dataKind: "none"'); + expect(code).toContain("return __getRuntimePagesDataKind(match.route.module, AppComponent);"); expect(code).not.toContain("typeof page_"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + it("keeps user page and API modules out of the Pages request-stage entry", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-request-stage-")); + const pagesDir = path.join(tmpDir, "pages"); + const middlewarePath = path.join(tmpDir, "middleware.ts"); + const instrumentationPath = path.join(tmpDir, "instrumentation.ts"); + + try { + fs.mkdirSync(path.join(pagesDir, "api"), { recursive: true }); + const pagePath = path.join(pagesDir, "index.tsx"); + const apiPath = path.join(pagesDir, "api", "hello.ts"); + const documentPath = path.join(pagesDir, "_document.tsx"); + fs.writeFileSync( + pagePath, + "export function getStaticProps() { return { props: {} }; } export default function Page() { return null; }", + ); + fs.writeFileSync(apiPath, "export default function handler() {};"); + // Next.js exposes req/res to custom Document getInitialProps for SSG + // renders because getStaticProps pages are not automatic exports. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/render.tsx + fs.writeFileSync( + documentPath, + "const Document = Object.assign(() => null, { getInitialProps: async () => ({ html: '' }) }); export default Document;", + ); + fs.writeFileSync(middlewarePath, "export function middleware() {};"); + fs.writeFileSync(instrumentationPath, "export function register() {};"); + + const code = await generatePagesRequestEntry( + pagesDir, + await resolveNextConfig({ generateBuildId: () => "split-build" }), + createValidFileMatcher(), + middlewarePath, + instrumentationPath, + ["/public.txt"], + ); + + expect(code).toContain('export const buildId = "split-build"'); + expect(code).toContain("export const hasRequestAwareDocument = true"); + expect(code).toContain('dataKind: "static"'); + expect(code).toContain('pattern: "/api/hello"'); + expect(code).toContain("export function matchApiRoute(url, request)"); + expect(code).toContain('export const publicFiles = new Set(["/public.txt"])'); + expect(code).toContain(JSON.stringify(middlewarePath)); + expect(code).not.toContain(JSON.stringify(pagePath)); + expect(code).not.toContain(JSON.stringify(apiPath)); + expect(code).not.toContain(JSON.stringify(documentPath)); + expect(code).not.toContain("react-dom/server.edge"); + expect(code).not.toContain("createPagesPageHandler"); + expect(code).not.toContain("handlePagesApiRoute"); + expect(code).toContain("await __ensureInstrumentationRegistered(_instrumentation)"); + expect(code).not.toContain("await _instrumentation.register()"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("keeps middleware out of the Pages response-stage entry", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-response-stage-")); + const pagesDir = path.join(tmpDir, "pages"); + const middlewarePath = path.join(tmpDir, "middleware.ts"); + const instrumentationPath = path.join(tmpDir, "instrumentation.ts"); + + try { + fs.mkdirSync(path.join(pagesDir, "api"), { recursive: true }); + const pagePath = path.join(pagesDir, "index.tsx"); + const apiPath = path.join(pagesDir, "api", "hello.ts"); + fs.writeFileSync(pagePath, "export default function Page() { return null; }"); + fs.writeFileSync(apiPath, "export default function handler() {};"); + fs.writeFileSync(middlewarePath, "throw new Error('middleware-canary');"); + fs.writeFileSync(instrumentationPath, "export function register() {};"); + + const code = await generatePagesResponseEntry( + pagesDir, + await resolveNextConfig({ generateBuildId: () => "split-build" }), + createValidFileMatcher(), + middlewarePath, + instrumentationPath, + ); + + expect(code).toContain(JSON.stringify(pagePath)); + expect(code).toContain(JSON.stringify(apiPath)); + expect(code).toContain("createPagesPageHandler"); + expect(code).toContain("handlePagesApiRoute"); + expect(code).toContain("export const hasMiddleware = true"); + expect(code).toContain("await __ensureInstrumentationRegistered(_instrumentation)"); + expect(code).not.toContain("await _instrumentation.register()"); + expect(code).not.toContain(JSON.stringify(middlewarePath)); + expect(code).not.toContain("runGeneratedMiddleware"); + expect(code).not.toContain("export async function runMiddleware"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + // Ported from Next.js: test/e2e/no-page-props/no-page-props.test.ts // https://github.com/vercel/next.js/blob/v16.3.0-canary.80/test/e2e/no-page-props/no-page-props.test.ts it("uses the framework error page in server and client entries when _error is absent", async () => { diff --git a/tests/fetch-handler.test.ts b/tests/fetch-handler.test.ts index b0c4a4c19..f5ea211f3 100644 --- a/tests/fetch-handler.test.ts +++ b/tests/fetch-handler.test.ts @@ -3,21 +3,32 @@ import os from "node:os"; import path from "node:path"; import { createServer, type ViteDevServer } from "vite-plus"; import { describe, expect, it } from "vite-plus/test"; -import vinext from "../packages/vinext/src/index.js"; +import { resolveRuntimeEntryModule } from "../packages/vinext/src/entries/runtime-entry-module.js"; +import vinext, { type VinextOptions } from "../packages/vinext/src/index.js"; -async function loadUnifiedFetchHandler(root: string): Promise { +async function loadVirtualModule( + root: string, + id: string, + options: { + cache?: VinextOptions["cache"]; + hostPluginName?: string; + } = {}, +): Promise { let server: ViteDevServer | undefined; try { server = await createServer({ root, configFile: false, - plugins: [vinext()], + plugins: [ + vinext({ cache: options.cache }), + ...(options.hostPluginName ? [{ name: options.hostPluginName }] : []), + ], server: { port: 0 }, logLevel: "silent", }); - const resolved = await server.pluginContainer.resolveId("virtual:vinext-worker-entry"); - expect(resolved?.id).toBe("\0virtual:vinext-worker-entry"); + const resolved = await server.pluginContainer.resolveId(id); + expect(resolved?.id).toBe(`\0${id}`); const loaded = await server.pluginContainer.load(resolved!.id); return typeof loaded === "string" ? loaded : ((loaded as { code?: string })?.code ?? ""); @@ -26,7 +37,14 @@ async function loadUnifiedFetchHandler(root: string): Promise { } } -describe("unified Cloudflare fetch handler", () => { +function loadUnifiedFetchHandler( + root: string, + options: Parameters[2] = {}, +): Promise { + return loadVirtualModule(root, "virtual:vinext-worker-entry", options); +} + +describe("unified Worker fetch handler", () => { it("delegates App Router apps to the App Router worker entry", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-fetch-handler-app-")); try { @@ -60,4 +78,117 @@ describe("unified Cloudflare fetch handler", () => { fs.rmSync(root, { recursive: true, force: true }); } }); + + it("lets a compatible adapter select a transport-neutral multi-stage facade", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-fetch-handler-stages-")); + try { + fs.mkdirSync(path.join(root, "app"), { recursive: true }); + fs.writeFileSync( + path.join(root, "app/page.tsx"), + "export default function Page() { return
app
; }\n", + ); + const entry = "/adapter/stage-gateway.js"; + const cache: VinextOptions["cache"] = { + cdn: { + adapter: "/adapter/cache.js", + output: { + entry, + matchesBuild: ({ plugins }) => + plugins.some(({ name }) => name === "independent-stage-host"), + type: "multi-stage", + }, + }, + }; + + await expect( + loadUnifiedFetchHandler(root, { cache, hostPluginName: "independent-stage-host" }), + ).resolves.toBe( + [ + `export { default } from ${JSON.stringify(entry)};`, + `export * from ${JSON.stringify(entry)};`, + "", + ].join("\n"), + ); + await expect(loadUnifiedFetchHandler(root, { cache })).resolves.toBe( + 'export { default } from "vinext/server/app-router-entry";', + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.each(["app-router-entry", "pages-router-entry"])( + "routes a direct %s main through the selected facade", + async (entryName) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-direct-router-stages-")); + let server: ViteDevServer | undefined; + try { + fs.mkdirSync(path.join(root, "app"), { recursive: true }); + fs.writeFileSync( + path.join(root, "app/page.tsx"), + "export default function Page() { return
app
; }\n", + ); + server = await createServer({ + root, + configFile: false, + plugins: [ + vinext({ + cache: { + cdn: { + adapter: "/adapter/cache.js", + output: { entry: "/adapter/stage-gateway.js", type: "multi-stage" }, + }, + }, + }), + ], + server: { port: 0 }, + logLevel: "silent", + }); + + await expect( + server.pluginContainer.resolveId(`vinext/server/${entryName}`), + ).resolves.toMatchObject({ id: "\0virtual:vinext-worker-entry" }); + } finally { + await server?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.each([ + [ + "App", + "app", + "app/page.tsx", + "export default function Page() { return
app
; }\n", + "app-request-stage-independent-entry", + "app-response-stage-entry", + ], + [ + "Pages", + "pages", + "pages/index.tsx", + "export default function Page() { return
pages
; }\n", + "pages-request-stage-entry", + "pages-response-stage-entry", + ], + ])( + "exposes %s request and response stages as independent virtual entries", + async (_router, directory, file, source, requestEntry, responseEntry) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `vinext-${directory}-stages-`)); + try { + fs.mkdirSync(path.join(root, directory), { recursive: true }); + fs.writeFileSync(path.join(root, file), source); + + await expect(loadVirtualModule(root, "virtual:vinext-request-stage")).resolves.toBe( + `export { handleRequestStage } from ${JSON.stringify(resolveRuntimeEntryModule(requestEntry))};\n`, + ); + await expect(loadVirtualModule(root, "virtual:vinext-response-stage")).resolves.toBe( + `export { handleResponseStage } from ${JSON.stringify(resolveRuntimeEntryModule(responseEntry))};\n`, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); }); diff --git a/tests/inject-pregenerated-paths.test.ts b/tests/inject-pregenerated-paths.test.ts index 30fb6da8b..d84a1863c 100644 --- a/tests/inject-pregenerated-paths.test.ts +++ b/tests/inject-pregenerated-paths.test.ts @@ -4,7 +4,10 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { injectPregeneratedConcretePaths } from "../packages/vinext/src/build/inject-pregenerated-paths.js"; -import { clearPregeneratedConcretePaths } from "../packages/vinext/src/server/pregenerated-concrete-paths.js"; +import { + clearPregeneratedConcretePaths, + PREGENERATED_CONCRETE_PATHS_MODULE, +} from "../packages/vinext/src/server/pregenerated-concrete-paths.js"; let tmpDir: string; @@ -142,6 +145,37 @@ describe("injectPregeneratedConcretePaths", () => { expect(workerEntry).toMatchObject({ renderedPaths: ["/blog/post-a"] }); }); + it("hydrates an independently deployed response-stage entry", async () => { + const registryModuleUrl = pathToFileURL( + path.resolve("packages/vinext/src/server/pregenerated-concrete-paths.ts"), + ).href; + writeFile("dist/server/index.js", "export default { fetch() {} };\n"); + writeFile( + "dist/server/vinext-response-stage.js", + [ + `import "./${PREGENERATED_CONCRETE_PATHS_MODULE}";`, + `import { getRenderedConcreteUrlPathsForRoute, initPregeneratedPathsFromGlobals } from ${JSON.stringify(registryModuleUrl)};`, + "initPregeneratedPathsFromGlobals();", + 'export const renderedPaths = [...(getRenderedConcreteUrlPathsForRoute("/blog/:slug") ?? [])];', + "export default { fetch() {} };", + "", + ].join("\n"), + ); + writeFile( + "dist/server/vinext-prerender.json", + JSON.stringify({ + buildId: "test", + pregeneratedConcretePaths: [["/blog/:slug", ["/blog/post-a"]]], + }), + ); + + injectPregeneratedConcretePaths(tmpDir); + + const entryUrl = pathToFileURL(path.join(tmpDir, "dist/server/vinext-response-stage.js")).href; + const responseEntry: unknown = await import(`${entryUrl}?t=${Date.now()}`); + expect(responseEntry).toMatchObject({ renderedPaths: ["/blog/post-a"] }); + }); + it("strips an earlier injection when the manifest is corrupt", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); writeFile( diff --git a/tests/pages-api-route.test.ts b/tests/pages-api-route.test.ts index 7feac3252..2aeb3e078 100644 --- a/tests/pages-api-route.test.ts +++ b/tests/pages-api-route.test.ts @@ -32,6 +32,30 @@ function createMatch( } describe("pages api route", () => { + it("lets a handler override response headers installed before user code", async () => { + const response = await handlePagesApiRoute({ + initialResponseHeaders: new Headers({ + "Cache-Control": "public, s-maxage=60", + Vary: "x-visitor", + "x-config-variant": "preview", + "x-from-middleware": "present", + }), + match: createMatch((_req, res) => { + expect(res.getHeader("x-config-variant")).toBe("preview"); + expect(res.getHeader("x-from-middleware")).toBe("present"); + res.setHeader("Cache-Control", "private, no-store"); + res.json({ ok: true }); + }), + request: new Request("https://example.com/api/policy"), + url: "/api/policy", + }); + + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + expect(response.headers.get("Vary")).toBe("x-visitor"); + expect(response.headers.get("x-config-variant")).toBe("preview"); + expect(response.headers.get("x-from-middleware")).toBe("present"); + }); + it("does not expose process environment variables on the request", async () => { const previousValue = process.env.VINEXT_API_REQUEST_ENV_TEST; process.env.VINEXT_API_REQUEST_ENV_TEST = "secret"; diff --git a/tests/pages-page-handler.test.ts b/tests/pages-page-handler.test.ts index 0059239bd..026aefce7 100644 --- a/tests/pages-page-handler.test.ts +++ b/tests/pages-page-handler.test.ts @@ -27,6 +27,7 @@ import { PRERENDER_REVALIDATE_HEADER, } from "../packages/vinext/src/server/isr-cache.js"; import { after } from "../packages/vinext/src/shims/server.js"; +import { VINEXT_REVALIDATED_CACHE_TAG_HEADER } from "../packages/vinext/src/server/headers.js"; afterEach(() => setCdnCacheAdapter(new DefaultCdnCacheAdapter())); @@ -187,6 +188,51 @@ describe("createPagesPageHandler — after() lifecycle", () => { }); }); +describe("createPagesPageHandler — pre-render response headers", () => { + it("lets getServerSideProps override config cache policy", async () => { + // Ported from Next.js: + // test/e2e/middleware-custom-matchers/app/pages/index.js + // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-custom-matchers/app/pages/index.js + const handler = createPagesPageHandler( + makeOpts({ + pageRoutes: [ + makeRoute( + "/", + makePageModule({ + getServerSideProps: async ({ + res, + }: { + res: { + getHeader(name: string): string | string[] | number | undefined; + setHeader(name: string, value: string): void; + }; + }) => { + expect(res.getHeader("x-config-variant")).toBe("preview"); + expect(res.getHeader("x-from-middleware")).toBe("present"); + res.setHeader("Cache-Control", "private, no-store"); + return { props: {} }; + }, + }), + ), + ], + }), + ); + + const initialHeaders = new Headers({ + "Cache-Control": "public, s-maxage=60", + Vary: "x-visitor", + "x-config-variant": "preview", + "x-from-middleware": "present", + }); + const response = await handler(makeRequest(), "/", null, null, null, initialHeaders); + + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + expect(response.headers.get("Vary")).toBe("x-visitor"); + expect(response.headers.get("x-config-variant")).toBe("preview"); + expect(response.headers.get("x-from-middleware")).toBe("present"); + }); +}); + // --------------------------------------------------------------------------- // Route miss → 404 fallback // --------------------------------------------------------------------------- @@ -356,7 +402,7 @@ describe("createPagesPageHandler — on-demand terminal responses", () => { }), ); const handler = createPagesPageHandler(makeOpts({ pageRoutes: [route] })); - const request = new Request("http://localhost/redirect", { + const request = new Request("http://localhost/alias", { headers: { [PRERENDER_REVALIDATE_HEADER]: getRevalidateSecret() }, }); @@ -364,6 +410,7 @@ describe("createPagesPageHandler — on-demand terminal responses", () => { expect(response.headers.get("x-nextjs-cache")).toBe("REVALIDATED"); expect(response.headers.get("x-vinext-cache")).toBeNull(); + expect(response.headers.get(VINEXT_REVALIDATED_CACHE_TAG_HEADER)).toBe("_N_T_/redirect"); }); }); @@ -728,6 +775,23 @@ describe("createPagesPageHandler — preview responses", () => { expect(response.headers.get("x-example-cache-tag")).toBe("draft-404"); }); + it("keeps invalid preview-cookie cleanup private", () => { + setCdnCacheAdapter(new CloudflareCdnCacheAdapter()); + const response = finalizePagesPreviewResponse( + new Response("stale preview", { + headers: { + "Cache-Control": "public, max-age=0, must-revalidate", + "CDN-Cache-Control": "public, s-maxage=60", + }, + }), + { data: false, shouldClear: true }, + ); + + expect(response.headers.get("cache-control")).toBe(PAGES_PREVIEW_CACHE_CONTROL); + expect(response.headers.get("cdn-cache-control")).toBeNull(); + expect(response.headers.getSetCookie()).toHaveLength(2); + }); + it("does not expose preview notFound responses to shared Cloudflare caching", async () => { setCdnCacheAdapter(new CloudflareCdnCacheAdapter()); const pageRoute = makeRoute( diff --git a/tests/pages-page-response.test.ts b/tests/pages-page-response.test.ts index 10bc2bc2f..ca04c7e3b 100644 --- a/tests/pages-page-response.test.ts +++ b/tests/pages-page-response.test.ts @@ -296,6 +296,23 @@ describe("pages page response", () => { ); }); + it("reports the resolved Pages tag after an on-demand regeneration", async () => { + const common = createCommonOptions(); + + const response = await renderPagesPageResponse({ + ...common.options, + DocumentComponent: null, + getSSRHeadHTML: undefined, + isOnDemandRevalidate: true, + isrCachePathname: "/posts/resolved", + isrRevalidateSeconds: 60, + routeUrl: "/posts/alias", + }); + + expect(response.headers.get("x-nextjs-cache")).toBe("REVALIDATED"); + expect(response.headers.get("x-vinext-revalidated-cache-tag")).toBe("_N_T_/posts/resolved"); + }); + it("persists indefinite Pages results while formatting a static response policy", async () => { const common = createCommonOptions(); diff --git a/tests/pages-request-pipeline.test.ts b/tests/pages-request-pipeline.test.ts index 46d7c0d76..d2d289fbc 100644 --- a/tests/pages-request-pipeline.test.ts +++ b/tests/pages-request-pipeline.test.ts @@ -694,6 +694,46 @@ describe("middleware", () => { ); }); + // Next.js stages matching headers() rules before middleware and retains them + // on terminal middleware responses. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-utils/resolve-routes.ts + it("preserves matching config headers on terminal middleware redirects", async () => { + const result = await runPagesRequest( + makeRequest("/foo"), + baseDeps({ + configHeaders: [{ source: "/foo", headers: [{ key: "x-config", value: "config" }] }], + runMiddleware: makeMiddleware({ + continue: false, + redirectUrl: "http://localhost/bar", + responseHeaders: new Headers({ "x-middleware": "middleware" }), + }), + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(result.response.headers.get("x-config")).toBe("config"); + expect(result.response.headers.get("x-middleware")).toBe("middleware"); + }); + + it("preserves matching config headers on terminal middleware bodies", async () => { + const result = await runPagesRequest( + makeRequest("/foo"), + baseDeps({ + configHeaders: [{ source: "/foo", headers: [{ key: "x-config", value: "config" }] }], + runMiddleware: makeMiddleware({ + continue: false, + response: new Response("blocked", { headers: { "x-config": "middleware" } }), + }), + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(await result.response.text()).toBe("blocked"); + expect(result.response.headers.get("x-config")).toBe("middleware"); + }); + // Ported from Next.js: test/e2e/middleware-general/test/index.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-general/test/index.test.ts it("does not classify a normal request as data from x-nextjs-data alone", async () => { @@ -1319,11 +1359,49 @@ describe("API routes", () => { expect(result.type).toBe("response"); if (result.type !== "response") return; expect(result.response.status).toBe(200); - expect(handleApi).toHaveBeenCalledWith(expect.any(Request), "/api/users", null); + expect(handleApi).toHaveBeenCalledWith( + expect.any(Request), + "/api/users", + null, + expect.any(Headers), + ); // API responses default a missing content-type to octet-stream, not text/html. expect(result.defaultContentType).toBe("application/octet-stream"); }); + it("passes staged middleware and config response headers to API dispatch", async () => { + const handleApi = vi.fn( + async (_request: Request, _apiUrl: string, _ctx: unknown, stagedHeaders: Headers) => { + expect(stagedHeaders.get("cache-control")).toBe("private, no-store"); + expect(stagedHeaders.get("x-visitor-id")).toBe("visitor-a"); + return new Response("api", { headers: { "x-inner": "kept" } }); + }, + ); + + const result = await runPagesRequest( + makeRequest("/api/users"), + baseDeps({ + configHeaders: [ + { + source: "/api/users", + headers: [{ key: "Cache-Control", value: "private, no-store" }], + }, + ], + handleApi, + hasMiddleware: true, + runMiddleware: makeMiddleware({ + responseHeaders: [["x-visitor-id", "visitor-a"]], + }), + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(result.response.headers.get("cache-control")).toBe("private, no-store"); + expect(result.response.headers.get("x-visitor-id")).toBe("visitor-a"); + expect(result.response.headers.get("x-inner")).toBe("kept"); + }); + it("continues to fallback rewrites when an API path has no route match", async () => { const matchApiRoute = vi.fn().mockReturnValue(null); const proxyExternal = vi.fn(async () => new Response("upstream")); @@ -1586,7 +1664,12 @@ describe("serveFilesystemRoute", () => { "beforeFiles", "/api/rewritten", ); - expect(handleApi).toHaveBeenCalledWith(expect.any(Request), "/api/rewritten", null); + expect(handleApi).toHaveBeenCalledWith( + expect.any(Request), + "/api/rewritten", + null, + expect.any(Headers), + ); }); it("lets a middleware rewrite move a mutation away from an existing public file", async () => { @@ -1620,7 +1703,12 @@ describe("serveFilesystemRoute", () => { "beforeFiles", "/api/from-middleware", ); - expect(handleApi).toHaveBeenCalledWith(expect.any(Request), "/api/from-middleware", null); + expect(handleApi).toHaveBeenCalledWith( + expect.any(Request), + "/api/from-middleware", + null, + expect.any(Headers), + ); }); it("re-enters filesystem matching after a middleware rewrite", async () => { @@ -1704,7 +1792,12 @@ describe("serveFilesystemRoute", () => { ); expect(apiResult.type).toBe("response"); - expect(handleApi).toHaveBeenCalledWith(expect.any(Request), "/api/hello", null); + expect(handleApi).toHaveBeenCalledWith( + expect.any(Request), + "/api/hello", + null, + expect.any(Headers), + ); expect(pageResult.type).toBe("response"); expect(renderPage).toHaveBeenCalledWith( expect.any(Request), @@ -1813,7 +1906,12 @@ describe("afterFiles rewrites", () => { ); expect(result.type).toBe("response"); - expect(handleApi).toHaveBeenCalledWith(expect.any(Request), "/api/hello", null); + expect(handleApi).toHaveBeenCalledWith( + expect.any(Request), + "/api/hello", + null, + expect.any(Headers), + ); }); it("applies afterFiles rewrite when page match is dynamic", async () => { @@ -2097,7 +2195,12 @@ describe("fallback rewrites on 404", () => { ); expect(result.type).toBe("response"); - expect(handleApi).toHaveBeenCalledWith(expect.any(Request), "/api/hello", null); + expect(handleApi).toHaveBeenCalledWith( + expect.any(Request), + "/api/hello", + null, + expect.any(Headers), + ); }); it("uses fallback rewrite when page misses and renders 404", async () => { diff --git a/tests/pages-request-worker-stage.test.ts b/tests/pages-request-worker-stage.test.ts new file mode 100644 index 000000000..6dffd26b6 --- /dev/null +++ b/tests/pages-request-worker-stage.test.ts @@ -0,0 +1,762 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { handleRequestStage } from "../packages/vinext/src/server/pages-request-stage-entry.js"; +import worker from "../packages/vinext/src/server/pages-router-entry.js"; +import { + PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER, + PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, +} from "../packages/vinext/src/server/worker-stages.js"; +import { + VINEXT_CACHEABILITY_PROBE_HEADER, + VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER, + VINEXT_EXPECTED_WORKER_VERSION_HEADER, + VINEXT_PRERENDER_SECRET_HEADER, +} from "../packages/vinext/src/server/headers.js"; +import { serializeWorkerCacheabilityProbeRoute } from "../packages/vinext/src/server/cacheability-request.js"; +import type { DispatchWorkerResponseStage } from "../packages/vinext/src/server/worker-stages.js"; +import type { MiddlewareResult } from "../packages/vinext/src/server/pages-request-pipeline.js"; +import { + runWithExecutionContext, + type ExecutionContextLike, +} from "../packages/vinext/src/shims/request-context.js"; +import { + CACHEABILITY_REQUEST_STATE, + type RouteCacheabilityState, +} from "../packages/vinext/src/shims/cacheability-classification.js"; +import { + DefaultCdnCacheAdapter, + setCdnCacheAdapter, + type CdnCacheAdapter, +} from "../packages/vinext/src/shims/cdn-cache.js"; + +const mocks = vi.hoisted(() => ({ + authorizeOnDemandRevalidate: vi.fn<(value: string | null) => boolean>(() => false), + configHeaders: [] as Array>, + matchApiRoute: vi.fn((url: string) => + url === "/api/hello" + ? { route: { dataKind: "dynamic", isDynamic: false, pattern: "/api/hello" } } + : null, + ), + matchPageRoute: vi.fn< + (url: string) => { + route: { dataKind: string; isDynamic: boolean; pattern: string }; + } | null + >((url: string) => ({ + route: { + dataKind: url.startsWith("/gssp") ? "server" : "static", + isDynamic: false, + pattern: url.startsWith("/gssp") ? "/gssp" : "/page", + }, + })), + registerCacheAdapters: vi.fn(), + registerImageOptimizer: vi.fn(), + renderResponse: vi.fn(), + normalizeDataRequest: vi.fn((request: Request) => ({ + isDataReq: false, + normalizedPathname: null as string | null, + notFoundResponse: null, + request, + })), + runMiddleware: vi.fn<() => Promise>(async () => ({ continue: true })), +})); + +function cacheabilityContext(state: RouteCacheabilityState): ExecutionContextLike { + const context: ExecutionContextLike = { waitUntil() {} }; + Reflect.set(context, CACHEABILITY_REQUEST_STATE, state); + return context; +} + +vi.mock("virtual:vinext-cdn-cache-adapter", () => ({ + registerConfiguredCacheAdapters: mocks.registerCacheAdapters, +})); + +vi.mock("virtual:vinext-cache-adapters", () => ({ + registerConfiguredCacheAdapters: mocks.registerCacheAdapters, +})); + +vi.mock("virtual:vinext-image-adapters", () => ({ + registerConfiguredImageOptimizer: mocks.registerImageOptimizer, +})); + +vi.mock("virtual:vinext-cacheability-manifest", () => ({ default: null })); + +vi.mock("virtual:vinext-pages-request-entry", () => ({ + authorizeOnDemandRevalidate: mocks.authorizeOnDemandRevalidate, + buildId: "request-build", + hasMiddleware: false, + hasRequestAwareDocument: false, + matchApiRoute: mocks.matchApiRoute, + matchPageRoute: mocks.matchPageRoute, + normalizeDataRequest: mocks.normalizeDataRequest, + prerenderSecret: "prerender-secret", + publicFiles: new Set(), + runMiddleware: mocks.runMiddleware, + vinextConfig: { + headers: mocks.configHeaders, + i18n: { defaultLocale: "en", locales: ["en", "fr"] }, + }, +})); + +vi.mock("../packages/vinext/src/server/pages-response-stage-entry.js", () => ({ + renderPagesResponse: mocks.renderResponse, +})); + +describe("Pages Worker request stage", () => { + beforeEach(() => { + setCdnCacheAdapter(new DefaultCdnCacheAdapter()); + mocks.authorizeOnDemandRevalidate.mockReset(); + mocks.authorizeOnDemandRevalidate.mockReturnValue(false); + mocks.matchApiRoute.mockReset(); + mocks.matchPageRoute.mockClear(); + mocks.configHeaders.length = 0; + mocks.matchApiRoute.mockImplementation((url: string) => + url === "/api/hello" + ? { route: { dataKind: "dynamic", isDynamic: false, pattern: "/api/hello" } } + : null, + ); + mocks.registerCacheAdapters.mockReset(); + mocks.registerImageOptimizer.mockReset(); + mocks.renderResponse.mockReset(); + mocks.renderResponse.mockResolvedValue(new Response("local")); + mocks.normalizeDataRequest.mockReset(); + mocks.normalizeDataRequest.mockImplementation((request: Request) => ({ + isDataReq: false, + normalizedPathname: null, + notFoundResponse: null, + request, + })); + mocks.runMiddleware.mockReset(); + mocks.runMiddleware.mockResolvedValue({ continue: true }); + }); + + it("dispatches an ordinary page with a versioned build envelope", async () => { + const dispatch = vi.fn(async () => new Response("remote")); + const response = await handleRequestStage( + new Request("https://example.com/page"), + undefined, + undefined, + dispatch, + ); + + await expect(response.text()).resolves.toBe("remote"); + expect(dispatch).toHaveBeenCalledExactlyOnceWith( + expect.any(Request), + { + buildId: "request-build", + cacheability: { policyHeaders: null, probeMode: null, resolvedRoutePathname: "/page" }, + kind: "pages-page", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + renderOptions: null, + resolvedUrl: "/page", + stagedHeaders: null, + }, + { cache: "shared" }, + ); + expect(mocks.renderResponse).not.toHaveBeenCalled(); + }); + + it("replays POST bodies across speculative miss and error-page dispatches", async () => { + mocks.matchPageRoute.mockImplementationOnce(() => null); + const bodies: string[] = []; + const dispatch = vi.fn(async (stageRequest, props) => { + bodies.push(await stageRequest.text()); + return new Response( + props.kind === "pages-page" && props.renderOptions?.renderErrorPageOnMiss === false + ? "miss" + : "error page", + { status: 404 }, + ); + }); + + const response = await handleRequestStage( + new Request("https://example.com/missing/nested", { + body: "payload", + method: "POST", + }), + undefined, + undefined, + dispatch, + ); + + expect(response.status).toBe(404); + await expect(response.text()).resolves.toBe("error page"); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(bodies).toEqual(["payload", "payload"]); + }); + + it("dispatches authenticated readiness through the response stage", async () => { + // No Next.js test port applies: independently hosted response stages are + // a vinext deployment contract. + const dispatch = vi.fn( + async () => + new Response(null, { + status: 204, + headers: { "Cache-Control": "no-store", "X-Vinext-Prerender-Readiness": "1" }, + }), + ); + const response = await handleRequestStage( + new Request("https://example.com/__vinext/prerender/readiness?attempt=one", { + headers: { + [VINEXT_EXPECTED_WORKER_VERSION_HEADER]: "version-a", + [VINEXT_PRERENDER_SECRET_HEADER]: "prerender-secret", + }, + }), + undefined, + undefined, + dispatch, + ); + + expect(response.status).toBe(204); + expect(dispatch).toHaveBeenCalledExactlyOnceWith( + expect.any(Request), + { + buildId: "request-build", + cacheability: { + policyHeaders: null, + probeMode: null, + resolvedRoutePathname: "/__vinext/prerender/readiness", + }, + kind: "pages-prerender-discovery", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + stagedHeaders: null, + }, + { cache: "bypass" }, + ); + const stageRequest = dispatch.mock.calls[0]?.[0]; + expect(stageRequest?.headers.get(VINEXT_EXPECTED_WORKER_VERSION_HEADER)).toBe("version-a"); + expect(stageRequest?.headers.get(VINEXT_PRERENDER_SECRET_HEADER)).toBeNull(); + }); + + it("keeps pathname-eligible middleware outside shared-stage classification", async () => { + mocks.runMiddleware.mockResolvedValue({ continue: true, pathnameEligible: true }); + const dispatch = vi.fn(async () => new Response("remote")); + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + mode: "admit", + }; + + await runWithExecutionContext(cacheabilityContext(state), () => + handleRequestStage(new Request("https://example.com/page"), undefined, undefined, dispatch), + ); + + expect(dispatch.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + expect(state.forcedDynamicReason).toBeUndefined(); + }); + + it("classifies a terminal middleware probe without dispatching the response stage", async () => { + mocks.runMiddleware.mockResolvedValue({ + continue: false, + pathnameEligible: true, + redirectStatus: 307, + redirectUrl: "https://example.com/login", + }); + const dispatch = vi.fn(async () => new Response("unused")); + const response = await handleRequestStage( + new Request("https://example.com/page?__vinext_cacheability_probe=one", { + headers: { + [VINEXT_CACHEABILITY_PROBE_HEADER]: "1", + [VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER]: serializeWorkerCacheabilityProbeRoute({ + kind: "pages-page", + pattern: "/page", + }), + [VINEXT_PRERENDER_SECRET_HEADER]: "prerender-secret", + }, + }), + undefined, + undefined, + dispatch, + ); + + expect(dispatch).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + kind: "pages-page", + pattern: "/page", + scope: "identity", + state: "dynamic", + status: 307, + version: 1, + }); + }); + + it.each(["/page", "/api/hello"])( + "bypasses shared %s rendering when middleware changes downstream request headers", + async (pathname) => { + // Ported from Next.js: + // test/e2e/middleware-request-header-overrides/test/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-request-header-overrides/test/index.test.ts + mocks.runMiddleware.mockResolvedValue({ + continue: true, + responseHeaders: new Headers({ + "x-middleware-override-headers": "x-visitor", + "x-middleware-request-x-visitor": "visitor-a", + }), + }); + const dispatch = vi.fn(async (request) => + Response.json({ visitor: request.headers.get("x-visitor") }), + ); + + const response = await handleRequestStage( + new Request(`https://example.com${pathname}`), + undefined, + undefined, + dispatch, + ); + + expect(dispatch.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + await expect(response.json()).resolves.toEqual({ visitor: "visitor-a" }); + }, + ); + + it("dispatches a preview request through the non-shared response stage", async () => { + const request = new Request("https://example.com/page", { + headers: { Cookie: "__prerender_bypass=preview" }, + }); + const dispatch = vi.fn(async () => new Response("remote")); + + const response = await handleRequestStage(request, undefined, undefined, dispatch); + + await expect(response.text()).resolves.toBe("remote"); + expect(dispatch).toHaveBeenCalledExactlyOnceWith( + expect.any(Request), + { + buildId: "request-build", + cacheability: { policyHeaders: null, probeMode: null, resolvedRoutePathname: "/page" }, + kind: "pages-page", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + renderOptions: null, + resolvedUrl: "/page", + stagedHeaders: [], + }, + { cache: "bypass" }, + ); + expect(mocks.renderResponse).not.toHaveBeenCalled(); + }); + + it("authenticates probes before filtering and bypasses the shared transport", async () => { + const dispatch = vi.fn(async () => new Response("probe")); + const response = await handleRequestStage( + new Request("https://example.com/page?__vinext_cacheability_probe=retry", { + headers: { + "X-Vinext-Cacheability-Probe": "1", + "X-Vinext-Prerender-Secret": "prerender-secret", + }, + }), + undefined, + undefined, + dispatch, + ); + + await expect(response.text()).resolves.toBe("probe"); + expect(dispatch).toHaveBeenCalledOnce(); + const [request, props, options] = dispatch.mock.calls[0]!; + expect(new URL(request.url).searchParams.has("__vinext_cacheability_probe")).toBe(false); + expect(request.headers.has("X-Vinext-Cacheability-Probe")).toBe(false); + expect(request.headers.has("X-Vinext-Prerender-Secret")).toBe(false); + expect(props.cacheability).toMatchObject({ + policyHeaders: null, + probeMode: "probe", + resolvedRoutePathname: "/page", + }); + expect(options).toEqual({ cache: "bypass" }); + }); + + it("transports matched positive config cache policy using the i18n match path", async () => { + setCdnCacheAdapter({ + buildResponseHeaders: ({ cacheControl }) => ({ "Cache-Control": cacheControl }), + ownsBackgroundRevalidation: false, + responsePolicyHeaderNames: ["CDN-Cache-Control"], + async get() { + return null; + }, + async revalidateTag() {}, + async set() {}, + }); + mocks.configHeaders.push( + { + source: "/en/gssp", + headers: [ + { key: "Cache-Control", value: "public, s-maxage=60" }, + { key: "Vary", value: "x-visitor" }, + ], + }, + { + source: "/en/gssp", + has: [{ type: "cookie", key: "preview", value: "1" }], + headers: [ + { key: "CDN-Cache-Control", value: "public, s-maxage=120" }, + { key: "x-config-variant", value: "preview" }, + ], + }, + ); + const dispatch = vi.fn(async () => new Response("page")); + + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + mode: "admit", + }; + await runWithExecutionContext(cacheabilityContext(state), () => + handleRequestStage( + new Request("https://example.com/gssp", { headers: { Cookie: "preview=1" } }), + undefined, + undefined, + dispatch, + ), + ); + + expect(dispatch.mock.calls[0]?.[1].cacheability.policyHeaders).toEqual([ + ["Cache-Control", "public, s-maxage=60"], + ["CDN-Cache-Control", "public, s-maxage=120"], + ["Vary", "x-visitor"], + ]); + expect(new Headers(dispatch.mock.calls[0]?.[1].stagedHeaders ?? [])).toEqual( + new Headers({ + "Cache-Control": "public, s-maxage=60", + "CDN-Cache-Control": "public, s-maxage=120", + Vary: "x-visitor", + "x-config-variant": "preview", + }), + ); + expect(state.forcedDynamicReason).toBeUndefined(); + }); + + it("transports middleware Vary into the shared stage cache identity", async () => { + mocks.runMiddleware.mockResolvedValue({ + continue: true, + responseHeaders: new Headers({ Vary: "x-visitor" }), + }); + const dispatch = vi.fn(async () => new Response("page")); + + await handleRequestStage( + new Request("https://example.com/page", { headers: { "x-visitor": "one" } }), + undefined, + undefined, + dispatch, + ); + + expect(dispatch.mock.calls[0]?.[1].cacheability.policyHeaders).toEqual([["Vary", "x-visitor"]]); + expect(dispatch.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + }); + + it("lets an outer private config policy override a shared Pages artifact", async () => { + const adapter: CdnCacheAdapter = { + ownsBackgroundRevalidation: false, + responsePolicyHeaderNames: ["CDN-Cache-Control"], + buildResponseHeaders({ cacheControl }) { + return { + "Cache-Control": cacheControl, + "CDN-Cache-Control": null, + }; + }, + async get() { + return null; + }, + async revalidateTag() {}, + async set() {}, + }; + setCdnCacheAdapter(adapter); + mocks.configHeaders.push({ + source: "/en/page", + headers: [{ key: "Cache-Control", value: "private, no-store" }], + }); + const dispatch = vi.fn(async () => + Promise.resolve( + new Response("cached page", { + headers: { + "Cache-Control": "public, max-age=0, must-revalidate", + "CDN-Cache-Control": "public, max-age=60", + }, + }), + ), + ); + + const response = await handleRequestStage( + new Request("https://example.com/page"), + undefined, + undefined, + dispatch, + ); + + expect(dispatch.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(response.headers.get("cdn-cache-control")).toBeNull(); + await expect(response.text()).resolves.toBe("cached page"); + }); + + // Next.js applies middleware/config response headers before rendering, and + // its Pages sender only generates Cache-Control when one is not already set. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/send-payload.ts + it("preserves a positive middleware cache policy outside a shared static artifact", async () => { + mocks.runMiddleware.mockResolvedValue({ + continue: true, + responseHeaders: new Headers({ "Cache-Control": "public, s-maxage=45" }), + }); + const dispatch = vi.fn(async () => + Promise.resolve( + new Response("cached page", { + headers: { "Cache-Control": "public, max-age=0, must-revalidate" }, + }), + ), + ); + + const response = await handleRequestStage( + new Request("https://example.com/page"), + undefined, + undefined, + dispatch, + ); + + expect(dispatch.mock.calls[0]?.[1].stagedHeaders).toBeNull(); + expect(dispatch.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=45"); + }); + + // Next.js installs custom-route headers before invoking Pages handlers, so + // getServerSideProps remains authoritative when it writes the same header. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-server.ts + it("lets getServerSideProps override an earlier private config policy", async () => { + mocks.configHeaders.push({ + source: "/en/gssp", + headers: [{ key: "Cache-Control", value: "private, no-store" }], + }); + const dispatch = vi.fn(async () => + Promise.resolve( + new Response("gssp", { + headers: { "Cache-Control": "public, s-maxage=30" }, + }), + ), + ); + + const response = await handleRequestStage( + new Request("https://example.com/gssp"), + undefined, + undefined, + dispatch, + ); + + expect(dispatch.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=30"); + }); + + it.each([ + ["private, no-store", "public, s-maxage=30"], + ["public, s-maxage=60", "private, no-store"], + ])( + "lets getInitialProps replace config policy %s with %s", + async (configPolicy, renderedPolicy) => { + // Next.js applies custom-route headers before Pages rendering, so page + // and _app getInitialProps response writes remain authoritative. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-server.ts + mocks.matchPageRoute.mockReturnValue({ + route: { dataKind: "none", isDynamic: false, pattern: "/gip" }, + }); + mocks.configHeaders.push({ + source: "/en/gip", + headers: [{ key: "Cache-Control", value: configPolicy }], + }); + const dispatch = vi.fn(async () => + Promise.resolve( + new Response("gip", { + headers: { + "Cache-Control": renderedPolicy, + [PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER]: "request-time", + }, + }), + ), + ); + + const response = await handleRequestStage( + new Request("https://example.com/gip"), + undefined, + undefined, + dispatch, + ); + + expect(dispatch.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + expect(response.headers.get("Cache-Control")).toBe(renderedPolicy); + expect(response.headers.has(PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER)).toBe(false); + }, + ); + + it("dispatches authenticated revalidation through the uncached response stage", async () => { + mocks.authorizeOnDemandRevalidate.mockImplementation((value) => value === "build-secret"); + const dispatch = vi.fn(async () => + Promise.resolve( + new Response(null, { + headers: { [PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER]: "static" }, + }), + ), + ); + + const response = await handleRequestStage( + new Request("https://example.com/page", { + headers: { + "x-prerender-revalidate": "build-secret", + "x-prerender-revalidate-if-generated": "1", + }, + method: "HEAD", + }), + undefined, + undefined, + dispatch, + ); + + expect(dispatch).toHaveBeenCalledOnce(); + expect(dispatch.mock.calls[0]?.[0].method).toBe("HEAD"); + expect(dispatch.mock.calls[0]?.[0].headers.get("x-prerender-revalidate-if-generated")).toBe( + "1", + ); + expect(response.headers.has(PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER)).toBe(false); + expect(dispatch.mock.calls[0]?.[1]).toMatchObject({ + kind: "pages-page", + stagedHeaders: [], + }); + expect(dispatch.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + }); + + it("preserves shared Pages HEAD request semantics and strips the body", async () => { + const dispatch = vi.fn( + async () => new Response("cached-html", { headers: { "x-generation": "one" } }), + ); + + const response = await handleRequestStage( + new Request("https://example.com/page", { method: "HEAD" }), + undefined, + undefined, + dispatch, + ); + + expect(dispatch.mock.calls[0]?.[0].method).toBe("HEAD"); + expect(dispatch.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + expect(response.headers.get("x-generation")).toBe("one"); + expect(response.body).toBeNull(); + }); + + it("preserves request-stage URL normalization for data requests", async () => { + mocks.normalizeDataRequest.mockImplementation((request: Request) => { + const url = new URL(request.url); + url.pathname = "/page"; + return { + isDataReq: true, + normalizedPathname: "/page", + notFoundResponse: null, + request: new Request(url, request), + }; + }); + const dispatch = vi.fn(async () => new Response("data")); + + await handleRequestStage( + new Request("https://example.com/_next/data/request-build/page.json?from=data"), + undefined, + undefined, + dispatch, + ); + + expect(new URL(dispatch.mock.calls[0]![0].url).pathname).toBe("/page"); + expect(dispatch.mock.calls[0]?.[1]).toMatchObject({ + cacheability: { representation: "pages-data" }, + renderOptions: { isDataReq: true }, + resolvedUrl: "/page?from=data", + }); + expect(dispatch.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + }); + + it("dispatches a GET API with the same deployment envelope", async () => { + const dispatch = vi.fn(async () => new Response("api")); + + await handleRequestStage( + new Request("https://example.com/api/hello"), + undefined, + undefined, + dispatch, + ); + + expect(dispatch).toHaveBeenCalledExactlyOnceWith( + expect.any(Request), + { + apiUrl: "/api/hello", + buildId: "request-build", + cacheability: { + policyHeaders: null, + probeMode: null, + resolvedRoutePathname: "/api/hello", + }, + kind: "pages-api", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + stagedHeaders: [], + }, + { cache: "shared" }, + ); + }); + + it("dispatches non-idempotent APIs with cache bypass", async () => { + const dispatch = vi.fn(async () => new Response("api")); + + await handleRequestStage( + new Request("https://example.com/api/hello", { method: "POST", body: "payload" }), + undefined, + undefined, + dispatch, + ); + + expect(dispatch).toHaveBeenCalledExactlyOnceWith( + expect.any(Request), + { + apiUrl: "/api/hello", + buildId: "request-build", + cacheability: { + policyHeaders: null, + probeMode: null, + resolvedRoutePathname: "/api/hello", + }, + kind: "pages-api", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + stagedHeaders: [], + }, + { cache: "bypass" }, + ); + expect(mocks.renderResponse).not.toHaveBeenCalled(); + }); + + it("carries nonce-sensitive staged headers through bypass dispatch", async () => { + const responseHeaders = new Headers({ + "Content-Security-Policy": "script-src 'nonce-request-stage'", + "Set-Cookie": "middleware=one; Path=/", + }); + responseHeaders.append("Set-Cookie", "second=two; Path=/"); + mocks.runMiddleware.mockResolvedValue({ continue: true, responseHeaders }); + const dispatch = vi.fn(async () => new Response("remote")); + + const response = await handleRequestStage( + new Request("https://example.com/page"), + undefined, + undefined, + dispatch, + ); + + expect(dispatch).toHaveBeenCalledOnce(); + const props = dispatch.mock.calls[0]?.[1]; + expect(props).toMatchObject({ kind: "pages-page" }); + expect(new Headers(props?.stagedHeaders ?? [])).toEqual(responseHeaders); + expect(dispatch.mock.calls[0]?.[2]).toEqual({ cache: "bypass" }); + expect(response.headers.get("content-security-policy")).toBe( + "script-src 'nonce-request-stage'", + ); + expect(response.headers.getSetCookie()).toEqual([ + "middleware=one; Path=/", + "second=two; Path=/", + ]); + expect(mocks.renderResponse).not.toHaveBeenCalled(); + }); + + it("keeps the default Pages worker behavior via lazy local rendering", async () => { + const response = await worker.fetch(new Request("https://example.com/page")); + + await expect(response.text()).resolves.toBe("local"); + expect(mocks.renderResponse).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/pages-response-stage.test.ts b/tests/pages-response-stage.test.ts new file mode 100644 index 000000000..fe5b16123 --- /dev/null +++ b/tests/pages-response-stage.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { + getPagesResponseStageCacheDisposition, + shouldDispatchPagesResponseStage, +} from "../packages/vinext/src/server/pages-response-stage.js"; +import { PRERENDER_REVALIDATE_HEADER } from "../packages/vinext/src/utils/protocol-headers.js"; + +function shouldDispatch( + init: RequestInit = {}, + options: { + authorizeOnDemandRevalidate?: (headerValue: string | null) => boolean; + stagedHeaders?: Headers; + } = {}, +): boolean { + return shouldDispatchPagesResponseStage({ + ...options, + request: new Request("https://example.com/page", init), + }); +} + +describe("Pages response-stage dispatch", () => { + it("maps ordinary and request-specific renders to shared and bypass dispatch", () => { + expect( + getPagesResponseStageCacheDisposition({ + request: new Request("https://example.com/page"), + }), + ).toBe("shared"); + expect( + getPagesResponseStageCacheDisposition({ + request: new Request("https://example.com/page", { + headers: { Cookie: "__prerender_bypass=preview" }, + }), + }), + ).toBe("bypass"); + }); + + it.each(["GET", "HEAD"])("allows an ordinary %s request", (method) => { + expect(shouldDispatch({ method })).toBe(true); + }); + + it.each(["POST", "PUT", "PATCH", "DELETE"])("keeps %s in the request stage", (method) => { + expect(shouldDispatch({ method })).toBe(false); + }); + + it.each(["__prerender_bypass", "__next_preview_data"])( + "keeps requests carrying the %s preview cookie local", + (name) => { + expect(shouldDispatch({ headers: { Cookie: `ordinary=1; ${name}=value; last=1` } })).toBe( + false, + ); + }, + ); + + it("does not treat similarly named or unrelated cookies as preview mode", () => { + expect( + shouldDispatch({ + headers: { + Cookie: "session=abc; prefix__prerender_bypass=value; __next_preview_data_extra=value", + }, + }), + ).toBe(true); + }); + + it.each([ + "no-cache", + "NO-STORE", + "max-age=0, no-cache", + "public, no-store, max-age=60", + 'no-cache="set-cookie"', + ])("keeps Cache-Control %s local", (cacheControl) => { + expect(shouldDispatch({ headers: { "Cache-Control": cacheControl } })).toBe(false); + }); + + it.each(["max-age=0", "public, max-age=0, must-revalidate", "no-cacheable=true"])( + "does not broaden bypass semantics for Cache-Control %s", + (cacheControl) => { + expect(shouldDispatch({ headers: { "Cache-Control": cacheControl } })).toBe(true); + }, + ); + + it("keeps authenticated on-demand revalidation local", () => { + const authorize = vi.fn((value: string | null) => value === "build-secret"); + expect( + shouldDispatch( + { headers: { [PRERENDER_REVALIDATE_HEADER]: "build-secret" } }, + { authorizeOnDemandRevalidate: authorize }, + ), + ).toBe(false); + expect(authorize).toHaveBeenCalledWith("build-secret"); + }); + + it("keeps authenticated on-demand revalidation out of the shared transport", () => { + expect( + getPagesResponseStageCacheDisposition({ + authorizeOnDemandRevalidate: (value) => value === "build-secret", + request: new Request("https://example.com/page", { + headers: { [PRERENDER_REVALIDATE_HEADER]: "build-secret" }, + method: "HEAD", + }), + }), + ).toBe("bypass"); + }); + + it("does not let a forged on-demand revalidation header force a bypass", () => { + const authorize = vi.fn(() => false); + expect( + shouldDispatch( + { headers: { [PRERENDER_REVALIDATE_HEADER]: "forged-secret" } }, + { authorizeOnDemandRevalidate: authorize }, + ), + ).toBe(true); + }); + + it("keeps request and staged-response CSP nonces local", () => { + expect( + shouldDispatch({ headers: { "Content-Security-Policy": "script-src 'nonce-request'" } }), + ).toBe(false); + expect( + shouldDispatch( + {}, + { + stagedHeaders: new Headers({ + "Content-Security-Policy": "script-src 'self' 'nonce-middleware'", + }), + }, + ), + ).toBe(false); + }); + + it.each([ + ["Cache-Control", "private, max-age=0"], + ["CDN-Cache-Control", "public, max-age=60, no-store"], + ["Cloudflare-CDN-Cache-Control", "NO-CACHE"], + ])("keeps the inner artifact reusable under outer staged %s: %s", (name, value) => { + expect(shouldDispatch({}, { stagedHeaders: new Headers({ [name]: value }) })).toBe(true); + }); + + it("keeps the inner artifact reusable under outer staged Vary", () => { + expect(shouldDispatch({}, { stagedHeaders: new Headers({ Vary: "x-visitor-id" }) })).toBe(true); + }); + + it("keeps middleware cookie overlays local", () => { + expect( + shouldDispatch( + {}, + { stagedHeaders: new Headers({ "x-middleware-set-cookie": "session=updated" }) }, + ), + ).toBe(false); + }); + + it("keeps middleware request-header overrides out of the shared stage", () => { + expect( + shouldDispatchPagesResponseStage({ + request: new Request("https://example.com/page"), + requestHeadersChanged: true, + }), + ).toBe(false); + }); + + it("keeps getStaticProps renders with request-aware Documents out of the shared stage", () => { + // Next.js passes req/res to `_document.getInitialProps` whenever the page is + // not an automatic static export, including getStaticProps/ISR renders. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/render.tsx + expect( + shouldDispatchPagesResponseStage({ + hasRequestAwareDocument: true, + request: new Request("https://example.com/page"), + routeDataKind: "static", + }), + ).toBe(false); + expect( + shouldDispatchPagesResponseStage({ + hasRequestAwareDocument: false, + request: new Request("https://example.com/page"), + routeDataKind: "static", + }), + ).toBe(true); + expect( + shouldDispatchPagesResponseStage({ + hasRequestAwareDocument: true, + request: new Request("https://example.com/page"), + routeDataKind: "none", + }), + ).toBe(true); + }); +}); diff --git a/tests/pages-revalidate.test.ts b/tests/pages-revalidate.test.ts index 80603f11e..d9d30208f 100644 --- a/tests/pages-revalidate.test.ts +++ b/tests/pages-revalidate.test.ts @@ -5,7 +5,15 @@ import { PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER, } from "../packages/vinext/src/server/isr-cache.js"; import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js"; -import { VINEXT_REVALIDATE_HOST_HEADER } from "../packages/vinext/src/server/headers.js"; +import { + VINEXT_REVALIDATED_CACHE_TAG_HEADER, + VINEXT_REVALIDATE_HOST_HEADER, +} from "../packages/vinext/src/server/headers.js"; +import { + DefaultCdnCacheAdapter, + setCdnCacheAdapter, +} from "../packages/vinext/src/shims/cdn-cache.js"; +import { encodeCacheTag } from "../packages/vinext/src/utils/encode-cache-tag.js"; function stubFetch() { const fetchMock = vi.fn( @@ -57,6 +65,7 @@ function fetchHeadersAt( afterEach(() => { vi.unstubAllGlobals(); + setCdnCacheAdapter(new DefaultCdnCacheAdapter()); }); describe("performOnDemandRevalidate", () => { @@ -101,6 +110,27 @@ describe("performOnDemandRevalidate", () => { expect(url.href).toBe("http://app.local:3000/fixed-page"); }); + it.each([ + ["a rewritten source", "/alias?view=one", "_N_T_/source"], + ["a base-path-stripped source", "/docs/fixed-page", "_N_T_/fixed-page"], + ])("purges the actual regenerated tag for %s", async (_name, requestedPath, regeneratedTag) => { + const fetchMock = vi.fn( + async () => + new Response(null, { + headers: { [VINEXT_REVALIDATED_CACHE_TAG_HEADER]: regeneratedTag }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const adapter = new DefaultCdnCacheAdapter(); + const revalidateTag = vi.spyOn(adapter, "revalidateTag"); + setCdnCacheAdapter(adapter); + + await performOnDemandRevalidate(new Headers({ host: "app.local:3000" }), requestedPath); + + expect(revalidateTag).toHaveBeenCalledExactlyOnceWith(encodeCacheTag(regeneratedTag)); + }); + it("preserves unstable_onlyGenerated on the pinned request", async () => { const fetchMock = stubFetch(); const headers = new Headers({ host: "127.0.0.1:9999" }); @@ -196,6 +226,9 @@ describe("performOnDemandRevalidate", () => { }), ); vi.stubGlobal("fetch", fetchMock); + const adapter = new DefaultCdnCacheAdapter(); + const revalidateTag = vi.spyOn(adapter, "revalidateTag"); + setCdnCacheAdapter(adapter); const headers = new Headers({ host: "app.local:3000" }); await expect(performOnDemandRevalidate(headers, "/fixed-page")).rejects.toThrow( @@ -204,6 +237,7 @@ describe("performOnDemandRevalidate", () => { expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchUrlAt(fetchMock, 0).href).toBe("http://app.local:3000/fixed-page"); + expect(revalidateTag).not.toHaveBeenCalled(); }); it("accepts a terminal external GSP redirect marked REVALIDATED without following it", async () => { diff --git a/tests/pages-route-data-kind.test.ts b/tests/pages-route-data-kind.test.ts new file mode 100644 index 000000000..65020f9b8 --- /dev/null +++ b/tests/pages-route-data-kind.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vite-plus/test"; +import { getRuntimePagesDataKind } from "../packages/vinext/src/server/pages-route-data-kind.js"; + +describe("getRuntimePagesDataKind", () => { + it("uses the loaded page after HOCs and re-exports have run", () => { + expect(getRuntimePagesDataKind({ default: { getInitialProps() {} } }, null)).toBe("initial"); + }); + + it("detects custom _app getInitialProps but not the inherited default", () => { + const inherited = () => ({}); + expect( + getRuntimePagesDataKind({}, { getInitialProps: inherited, origGetInitialProps: inherited }), + ).toBe("none"); + expect( + getRuntimePagesDataKind({}, { getInitialProps() {}, origGetInitialProps: inherited }), + ).toBe("initial"); + }); + + it("keeps getStaticProps authoritative over _app and page initial props", () => { + expect( + getRuntimePagesDataKind( + { default: { getInitialProps() {} }, getStaticProps() {} }, + { getInitialProps() {} }, + ), + ).toBe("static"); + }); + + it("classifies getServerSideProps as request-time", () => { + expect(getRuntimePagesDataKind({ getServerSideProps() {} }, null)).toBe("server"); + }); +}); diff --git a/tests/pages-router-worker-entry.test.ts b/tests/pages-router-worker-entry.test.ts index 0efd622d5..1f3f6531c 100644 --- a/tests/pages-router-worker-entry.test.ts +++ b/tests/pages-router-worker-entry.test.ts @@ -9,10 +9,64 @@ function pagesWorkerEntryVirtualModules(): Plugin { ` export const prerenderSecret = "worker-prerender-secret"; export const vinextConfig = {}; +`, + ], + [ + "virtual:vinext-pages-request-entry", + ` +export const authorizeOnDemandRevalidate = () => false; +export const buildId = "worker-build"; +export const hasMiddleware = false; +export const hasRequestAwareDocument = false; +export const matchApiRoute = () => null; +export const matchPageRoute = () => ({ route: { dataKind: "static", isDynamic: false, pattern: "/page" } }); +export const normalizeDataRequest = (request) => ({ isDataReq: false, normalizedPathname: null, notFoundResponse: null, request }); +export const prerenderSecret = "worker-prerender-secret"; +export const publicFiles = new Set(); +export const runMiddleware = null; +export const vinextConfig = {}; +`, + ], + [ + "virtual:vinext-pages-response-entry", + ` +import { CACHEABILITY_REQUEST_STATE } from "vinext/shims/cacheability-classification"; +export const buildId = "worker-build"; +export const pageRoutes = []; +export const getRuntimePageDataKind = () => "static"; +export async function renderPage(request, _resolvedUrl, _route, ctx) { + const state = ctx[CACHEABILITY_REQUEST_STATE]; + if (state) { + state.route = { kind: "pages-page", pattern: "/page" }; + state.outcome = request.headers.has("x-runtime-dynamic") + ? { cacheable: false, dynamicUsage: true } + : { cacheable: true, cacheControl: "s-maxage=60" }; + } + return new Response("page"); +} `, ], ["virtual:vinext-cacheability-manifest", "export default null;"], - ["virtual:vinext-cache-adapters", "export function registerConfiguredCacheAdapters() {}"], + [ + "virtual:vinext-cache-adapters", + ` +import { setCdnCacheAdapter } from "vinext/shims/cdn-cache"; +export function registerConfiguredCacheAdapters() { + setCdnCacheAdapter({ + buildResponseHeaders({ cacheControl }) { return { "Cache-Control": cacheControl }; }, + ownsBackgroundRevalidation: false, + requiresCompletedResponseAdmission: true, + async get() { return null; }, + async revalidateTag() {}, + async set() {}, + }); +} +`, + ], + [ + "virtual:vinext-cdn-cache-adapter", + `export { registerConfiguredCacheAdapters } from "virtual:vinext-cache-adapters";`, + ], ["virtual:vinext-image-adapters", "export function registerConfiguredImageOptimizer() {}"], ]); @@ -83,4 +137,49 @@ describe("Pages Router production Worker readiness", () => { await server.close(); } }); + + it("finalizes completed-response admission around the whole single-stage Pages pipeline", async () => { + const server = await createServer({ + appType: "custom", + configFile: false, + logLevel: "silent", + plugins: [pagesWorkerEntryVirtualModules()], + resolve: { + alias: { + "vinext/shims": path.resolve(import.meta.dirname, "../packages/vinext/src/shims"), + }, + }, + server: { middlewareMode: true }, + }); + + try { + const entry = (await server.ssrLoadModule( + path.resolve(import.meta.dirname, "../packages/vinext/src/server/pages-router-entry.ts"), + )) as { + default: { + fetch(request: Request, env?: unknown, ctx?: { waitUntil(): void }): Promise; + }; + }; + + const admitted = await entry.default.fetch( + new Request("https://example.com/page", { headers: { Accept: "text/html" } }), + undefined, + { waitUntil() {} }, + ); + expect(admitted.headers.get("cache-control")).toBe("s-maxage=60"); + await expect(admitted.text()).resolves.toBe("page"); + + const dynamic = await entry.default.fetch( + new Request("https://example.com/page", { + headers: { Accept: "text/html", "x-runtime-dynamic": "1" }, + }), + undefined, + { waitUntil() {} }, + ); + expect(dynamic.headers.get("cache-control")).toContain("no-store"); + await expect(dynamic.text()).resolves.toBe("page"); + } finally { + await server.close(); + } + }); }); diff --git a/tests/pages-worker-stages.test.ts b/tests/pages-worker-stages.test.ts new file mode 100644 index 000000000..bbcb226ca --- /dev/null +++ b/tests/pages-worker-stages.test.ts @@ -0,0 +1,668 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { handleResponseStage } from "../packages/vinext/src/server/pages-response-stage-entry.js"; +import { + PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER, + PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, +} from "../packages/vinext/src/server/worker-stages.js"; +import { + DefaultCdnCacheAdapter, + setCdnCacheAdapter, + type CdnCacheAdapter, +} from "../packages/vinext/src/shims/cdn-cache.js"; +import { + CACHEABILITY_REQUEST_STATE, + type RouteCacheabilityState, +} from "../packages/vinext/src/shims/cacheability-classification.js"; +import { + VINEXT_EXPECTED_WORKER_VERSION_HEADER, + VINEXT_PRERENDER_READINESS_HEADER, +} from "../packages/vinext/src/server/headers.js"; +import { finalizePagesPreviewResponse } from "../packages/vinext/src/server/pages-page-handler.js"; + +const stages = vi.hoisted(() => ({ + api: vi.fn(), + getRuntimePageDataKind: vi.fn(() => "none"), + registerCacheAdapters: vi.fn(), + registerImageOptimizer: vi.fn(), + renderPage: vi.fn(), +})); + +const dispatchRequestStage = async () => new Response("request-stage"); + +vi.mock("virtual:vinext-cache-adapters", () => ({ + registerConfiguredCacheAdapters: stages.registerCacheAdapters, +})); + +vi.mock("virtual:vinext-image-adapters", () => ({ + registerConfiguredImageOptimizer: stages.registerImageOptimizer, +})); + +vi.mock("virtual:vinext-pages-response-entry", () => ({ + authorizeOnDemandRevalidate: vi.fn(() => false), + buildId: "test-build", + getRuntimePageDataKind: stages.getRuntimePageDataKind, + handleApiRoute: stages.api, + hasMiddleware: false, + matchPageRoute: null, + normalizeDataRequest: vi.fn(), + publicFiles: new Set(), + renderPage: stages.renderPage, + runMiddleware: null, + vinextConfig: {}, +})); + +vi.mock("virtual:vinext-cacheability-manifest", () => ({ default: null })); + +describe("Pages Worker response stage", () => { + beforeEach(() => { + setCdnCacheAdapter(new DefaultCdnCacheAdapter()); + stages.api.mockReset(); + stages.getRuntimePageDataKind.mockReset(); + stages.getRuntimePageDataKind.mockReturnValue("none"); + stages.registerCacheAdapters.mockReset(); + stages.registerImageOptimizer.mockReset(); + stages.renderPage.mockReset(); + }); + + it("stamps trusted request-time policy ownership from loaded Pages modules", async () => { + stages.getRuntimePageDataKind.mockReturnValue("initial"); + stages.renderPage.mockResolvedValue(new Response("gip")); + + const response = await handleResponseStage( + new Request("https://example.com/page"), + undefined, + undefined, + { + buildId: "test-build", + cacheability: { + policyHeaders: null, + probeMode: null, + resolvedRoutePathname: "/page", + }, + kind: "pages-page", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + renderOptions: null, + resolvedUrl: "/page", + stagedHeaders: null, + }, + dispatchRequestStage, + { cache: "shared" }, + ); + + expect(response.headers.get(PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER)).toBe("request-time"); + }); + + it("rejects malformed stage descriptions before dispatch", async () => { + const response = await handleResponseStage( + new Request("https://example.com/page"), + undefined, + undefined, + { kind: "pages-api" } as never, + dispatchRequestStage, + { cache: "shared" }, + ); + + expect(response.status).toBe(400); + expect(stages.api).not.toHaveBeenCalled(); + expect(stages.renderPage).not.toHaveBeenCalled(); + }); + + it("validates readiness from inside the response stage", async () => { + const validateRequest = vi.fn(() => null); + const adapter: CdnCacheAdapter = { + ownsBackgroundRevalidation: false, + async get() { + return null; + }, + async set() {}, + buildResponseHeaders() { + return {}; + }, + validateRequest, + async revalidateTag() {}, + }; + stages.registerCacheAdapters.mockImplementation(() => setCdnCacheAdapter(adapter)); + const request = new Request( + "https://example.com/__vinext/prerender/readiness?attempt=response-stage", + { headers: { [VINEXT_EXPECTED_WORKER_VERSION_HEADER]: "version-a" } }, + ); + + const response = await handleResponseStage( + request, + { binding: "value" }, + undefined, + { + buildId: "test-build", + cacheability: { + policyHeaders: null, + probeMode: null, + resolvedRoutePathname: "/__vinext/prerender/readiness", + }, + kind: "pages-prerender-discovery", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + stagedHeaders: null, + }, + dispatchRequestStage, + { cache: "bypass" }, + ); + + expect(response.status).toBe(204); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(response.headers.get(VINEXT_PRERENDER_READINESS_HEADER)).toBe("1"); + expect(stages.registerCacheAdapters).toHaveBeenCalledWith({ binding: "value" }); + expect(validateRequest).toHaveBeenCalledWith(request); + expect(stages.renderPage).not.toHaveBeenCalled(); + }); + + it("renders a page with no outer middleware response headers", async () => { + const request = new Request("https://example.com/original", { + headers: { "x-post-middleware": "kept" }, + }); + const response = new Response("page"); + stages.renderPage.mockResolvedValue(response); + + const rendered = await handleResponseStage( + request, + { binding: "value" }, + undefined, + { + buildId: "test-build", + cacheability: { + policyHeaders: null, + probeMode: null, + resolvedRoutePathname: "/rewritten", + }, + kind: "pages-page", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + renderOptions: { isDataReq: true }, + resolvedUrl: "/rewritten?slug=one", + stagedHeaders: null, + }, + dispatchRequestStage, + { cache: "shared" }, + ); + + await expect(rendered.text()).resolves.toBe("page"); + expect(rendered.headers.get(PAGES_RESPONSE_STAGE_POLICY_OWNER_HEADER)).toBe("static"); + + expect(stages.registerCacheAdapters).toHaveBeenCalledWith({ binding: "value" }); + expect(stages.registerImageOptimizer).toHaveBeenCalledWith({ binding: "value" }); + expect(stages.renderPage).toHaveBeenCalledExactlyOnceWith( + request, + "/rewritten?slug=one", + null, + expect.any(Object), + expect.any(Headers), + { isDataReq: true }, + expect.any(Headers), + ); + const stagedHeaders = stages.renderPage.mock.calls[0]?.[4] as Headers; + expect([...stagedHeaders]).toEqual([]); + }); + + it("strips stale Content-Length before a streamed page crosses the stage transport", async () => { + const streamed = new Response("streamed page", { + headers: { + "Content-Length": "1", + "Content-Type": "text/html; charset=utf-8", + }, + }) as Response & { __vinextStreamedHtmlResponse?: boolean }; + streamed.__vinextStreamedHtmlResponse = true; + stages.renderPage.mockResolvedValue(streamed); + + const response = await handleResponseStage( + new Request("https://example.com/page"), + undefined, + undefined, + { + buildId: "test-build", + cacheability: { + policyHeaders: null, + probeMode: null, + resolvedRoutePathname: "/page", + }, + kind: "pages-page", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + renderOptions: null, + resolvedUrl: "/page", + stagedHeaders: null, + }, + dispatchRequestStage, + { cache: "bypass" }, + ); + + expect(response.headers.get("Content-Length")).toBeNull(); + expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8"); + await expect(response.text()).resolves.toBe("streamed page"); + }); + + it("strips stale Content-Length after preview response finalization", async () => { + // Next.js preview renders still flow through its normal HTML sender; applying + // the preview no-store policy must not turn a streamed body into a fixed-length one. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/send-payload.ts + const streamed = new Response("preview page", { + headers: { "Content-Length": "1", "Content-Type": "text/html; charset=utf-8" }, + }) as Response & { __vinextStreamedHtmlResponse?: boolean }; + streamed.__vinextStreamedHtmlResponse = true; + stages.renderPage.mockResolvedValue( + finalizePagesPreviewResponse(streamed, { data: { enabled: true }, shouldClear: false }), + ); + + const response = await handleResponseStage( + new Request("https://example.com/page", { + headers: { Cookie: "__prerender_bypass=preview" }, + }), + undefined, + undefined, + { + buildId: "test-build", + cacheability: { policyHeaders: null, probeMode: null, resolvedRoutePathname: "/page" }, + kind: "pages-page", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + renderOptions: null, + resolvedUrl: "/page", + stagedHeaders: [], + }, + dispatchRequestStage, + { cache: "bypass" }, + ); + + expect(response.headers.get("Content-Length")).toBeNull(); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + await expect(response.text()).resolves.toBe("preview page"); + }); + + it("preserves the dynamic Pages data short-circuit without an HTML render", async () => { + const response = new Response('{"pageProps":{"dynamic":true}}', { + headers: { "Content-Type": "application/json" }, + }); + stages.renderPage.mockResolvedValue(response); + + await expect( + handleResponseStage( + new Request("https://example.com/page"), + undefined, + undefined, + { + buildId: "test-build", + cacheability: { policyHeaders: null, probeMode: null, resolvedRoutePathname: "/page" }, + kind: "pages-page", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + renderOptions: { isDataReq: true }, + resolvedUrl: "/page", + stagedHeaders: null, + }, + dispatchRequestStage, + { cache: "shared" }, + ).then((result) => result.text()), + ).resolves.toBe('{"pageProps":{"dynamic":true}}'); + expect(stages.renderPage).toHaveBeenCalledExactlyOnceWith( + expect.any(Request), + "/page", + null, + expect.any(Object), + expect.any(Headers), + { isDataReq: true }, + expect.any(Headers), + ); + }); + + it("admits normalized Pages data using its trusted representation", async () => { + const adapter: CdnCacheAdapter = { + buildResponseHeaders: ({ cacheControl }) => ({ "Cache-Control": cacheControl }), + ownsBackgroundRevalidation: false, + requiresCompletedResponseAdmission: true, + async get() { + return null; + }, + async revalidateTag() {}, + async set() {}, + }; + stages.registerCacheAdapters.mockImplementation(() => setCdnCacheAdapter(adapter)); + stages.renderPage.mockImplementation(async (...args: unknown[]) => { + const context = args[3] as Record; + const state = context[CACHEABILITY_REQUEST_STATE] as RouteCacheabilityState; + expect(state.admission?.representation).toBe("pages-data"); + state.route = { kind: "pages-page", pattern: "/page" }; + state.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + return new Response('{"pageProps":{"cached":true}}'); + }); + + const response = await handleResponseStage( + new Request("https://example.com/page"), + undefined, + undefined, + { + buildId: "test-build", + cacheability: { + policyHeaders: null, + probeMode: null, + representation: "pages-data", + resolvedRoutePathname: "/page", + }, + kind: "pages-page", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + renderOptions: { isDataReq: true }, + resolvedUrl: "/page", + stagedHeaders: null, + }, + dispatchRequestStage, + { cache: "shared" }, + ); + + expect(response.headers.get("Cache-Control")).toBe("s-maxage=60"); + await expect(response.json()).resolves.toEqual({ pageProps: { cached: true } }); + }); + + it("dispatches a Pages API with its resolved URL and no outer composition", async () => { + const request = new Request("https://example.com/original"); + const response = new Response("api"); + stages.api.mockResolvedValue(response); + + await expect( + handleResponseStage( + request, + undefined, + undefined, + { + apiUrl: "/api/rewritten?slug=one", + buildId: "test-build", + cacheability: { + policyHeaders: null, + probeMode: null, + resolvedRoutePathname: "/api/rewritten", + }, + kind: "pages-api", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + stagedHeaders: null, + }, + dispatchRequestStage, + { cache: "shared" }, + ), + ).resolves.toBe(response); + + expect(stages.api).toHaveBeenCalledExactlyOnceWith( + request, + "/api/rewritten?slug=one", + expect.any(Object), + "https://example.com", + "node", + expect.any(Headers), + ); + expect(stages.renderPage).not.toHaveBeenCalled(); + }); + + it("installs complete request-stage headers before Pages API user code", async () => { + stages.api.mockImplementation(async (...args: unknown[]) => { + const initialHeaders = args[5] as Headers; + expect(initialHeaders.get("x-config-variant")).toBe("preview"); + expect(initialHeaders.get("x-from-middleware")).toBe("present"); + return new Response("api"); + }); + + await handleResponseStage( + new Request("https://example.com/api/headers"), + undefined, + undefined, + { + apiUrl: "/api/headers", + buildId: "test-build", + cacheability: { + policyHeaders: [["Cache-Control", "public, s-maxage=60"]], + probeMode: null, + resolvedRoutePathname: "/api/headers", + }, + kind: "pages-api", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + stagedHeaders: [ + ["cache-control", "public, s-maxage=60"], + ["x-config-variant", "preview"], + ["x-from-middleware", "present"], + ], + }, + dispatchRequestStage, + { cache: "shared" }, + ); + }); + + it("installs middleware and config headers before a staged GSSP render", async () => { + // Ported from Next.js: + // test/e2e/middleware-custom-matchers/app/pages/index.js + // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-custom-matchers/app/pages/index.js + stages.renderPage.mockImplementation(async (...args: unknown[]) => { + const initialHeaders = args[6] as Headers; + expect(initialHeaders.get("x-config-variant")).toBe("preview"); + expect(initialHeaders.get("x-from-middleware")).toBe("present"); + return new Response("page"); + }); + + await handleResponseStage( + new Request("https://example.com/gssp"), + undefined, + undefined, + { + buildId: "test-build", + cacheability: { + policyHeaders: [["Cache-Control", "public, s-maxage=60"]], + probeMode: null, + resolvedRoutePathname: "/gssp", + }, + kind: "pages-page", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + renderOptions: null, + resolvedUrl: "/gssp", + stagedHeaders: [ + ["cache-control", "public, s-maxage=60"], + ["x-config-variant", "preview"], + ["x-from-middleware", "present"], + ], + }, + dispatchRequestStage, + { cache: "shared" }, + ); + }); + + it("admits an explicitly public Pages API response", async () => { + const adapter: CdnCacheAdapter = { + buildResponseHeaders: ({ cacheControl }) => ({ "Cache-Control": cacheControl }), + ownsBackgroundRevalidation: false, + requiresCompletedResponseAdmission: true, + responseVary: "verbatim", + async get() { + return null; + }, + async revalidateTag() {}, + async set() {}, + }; + stages.registerCacheAdapters.mockImplementation(() => setCdnCacheAdapter(adapter)); + stages.api.mockResolvedValue( + new Response("public api", { headers: { "Cache-Control": "public, s-maxage=60" } }), + ); + + const response = await handleResponseStage( + new Request("https://example.com/api/public"), + undefined, + undefined, + { + apiUrl: "/api/public", + buildId: "test-build", + cacheability: { + policyHeaders: null, + probeMode: null, + resolvedRoutePathname: "/api/public", + }, + kind: "pages-api", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + stagedHeaders: null, + }, + dispatchRequestStage, + { cache: "shared" }, + ); + + expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=60"); + await expect(response.text()).resolves.toBe("public api"); + }); + + it("does not let config public policy overwrite a Pages API private response", async () => { + const adapter: CdnCacheAdapter = { + buildResponseHeaders: ({ cacheControl }) => ({ "Cache-Control": cacheControl }), + ownsBackgroundRevalidation: false, + requiresCompletedResponseAdmission: true, + responseVary: "verbatim", + async get() { + return null; + }, + async revalidateTag() {}, + async set() {}, + }; + stages.registerCacheAdapters.mockImplementation(() => setCdnCacheAdapter(adapter)); + stages.api.mockImplementation(async (...args: unknown[]) => { + const initialHeaders = args[5] as Headers; + expect(initialHeaders.get("Cache-Control")).toBe("public, s-maxage=60"); + return new Response("private api", { + headers: { "Cache-Control": "private, no-store" }, + }); + }); + + const response = await handleResponseStage( + new Request("https://example.com/api/private"), + undefined, + undefined, + { + apiUrl: "/api/private", + buildId: "test-build", + cacheability: { + policyHeaders: [["Cache-Control", "public, s-maxage=60"]], + probeMode: null, + resolvedRoutePathname: "/api/private", + }, + kind: "pages-api", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + stagedHeaders: null, + }, + dispatchRequestStage, + { cache: "shared" }, + ); + + expect(response.headers.get("Cache-Control")).toBe("no-store, must-revalidate"); + await expect(response.text()).resolves.toBe("private api"); + }); + + it("preserves an adapter-supplied Worker runtime for Pages API responses", async () => { + const request = new Request("https://example.com/original"); + stages.api.mockResolvedValue(new Response("api")); + + await handleResponseStage( + request, + undefined, + { hostRuntime: "worker", waitUntil() {} }, + { + apiUrl: "/api/worker", + buildId: "test-build", + cacheability: { + policyHeaders: null, + probeMode: null, + resolvedRoutePathname: "/api/worker", + }, + kind: "pages-api", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + stagedHeaders: null, + }, + dispatchRequestStage, + { cache: "shared" }, + ); + + expect(stages.api.mock.calls[0]?.[4]).toBe("worker"); + }); + + it("rejects a response-stage deployment mismatch before rendering", async () => { + const response = await handleResponseStage( + new Request("https://example.com/page"), + undefined, + undefined, + { + buildId: "older-build", + cacheability: { policyHeaders: null, probeMode: null, resolvedRoutePathname: "/page" }, + kind: "pages-page", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + renderOptions: null, + resolvedUrl: "/page", + stagedHeaders: null, + }, + dispatchRequestStage, + { cache: "shared" }, + ); + + expect(response.status).toBe(409); + expect(stages.api).not.toHaveBeenCalled(); + expect(stages.renderPage).not.toHaveBeenCalled(); + }); + + it("rejects a response-stage host mismatch before rendering", async () => { + const response = await handleResponseStage( + new Request("https://second.example/page"), + undefined, + undefined, + { + buildId: "test-build", + cacheability: { policyHeaders: null, probeMode: null, resolvedRoutePathname: "/page" }, + kind: "pages-page", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + renderOptions: null, + requestHost: "first.example", + resolvedUrl: "/page", + stagedHeaders: null, + }, + dispatchRequestStage, + { cache: "shared" }, + ); + + expect(response.status).toBe(400); + expect(stages.renderPage).not.toHaveBeenCalled(); + }); + + it("reconstructs bypass-stage headers for nonce and cookie-sensitive page renders", async () => { + stages.renderPage.mockResolvedValue(new Response("page")); + const stagedHeaders: Array<[string, string]> = [ + ["content-security-policy", "script-src 'nonce-stage'"], + ["set-cookie", "first=one; Path=/"], + ["set-cookie", "second=two; Path=/"], + ]; + + await handleResponseStage( + new Request("https://example.com/page"), + undefined, + undefined, + { + buildId: "test-build", + cacheability: { policyHeaders: null, probeMode: null, resolvedRoutePathname: "/page" }, + kind: "pages-page", + protocolVersion: PAGES_RESPONSE_STAGE_PROTOCOL_VERSION, + requestHost: "example.com", + renderOptions: null, + resolvedUrl: "/page", + stagedHeaders, + }, + dispatchRequestStage, + { cache: "bypass" }, + ); + + const reconstructed = stages.renderPage.mock.calls[0]?.[4] as Headers; + expect(reconstructed.get("content-security-policy")).toBe("script-src 'nonce-stage'"); + expect(reconstructed.getSetCookie()).toEqual(["first=one; Path=/", "second=two; Path=/"]); + }); +}); diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 88c74c4ac..dd60fb995 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -253,6 +253,46 @@ describe("prerender path manifest", () => { }); }); + it("does not prune a pattern whose adapter cache policy varies by pathname", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/policy/[slug]/page.tsx", + [ + "export const dynamic = 'force-dynamic';", + "export function generateStaticParams() { return [{ slug: 'ordinary' }, { slug: 'special' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile( + "next.config.mjs", + [ + "export default {", + " headers: async () => [{", + " source: '/policy/special',", + " headers: [{ key: 'CDN-Cache-Control', value: 'public, s-maxage=60' }],", + " }],", + "};", + ].join("\n"), + ); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + buildIdentity: "response-header", + responsePolicyHeaderNames: ["CDN-Cache-Control"], + responseVary: "verbatim", + }); + + expect(manifest?.routePatterns).toMatchObject({ + "/policy/ordinary": { cacheabilityProbe: { canPrunePattern: false } }, + "/policy/special": { cacheabilityProbe: { canPrunePattern: false } }, + }); + }); + it("discovers only Next.js-static Route Handler GET identities", async () => { // Ported from Next.js static eligibility and dynamic Route Handler params: // packages/next/src/server/route-modules/app-route/helpers/is-static-gen-enabled.ts @@ -628,13 +668,17 @@ describe("prerender path manifest", () => { ]); const nextConfig = await resolveNextConfig( { - rewrites: () => [ - { - source: "/rewrite-me", - destination: "/safe", - has: [{ type: "header", key: "x-route-variant", value: "1" }], - }, - ], + rewrites: () => ({ + beforeFiles: [ + { + source: "/rewrite-me", + destination: "/safe", + has: [{ type: "header", key: "x-route-variant", value: "1" }], + }, + ], + afterFiles: [], + fallback: [], + }), }, tmpDir, ); @@ -651,7 +695,374 @@ describe("prerender path manifest", () => { expect(manifest?.loadingShellPaths).toEqual(["/safe"]); }); - it("excludes warm paths shadowed by configured redirects", async () => { + it("warms rewrite source paths when routing runs in an uncached stage", async () => { + // Rewrite-aware prefetches can resolve a public URL to a different route: + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/concurrent-navigations/mismatching-prefetch.test.ts + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/rewrite-me/page.tsx", + "export const revalidate = 60; export default function Page() {}\n", + ); + writeFile("app/rewrite-me/loading.tsx", "export default function Loading() { return null; }\n"); + writeFile( + "app/safe/page.tsx", + "export const revalidate = 60; export default function Page() {}\n", + ); + writeFile("app/safe/loading.tsx", "export default function Loading() { return null; }\n"); + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const nextConfig = await resolveNextConfig( + { + rewrites: () => ({ + beforeFiles: [ + { + source: "/rewrite-me", + destination: "/safe", + has: [{ type: "header", key: "x-route-variant", value: "1" }], + }, + ], + afterFiles: [], + fallback: [], + }), + }, + tmpDir, + ); + + const manifest = await emitPrerenderPathManifest({ + nextConfig, + requestRouting: "uncached-stage", + responseVary: "verbatim", + root: tmpDir, + }); + + expect(manifest?.paths).toEqual(["/rewrite-me", "/safe"]); + expect(manifest?.excludedWarmPaths).toBeUndefined(); + expect(manifest?.rscPaths).toEqual(["/rewrite-me", "/safe"]); + expect(manifest?.loadingShellPaths).toEqual(["/rewrite-me", "/safe"]); + expect(manifest?.routePatterns?.["/rewrite-me"]?.cacheabilityProbe).toMatchObject({ + routeMayResolve: true, + }); + expect(manifest?.routePatterns?.["/safe"]?.cacheabilityProbe?.routeMayResolve).toBeUndefined(); + }); + + it("retains external rewrite sources only when routing runs in an uncached stage", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/external/page.tsx", + "export const revalidate = 60; export default function Page() {}\n", + ); + writeFile( + "app/safe/page.tsx", + "export const revalidate = 60; export default function Page() {}\n", + ); + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const nextConfig = await resolveNextConfig( + { + rewrites: () => ({ + beforeFiles: [ + { + source: "/external", + destination: "https://upstream.example/:path*", + has: [{ type: "header", key: "x-use-external", value: "1" }], + }, + ], + afterFiles: [], + fallback: [], + }), + }, + tmpDir, + ); + + const singleStageManifest = await emitPrerenderPathManifest({ + nextConfig, + responseVary: "verbatim", + root: tmpDir, + }); + + expect(singleStageManifest?.paths).toEqual(["/safe"]); + expect(singleStageManifest?.excludedWarmPaths).toEqual(["/external"]); + expect(singleStageManifest?.routePatterns?.["/external"]).toBeUndefined(); + + const stagedManifest = await emitPrerenderPathManifest({ + nextConfig, + requestRouting: "uncached-stage", + responseVary: "verbatim", + root: tmpDir, + }); + + expect(stagedManifest?.paths).toEqual(["/external", "/safe"]); + expect(stagedManifest?.excludedWarmPaths).toBeUndefined(); + expect(stagedManifest?.routePatterns?.["/external"]?.cacheabilityProbe).toMatchObject({ + requestStageMayTerminate: true, + }); + }); + + it.each(["afterFiles", "fallback"] as const)( + "keeps non-dynamic App routes ahead of %s rewrites", + async (phase) => { + // Next.js checks non-dynamic pages before afterFiles and every matched + // route before fallback: + // https://github.com/vercel/next.js/blob/canary/docs/01-app/03-api-reference/05-config/01-next-config-js/rewrites.mdx + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/local/page.tsx", + "export const revalidate = 60; export default function Page() {}\n", + ); + writeFile( + "app/external/page.tsx", + "export const revalidate = 60; export default function Page() {}\n", + ); + writeFile( + "app/target/page.tsx", + "export const revalidate = 60; export default function Page() {}\n", + ); + writeFile( + "app/api/static/route.ts", + "export const revalidate = 60; export function GET() { return new Response('ok'); }\n", + ); + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const rewrites = [ + { source: "/local", destination: "/target" }, + { source: "/external", destination: "https://upstream.example/local" }, + { source: "/api/static", destination: "https://upstream.example/api" }, + ]; + const nextConfig = await resolveNextConfig( + { + rewrites: () => ({ + beforeFiles: [], + afterFiles: phase === "afterFiles" ? rewrites : [], + fallback: phase === "fallback" ? rewrites : [], + }), + }, + tmpDir, + ); + + const manifest = await emitPrerenderPathManifest({ + nextConfig, + requestRouting: "uncached-stage", + responseVary: "verbatim", + root: tmpDir, + }); + + expect(manifest?.paths).toHaveLength(3); + expect(manifest?.paths).toEqual(expect.arrayContaining(["/external", "/local", "/target"])); + expect(manifest?.routeHandlerPaths).toEqual(["/api/static"]); + expect(manifest?.excludedWarmPaths).toBeUndefined(); + expect(manifest?.routePatterns?.["/external"]).toBeDefined(); + expect( + manifest?.routePatterns?.["/local"]?.cacheabilityProbe?.routeMayResolve, + ).toBeUndefined(); + }, + ); + + it("still lets external afterFiles rewrites shadow discovered dynamic App paths", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/cached/[slug]/page.tsx", + [ + "export const revalidate = 60;", + "export function generateStaticParams() { return []; }", + "export default function Page() {}", + ].join("\n"), + ); + writeFile( + "app/target/page.tsx", + "export const revalidate = 60; export default function Page() {}\n", + ); + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const nextConfig = await resolveNextConfig( + { + rewrites: () => ({ + beforeFiles: [], + afterFiles: [ + { + source: "/cached/intro", + destination: "https://upstream.example/intro", + }, + { source: "/cached/featured", destination: "/target" }, + ], + fallback: [], + }), + }, + tmpDir, + ); + + const manifest = await emitPrerenderPathManifest({ + nextConfig, + requestRouting: "uncached-stage", + responseVary: "verbatim", + root: tmpDir, + }); + + expect(manifest?.paths).toHaveLength(3); + expect(manifest?.paths).toEqual( + expect.arrayContaining(["/cached/featured", "/cached/intro", "/target"]), + ); + expect(manifest?.excludedWarmPaths).toBeUndefined(); + expect( + manifest?.routePatterns?.["/cached/intro"]?.cacheabilityProbe?.requestStageMayTerminate, + ).toBe(true); + expect(manifest?.routePatterns?.["/cached/featured"]?.cacheabilityProbe?.routeMayResolve).toBe( + true, + ); + }); + + it("keeps a non-dynamic Pages route ahead of an external afterFiles rewrite", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/entry.js", "export default {};\n"); + writeFile("pages/about.tsx", "export default function Page() {}\n"); + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const nextConfig = await resolveNextConfig( + { + rewrites: () => ({ + beforeFiles: [], + afterFiles: [{ source: "/about", destination: "https://upstream.example/about" }], + fallback: [], + }), + }, + tmpDir, + ); + + const manifest = await emitPrerenderPathManifest({ + nextConfig, + requestRouting: "uncached-stage", + responseVary: "verbatim", + root: tmpDir, + }); + + expect(manifest?.paths).toEqual(["/about"]); + expect(manifest?.pagesPaths).toEqual(["/about"]); + expect(manifest?.excludedWarmPaths).toBeUndefined(); + }); + + it.each([ + { + destination: "https://upstream.example/first", + expectedTerminal: true, + phase: "afterFiles" as const, + source: "/first", + }, + { + destination: "https://upstream.example/:path*", + expectedTerminal: false, + phase: "fallback" as const, + source: "/:path*", + }, + ])("applies Pages dynamic-route precedence around $phase rewrites", async (testCase) => { + // The fallback case is ported from Next.js: + // test/e2e/fallback-false-rewrite/fallback-false-rewrite.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/fallback-false-rewrite/fallback-false-rewrite.test.ts + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/entry.js", "export default {};\n"); + writeFile( + "pages/[slug].tsx", + [ + "export function getStaticPaths() { return { paths: [], fallback: false }; }", + "export function getStaticProps() { return { props: {}, revalidate: 60 }; }", + "export default function Page() {}", + ].join("\n"), + ); + vi.mocked(fetch).mockResolvedValue( + Response.json({ fallback: false, paths: ["/first", "/second"] }), + ); + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const rewrite = { + source: testCase.source, + destination: testCase.destination, + }; + const nextConfig = await resolveNextConfig( + { + rewrites: () => ({ + beforeFiles: [], + afterFiles: testCase.phase === "afterFiles" ? [rewrite] : [], + fallback: testCase.phase === "fallback" ? [rewrite] : [], + }), + }, + tmpDir, + ); + + const manifest = await emitPrerenderPathManifest({ + nextConfig, + requestRouting: "uncached-stage", + responseVary: "verbatim", + root: tmpDir, + }); + + expect(manifest?.paths).toEqual(["/first", "/second"]); + expect(manifest?.pagesPaths).toEqual(["/first", "/second"]); + expect(manifest?.excludedWarmPaths).toBeUndefined(); + expect( + manifest?.routePatterns?.["/first"]?.cacheabilityProbe?.requestStageMayTerminate === true, + ).toBe(testCase.expectedTerminal); + }); + + it("allows the uncached middleware stage to resolve warm routes", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile("middleware.ts", "export default function middleware() {}\n"); + writeFile( + "app/middleware-source/page.tsx", + "export const revalidate = 60; export default function Page() {}\n", + ); + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const manifest = await emitPrerenderPathManifest({ + nextConfig: await resolveNextConfig({}, tmpDir), + requestRouting: "uncached-stage", + responseVary: "verbatim", + root: tmpDir, + }); + + expect(manifest?.routePatterns?.["/middleware-source"]?.cacheabilityProbe).toMatchObject({ + canPrunePattern: true, + requestStageMayTerminate: true, + routeMayResolve: true, + }); + }); + + it("retains redirect sources only when routing runs in an uncached stage", async () => { // Next.js applies config redirects before rendering the filesystem route: // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts writeFile("package.json", JSON.stringify({ type: "module" })); @@ -673,20 +1084,41 @@ describe("prerender path manifest", () => { ]); const nextConfig = await resolveNextConfig( { - redirects: () => [{ source: "/redirect-me", destination: "/safe", permanent: false }], + redirects: () => [ + { + source: "/redirect-me", + destination: "/safe", + has: [{ type: "header", key: "x-redirect", value: "1" }], + permanent: false, + }, + ], }, tmpDir, ); - const manifest = await emitPrerenderPathManifest({ + const singleStageManifest = await emitPrerenderPathManifest({ nextConfig, responseVary: "verbatim", root: tmpDir, }); - expect(manifest?.paths).toEqual(["/safe"]); - expect(manifest?.excludedWarmPaths).toEqual(["/redirect-me"]); - expect(manifest?.rscPaths).toEqual(["/safe"]); + expect(singleStageManifest?.paths).toEqual(["/safe"]); + expect(singleStageManifest?.excludedWarmPaths).toEqual(["/redirect-me"]); + expect(singleStageManifest?.rscPaths).toEqual(["/safe"]); + + const stagedManifest = await emitPrerenderPathManifest({ + nextConfig, + requestRouting: "uncached-stage", + responseVary: "verbatim", + root: tmpDir, + }); + + expect(stagedManifest?.paths).toEqual(["/redirect-me", "/safe"]); + expect(stagedManifest?.excludedWarmPaths).toBeUndefined(); + expect(stagedManifest?.rscPaths).toEqual(["/redirect-me", "/safe"]); + expect(stagedManifest?.routePatterns?.["/redirect-me"]?.cacheabilityProbe).toMatchObject({ + requestStageMayTerminate: true, + }); }); it("discovers a static child route from parent-layout generateStaticParams", async () => { @@ -815,7 +1247,11 @@ describe("prerender path manifest", () => { const nextConfig = await resolveNextConfig( { trailingSlash: true, - rewrites: () => [{ source: "/foo/", destination: "/other" }], + rewrites: () => ({ + beforeFiles: [{ source: "/foo/", destination: "/other" }], + afterFiles: [], + fallback: [], + }), }, tmpDir, ); @@ -848,7 +1284,11 @@ describe("prerender path manifest", () => { locales: ["en", "fr"], domains: [{ domain: "example.fr", defaultLocale: "fr" }], }, - rewrites: () => [{ source: "/fr/foo", destination: "/other", locale: false }], + rewrites: () => ({ + beforeFiles: [{ source: "/fr/foo", destination: "/other", locale: false }], + afterFiles: [], + fallback: [], + }), }, tmpDir, ); @@ -1399,6 +1839,41 @@ describe("prerender path manifest", () => { ); }); + it("uses the generated application entry recorded in the server build manifest", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/index.js", 'import "cloudflare:workers";\n'); + writeFile("dist/server/application-entry.js", "export default {};\n"); + writeFile( + "dist/server/.vite/manifest.json", + JSON.stringify({ + "virtual:vinext-rsc-entry": { + file: "application-entry.js", + isDynamicEntry: true, + }, + }), + ); + writeFile( + "app/cached/[slug]/page.tsx", + [ + "export const revalidate = 60;", + "export function generateStaticParams() { return [{ slug: 'intro' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + + await emitPrerenderPathManifest({ root: tmpDir }); + + expect(startProdServerMock).toHaveBeenCalledWith( + expect.objectContaining({ + rscEntryPath: toSlash(path.join(tmpDir, "dist/server/application-entry.js")), + }), + ); + }); + it("derives a custom RSC bundle path from route metadata", async () => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); @@ -1746,7 +2221,11 @@ describe("prerender path manifest", () => { const nextConfig = await resolveNextConfig( { i18n: { defaultLocale: "en", locales: ["en", "fr"] }, - rewrites: () => [{ source: "/fr/about", destination: "/other", locale: false }], + rewrites: () => ({ + beforeFiles: [{ source: "/fr/about", destination: "/other", locale: false }], + afterFiles: [], + fallback: [], + }), }, tmpDir, ); diff --git a/tests/prerender-route-params.test.ts b/tests/prerender-route-params.test.ts index 263834e92..631220f70 100644 --- a/tests/prerender-route-params.test.ts +++ b/tests/prerender-route-params.test.ts @@ -1,10 +1,47 @@ import { describe, expect, it } from "vite-plus/test"; import { encodePrerenderRouteParams, + isTrustedPrerenderState, matchPrerenderRouteParamsPayload, + readTrustedPrerenderStateFromHeaders, type PrerenderRouteParamsPayload, } from "../packages/vinext/src/server/prerender-route-params.js"; +describe("trusted prerender stage state", () => { + it("authenticates route params and speculative mode once at the request boundary", () => { + const previousPrerender = process.env.VINEXT_PRERENDER; + process.env.VINEXT_PRERENDER = "1"; + try { + const headers = new Headers({ + "x-vinext-prerender-route-params": encodeURIComponent( + JSON.stringify({ routePattern: "/post/:slug", params: { slug: "hello" } }), + ), + "x-vinext-prerender-secret": "expected-secret", + "x-vinext-prerender-speculative": "1", + }); + + expect(readTrustedPrerenderStateFromHeaders(headers, "expected-secret")).toEqual({ + routeParams: { routePattern: "/post/:slug", params: { slug: "hello" } }, + speculative: true, + }); + expect(readTrustedPrerenderStateFromHeaders(headers, "wrong-secret")).toBeNull(); + } finally { + if (previousPrerender === undefined) delete process.env.VINEXT_PRERENDER; + else process.env.VINEXT_PRERENDER = previousPrerender; + } + }); + + it("validates the complete serialized stage shape", () => { + const state = { + routeParams: { routePattern: "/post/:slug", params: { slug: "hello" } }, + speculative: true, + }; + expect(isTrustedPrerenderState(state)).toBe(true); + expect(isTrustedPrerenderState({ ...state, secret: "must-not-cross" })).toBe(false); + expect(isTrustedPrerenderState({ ...state, speculative: "1" })).toBe(false); + }); +}); + function matchesExactRoute( payload: PrerenderRouteParamsPayload | null, routePattern: string, diff --git a/tests/request-pipeline.test.ts b/tests/request-pipeline.test.ts index 7d6390592..500705758 100644 --- a/tests/request-pipeline.test.ts +++ b/tests/request-pipeline.test.ts @@ -24,14 +24,20 @@ import { } from "../packages/vinext/src/server/config-headers.js"; import { VINEXT_CACHEABILITY_PROBE_HEADER, + VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER, VINEXT_EXPECTED_WORKER_VERSION_HEADER, VINEXT_PRERENDER_CACHE_LIFE_HEADER, VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, VINEXT_PRERENDER_SPECULATIVE_HEADER, + VINEXT_REVALIDATED_CACHE_TAG_HEADER, VINEXT_REVALIDATE_HOST_HEADER, } from "../packages/vinext/src/server/headers.js"; import { buildRequestHeadersFromMiddlewareResponse } from "../packages/vinext/src/utils/middleware-request-headers.js"; -import { readStaticFileSignal } from "../packages/vinext/src/server/static-file-signal.js"; +import { + readStaticFileSignal, + restoreStaticFileSignalFromTransport, + serializeStaticFileSignalForTransport, +} from "../packages/vinext/src/server/static-file-signal.js"; // Ported from the URL boundary used by Next.js request handling: WHATWG URL // pathname parsing canonicalizes recognized dot segments before routing. @@ -380,6 +386,44 @@ describe("resolvePublicFileRoute", () => { expect(response.headers.get("x-vinext-static-file")).toBeNull(); expect(response.headers.get("cache-control")).toBe("no-store"); }); + + it("authenticates static file signals across standards-only transports", async () => { + const token = "request-stage-token"; + const serialized = serializeStaticFileSignalForTransport( + createStaticFileSignal("/stage asset.txt", { + headers: new Headers({ + "content-encoding": "gzip", + "content-length": "999", + "content-type": "application/wrong", + "transfer-encoding": "chunked", + "x-from-middleware": "1", + }), + status: 203, + }), + token, + ); + expect(serialized.headers.get("content-encoding")).toBeNull(); + expect(serialized.headers.get("content-length")).toBeNull(); + expect(serialized.headers.get("content-type")).toBeNull(); + expect(serialized.headers.get("transfer-encoding")).toBeNull(); + const transported = new Response(serialized.body, serialized); + const restored = restoreStaticFileSignalFromTransport(transported, token); + + expect(restored.status).toBe(203); + expect(restored.headers.get("x-from-middleware")).toBe("1"); + expect(restored.headers.get("x-vinext-stage-static-file")).toBeNull(); + expect(readStaticFileSignal(restored)).toBe("%2Fstage%20asset.txt"); + + const forged = restoreStaticFileSignalFromTransport( + new Response("route handler", { + headers: { "x-vinext-stage-static-file": `${token}:subverted` }, + }), + "different-token", + ); + expect(forged.headers.get("x-vinext-stage-static-file")).toBeNull(); + expect(readStaticFileSignal(forged)).toBeNull(); + await expect(forged.text()).resolves.toBe("route handler"); + }); }); // ── normalizeTrailingSlash ────────────────────────────────────────────── @@ -873,10 +917,12 @@ describe("filterInternalHeaders", () => { const headers = new Headers({ "cloudflare-workers-version-overrides": 'downstream="version-id"', [VINEXT_CACHEABILITY_PROBE_HEADER]: "forged", + [VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER]: "forged", [VINEXT_EXPECTED_WORKER_VERSION_HEADER]: "expected-version", [VINEXT_PRERENDER_CACHE_LIFE_HEADER]: "forged", [VINEXT_PRERENDER_ROUTE_PARAMS_HEADER]: "forged", [VINEXT_PRERENDER_SPECULATIVE_HEADER]: "forged", + [VINEXT_REVALIDATED_CACHE_TAG_HEADER]: "forged", [VINEXT_REVALIDATE_HOST_HEADER]: "example.fr", "user-agent": "test", }); @@ -888,21 +934,25 @@ describe("filterInternalHeaders", () => { expect(INTERNAL_HEADERS).not.toContain(VINEXT_PRERENDER_CACHE_LIFE_HEADER); expect(VINEXT_INTERNAL_HEADERS).toEqual([ VINEXT_CACHEABILITY_PROBE_HEADER.toLowerCase(), + VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER.toLowerCase(), VINEXT_EXPECTED_WORKER_VERSION_HEADER.toLowerCase(), VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, VINEXT_PRERENDER_SPECULATIVE_HEADER, VINEXT_PRERENDER_CACHE_LIFE_HEADER, VINEXT_REVALIDATE_HOST_HEADER, + VINEXT_REVALIDATED_CACHE_TAG_HEADER, ]); for (const name of VINEXT_INTERNAL_HEADERS) { expect(name).toBe(name.toLowerCase()); } expect(result.has(VINEXT_PRERENDER_ROUTE_PARAMS_HEADER)).toBe(false); expect(result.has(VINEXT_CACHEABILITY_PROBE_HEADER)).toBe(false); + expect(result.has(VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER)).toBe(false); expect(result.has(VINEXT_EXPECTED_WORKER_VERSION_HEADER)).toBe(false); expect(result.has(VINEXT_PRERENDER_SPECULATIVE_HEADER)).toBe(false); expect(result.has(VINEXT_PRERENDER_CACHE_LIFE_HEADER)).toBe(false); expect(result.has(VINEXT_REVALIDATE_HOST_HEADER)).toBe(false); + expect(result.has(VINEXT_REVALIDATED_CACHE_TAG_HEADER)).toBe(false); expect(result.get("cloudflare-workers-version-overrides")).toBe('downstream="version-id"'); expect(result.get("user-agent")).toBe("test"); }); @@ -1168,6 +1218,25 @@ describe("cloneRequestWithHeaders", () => { expect(Reflect.get(cloned, "cf")).toEqual({ country: "US" }); }); + it("preserves a lazy cf accessor without reading it while cloning headers", () => { + const original = new Request("http://localhost"); + let reads = 0; + Object.defineProperty(original, "cf", { + configurable: true, + enumerable: true, + get() { + reads += 1; + return { country: "US" }; + }, + }); + + const cloned = cloneRequestWithHeaders(original, new Headers()); + + expect(reads).toBe(0); + expect(Reflect.get(cloned, "cf")).toEqual({ country: "US" }); + expect(reads).toBe(1); + }); + it("replaces headers while preserving all other metadata", () => { const controller = new AbortController(); const original = new Request("http://localhost/path?x=1", { @@ -1233,6 +1302,25 @@ describe("cloneRequestWithUrl", () => { expect(Reflect.get(cloned, "cf")).toEqual({ country: "US" }); }); + it("preserves a lazy cf accessor without reading it while cloning URLs", () => { + const original = new Request("http://localhost/path?_rsc=abc"); + let reads = 0; + Object.defineProperty(original, "cf", { + configurable: true, + enumerable: true, + get() { + reads += 1; + return { country: "US" }; + }, + }); + + const cloned = cloneRequestWithUrl(original, "http://localhost/path"); + + expect(reads).toBe(0); + expect(Reflect.get(cloned, "cf")).toEqual({ country: "US" }); + expect(reads).toBe(1); + }); + it("preserves body readability for streaming requests", async () => { const bodyText = "hello world"; const original = new Request("http://localhost/path?_rsc=abc", { diff --git a/tests/response-stage-cacheability.test.ts b/tests/response-stage-cacheability.test.ts new file mode 100644 index 000000000..b93a600d9 --- /dev/null +++ b/tests/response-stage-cacheability.test.ts @@ -0,0 +1,395 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { + finalizeRequestStageCacheabilityProbe, + readWorkerCacheabilityProbeRoute, + readWorkerCacheabilityProbeMode, + serializeWorkerCacheabilityProbeRoute, + type WorkerCacheabilityProbeMode, +} from "../packages/vinext/src/server/cacheability-request.js"; +import { VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER } from "../packages/vinext/src/server/headers.js"; +import { cacheabilityManifestRouteKey } from "../packages/vinext/src/server/cacheability-manifest.js"; +import { withResponseStageCacheability } from "../packages/vinext/src/server/response-stage-cacheability.js"; +import { + CACHEABILITY_REQUEST_STATE, + type RouteCacheabilityState, +} from "../packages/vinext/src/shims/cacheability-classification.js"; +import { + DefaultCdnCacheAdapter, + setCdnCacheAdapter, + type CdnCacheAdapter, +} from "../packages/vinext/src/shims/cdn-cache.js"; + +afterEach(() => setCdnCacheAdapter(new DefaultCdnCacheAdapter())); + +function admissionAdapter(): CdnCacheAdapter { + return { + buildResponseHeaders: ({ cacheControl }) => ({ "Cache-Control": cacheControl }), + ownsBackgroundRevalidation: false, + requiresCompletedResponseAdmission: true, + responsePolicyHeaderNames: ["CDN-Cache-Control"], + responseVary: "verbatim", + async get() { + return null; + }, + async revalidateTag() {}, + async set() {}, + }; +} + +function contextState(context: ExecutionContext): RouteCacheabilityState | undefined { + return Reflect.get(context, CACHEABILITY_REQUEST_STATE) as RouteCacheabilityState | undefined; +} + +type ExecutionContext = { waitUntil(promise: Promise): void }; + +function baseContext(): ExecutionContext { + return { waitUntil() {} }; +} + +function registerAdapter(): void { + setCdnCacheAdapter(admissionAdapter()); +} + +describe("response-stage cacheability", () => { + it("authenticates probe mode before request headers are filtered", () => { + const request = new Request("https://example.com/page", { + headers: { + "X-Vinext-Cacheability-Probe": "identity", + "X-Vinext-Prerender-Secret": "secret", + }, + }); + + expect(readWorkerCacheabilityProbeMode(request, "secret")).toBe("identity"); + expect(readWorkerCacheabilityProbeMode(request, "different-secret")).toBeNull(); + }); + + it("returns a terminal request-stage result without waiting for body cancellation", async () => { + let cancelCalled = false; + const body = new ReadableStream({ + cancel() { + cancelCalled = true; + return new Promise(() => {}); + }, + }); + const route = { kind: "app-page" as const, pattern: "/你好" }; + const serializedRoute = serializeWorkerCacheabilityProbeRoute(route); + expect(serializedRoute).toContain("%E4%BD%A0%E5%A5%BD"); + const request = new Request("https://example.com/%E4%BD%A0%E5%A5%BD", { + headers: { + [VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER]: serializedRoute, + }, + }); + + const response = finalizeRequestStageCacheabilityProbe(new Response(body, { status: 307 }), { + mode: "probe", + responseStageDispatched: false, + route: readWorkerCacheabilityProbeRoute(request), + }); + + expect(cancelCalled).toBe(true); + await expect(response.json()).resolves.toMatchObject({ + kind: "app-page", + pattern: "/你好", + scope: "identity", + state: "dynamic", + status: 307, + terminal: true, + }); + }); + + it("skips ordinary admission for bypassed renders", async () => { + const context = baseContext(); + const response = new Response("private"); + const rendered = await withResponseStageCacheability( + { + buildId: "build-a", + cache: "bypass", + context, + rawManifest: null, + registerCacheAdapters: registerAdapter, + request: new Request("https://example.com/page", { + headers: { Accept: "text/html" }, + }), + }, + async (renderContext) => { + expect(renderContext).toBe(context); + expect(contextState(renderContext)).toBeUndefined(); + return response; + }, + ); + + expect(rendered).toBe(response); + await expect(rendered.text()).resolves.toBe("private"); + }); + + it("runs authenticated probes even when the response transport bypasses caching", async () => { + let closeBody!: () => void; + let markRenderStarted!: () => void; + const renderStarted = new Promise((resolve) => { + markRenderStarted = resolve; + }); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("rendered")); + closeBody = () => controller.close(); + }, + }); + let settled = false; + const responsePromise = withResponseStageCacheability( + { + buildId: "build-a", + cache: "bypass", + context: baseContext(), + probeMode: "probe" satisfies WorkerCacheabilityProbeMode, + rawManifest: null, + registerCacheAdapters: registerAdapter, + request: new Request("https://example.com/page"), + resolvedRoutePathname: "/resolved/page", + }, + async (context) => { + markRenderStarted(); + const state = contextState(context)!; + state.route = { kind: "app-page", pattern: "/page" }; + state.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + return new Response(body); + }, + ).finally(() => { + settled = true; + }); + + await renderStarted; + await Promise.resolve(); + expect(settled).toBe(false); + closeBody(); + const response = await responsePromise; + await expect(response.json()).resolves.toMatchObject({ + kind: "app-page", + routePathname: "/resolved/page", + state: "static-candidate", + }); + }); + + it("uses the rewritten route pathname without changing public request identity", async () => { + const route = { + kind: "app-page" as const, + pattern: "/target", + paths: { "/target": "static-candidate" as const }, + state: "runtime-check" as const, + }; + const routeKey = cacheabilityManifestRouteKey(route.kind, route.pattern); + const rawManifest = JSON.stringify({ + buildId: "build-a", + routes: { [routeKey]: route }, + version: 1, + }); + + const response = await withResponseStageCacheability( + { + buildId: "build-a", + cache: "shared", + context: baseContext(), + rawManifest, + registerCacheAdapters: registerAdapter, + request: new Request("https://example.com/public?ref=1", { + headers: { Accept: "text/html" }, + }), + resolvedRoutePathname: "/target", + }, + async (context) => { + const state = contextState(context)!; + expect(state.admission).toMatchObject({ + requestKey: "/public?ref=1", + routePathname: "/target", + }); + state.route = { kind: "app-page", pattern: "/target" }; + state.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + return new Response("static"); + }, + ); + + expect(response.headers.get("Cache-Control")).toBe("s-maxage=60"); + await expect(response.text()).resolves.toBe("static"); + }); + + it("applies safe config policy before final admission without overriding late vetoes", async () => { + const response = await withResponseStageCacheability( + { + buildId: "build-a", + cache: "shared", + context: baseContext(), + policyHeaders: [["Cache-Control", "public, s-maxage=60"]], + rawManifest: null, + registerCacheAdapters: registerAdapter, + request: new Request("https://example.com/dynamic", { + headers: { Accept: "text/html" }, + }), + resolvedRoutePathname: "/dynamic", + }, + async (context) => { + const state = contextState(context)!; + state.route = { kind: "app-page", pattern: "/dynamic" }; + state.outcome = { cacheable: false }; + return new Response("draft", { + headers: { "Set-Cookie": "__prerender_bypass=secret; Path=/" }, + }); + }, + ); + + expect(response.headers.get("Cache-Control")).toBe("no-store, must-revalidate"); + expect(response.headers.get("Set-Cookie")).toContain("__prerender_bypass"); + }); + + it("allows safe config policy to publish an otherwise dynamic response", async () => { + const response = await withResponseStageCacheability( + { + buildId: "build-a", + cache: "shared", + context: baseContext(), + policyHeaders: [ + ["CDN-Cache-Control", "public, s-maxage=90"], + ["Vary", "x-visitor"], + ], + rawManifest: null, + registerCacheAdapters: registerAdapter, + request: new Request("https://example.com/dynamic", { + headers: { Accept: "text/html" }, + }), + resolvedRoutePathname: "/dynamic", + }, + async (context) => { + const state = contextState(context)!; + state.route = { kind: "app-page", pattern: "/dynamic" }; + state.outcome = { cacheable: false }; + return new Response("dynamic", { headers: { Vary: "RSC" } }); + }, + ); + + expect(response.headers.get("CDN-Cache-Control")).toBe("public, s-maxage=90"); + expect(response.headers.get("Vary")).toBe("RSC, x-visitor"); + await expect(response.text()).resolves.toBe("dynamic"); + }); + + it("admits policy declared by a provider-neutral CDN adapter", async () => { + const adapter = admissionAdapter(); + setCdnCacheAdapter({ + ...adapter, + buildResponseHeaders: ({ cacheControl }) => ({ + "Cache-Control": "max-age=0, must-revalidate", + "X-Example-Edge-Policy": cacheControl, + }), + responsePolicyHeaderNames: ["X-Example-Edge-Policy"], + }); + + const response = await withResponseStageCacheability( + { + buildId: "build-a", + cache: "shared", + context: baseContext(), + policyHeaders: [["X-Example-Edge-Policy", "public, s-maxage=90"]], + rawManifest: null, + registerCacheAdapters() {}, + request: new Request("https://example.com/dynamic", { + headers: { Accept: "text/html" }, + }), + resolvedRoutePathname: "/dynamic", + }, + async (context) => { + const state = contextState(context)!; + expect(state.responsePolicyHeaderNames).toEqual(["cache-control", "x-example-edge-policy"]); + state.route = { kind: "app-page", pattern: "/dynamic" }; + state.outcome = { cacheable: false }; + return new Response("dynamic"); + }, + ); + + expect(response.headers.get("Cache-Control")).toBe("max-age=0, must-revalidate"); + expect(response.headers.get("X-Example-Edge-Policy")).toBe("public, s-maxage=90"); + await expect(response.text()).resolves.toBe("dynamic"); + }); + + it("applies adapter policy headers to a route response whose body already completed", async () => { + const adapter = admissionAdapter(); + setCdnCacheAdapter({ + ...adapter, + buildResponseHeaders: ({ cacheControl }) => ({ + "Cache-Control": "max-age=0, must-revalidate", + "CDN-Cache-Control": cacheControl, + }), + }); + + const response = await withResponseStageCacheability( + { + buildId: "build-a", + cache: "shared", + context: baseContext(), + rawManifest: null, + registerCacheAdapters() {}, + request: new Request("https://example.com/api/explicit", { + headers: { Accept: "application/json" }, + }), + resolvedRoutePathname: "/api/explicit", + }, + async (context) => { + const state = contextState(context)!; + state.route = { kind: "app-route", pattern: "/api/explicit" }; + state.completedResponseBody = true; + state.explicitResponseCachePolicy = true; + return new Response("complete", { + headers: { "Cache-Control": "public, s-maxage=60" }, + }); + }, + ); + + expect(response.headers.get("Cache-Control")).toBe("max-age=0, must-revalidate"); + expect(response.headers.get("CDN-Cache-Control")).toBe("public, s-maxage=60"); + await expect(response.text()).resolves.toBe("complete"); + }); + + it("admits an explicitly public Pages API response after clean body completion", async () => { + const response = await withResponseStageCacheability( + { + buildId: "build-a", + cache: "shared", + context: baseContext(), + rawManifest: null, + registerCacheAdapters: registerAdapter, + request: new Request("https://example.com/api/public", { + headers: { Accept: "application/json" }, + }), + resolvedRoutePathname: "/api/public", + }, + async (context) => { + contextState(context)!.route = { kind: "pages-api", pattern: "/api/public" }; + return new Response('{"public":true}', { + headers: { "Cache-Control": "public, s-maxage=60" }, + }); + }, + ); + + expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=60"); + await expect(response.json()).resolves.toEqual({ public: true }); + }); + + it("keeps a Pages API private without an explicit public policy", async () => { + const response = await withResponseStageCacheability( + { + buildId: "build-a", + cache: "shared", + context: baseContext(), + rawManifest: null, + registerCacheAdapters: registerAdapter, + request: new Request("https://example.com/api/private", { + headers: { Accept: "application/json" }, + }), + resolvedRoutePathname: "/api/private", + }, + async (context) => { + contextState(context)!.route = { kind: "pages-api", pattern: "/api/private" }; + return new Response('{"private":true}'); + }, + ); + + expect(response.headers.get("Cache-Control")).toBe("no-store, must-revalidate"); + await expect(response.json()).resolves.toEqual({ private: true }); + }); +}); diff --git a/tests/run-prerender-concurrency.test.ts b/tests/run-prerender-concurrency.test.ts index b56dbb3e8..221be7ef1 100644 --- a/tests/run-prerender-concurrency.test.ts +++ b/tests/run-prerender-concurrency.test.ts @@ -99,4 +99,32 @@ describe("runPrerender concurrency", () => { fs.rmSync(root, { recursive: true, force: true }); } }); + + it("starts the prerender server with the generated App entry from the build manifest", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-run-prerender-entry-")); + fs.mkdirSync(path.join(root, "app")); + fs.mkdirSync(path.join(root, "dist", "server", ".vite"), { recursive: true }); + fs.writeFileSync(path.join(root, "dist", "server", "index.js"), 'import "host:runtime";\n'); + fs.writeFileSync(path.join(root, "dist", "server", "application-entry.js"), "export {};\n"); + fs.writeFileSync( + path.join(root, "dist", "server", ".vite", "manifest.json"), + JSON.stringify({ + "virtual:vinext-rsc-entry": { file: "application-entry.js", isDynamicEntry: true }, + }), + ); + + try { + const { runPrerender } = await import("../packages/vinext/src/build/run-prerender.js"); + + await runPrerender({ root }); + + expect(prerenderAppMock).toHaveBeenCalledWith( + expect.objectContaining({ + rscBundlePath: path.join(root, "dist", "server", "application-entry.js"), + }), + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 029145056..c8a798455 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -11269,6 +11269,26 @@ describe("cookie name validation", () => { // NextRequest API tests describe("NextRequest API", () => { + it("preserves a lazy request.cf accessor without reading it", async () => { + const { NextRequest } = await import("../packages/vinext/src/shims/server.js"); + const source = new Request("https://example.com/"); + let reads = 0; + Object.defineProperty(source, "cf", { + configurable: true, + enumerable: true, + get() { + reads += 1; + return { country: "AU" }; + }, + }); + + const request = new NextRequest(source); + + expect(reads).toBe(0); + expect(Reflect.get(request, "cf")).toEqual({ country: "AU" }); + expect(reads).toBe(1); + }); + it("throws canonical 'Please use only absolute URLs' error for relative URL input", async () => { const { NextRequest } = await import("../packages/vinext/src/shims/server.js"); // Matches Next.js's documented behaviour — middleware tests assert on this diff --git a/tests/worker-entry-source.ts b/tests/worker-entry-source.ts index eaa315f13..aa1043d9c 100644 --- a/tests/worker-entry-source.ts +++ b/tests/worker-entry-source.ts @@ -9,7 +9,26 @@ export function readAppRouterEntrySource(): string { ); } +export function readAppRequestStageEntrySource(): string { + const sourceUrl = new URL( + "../packages/vinext/src/server/app-request-stage-independent-entry.ts", + import.meta.url, + ); + if (fs.existsSync(sourceUrl)) return fs.readFileSync(sourceUrl, "utf-8"); + return fs.readFileSync( + new URL( + "../packages/vinext/src/server/app-request-stage-independent-entry.js", + import.meta.url, + ), + "utf-8", + ); +} + export function readPagesRouterEntrySource(): string { + return readPagesRequestStageEntrySource(); +} + +export function readPagesSingleEntrySource(): string { const sourceUrl = new URL("../packages/vinext/src/server/pages-router-entry.ts", import.meta.url); if (fs.existsSync(sourceUrl)) return fs.readFileSync(sourceUrl, "utf-8"); return fs.readFileSync( @@ -17,3 +36,27 @@ export function readPagesRouterEntrySource(): string { "utf-8", ); } + +export function readPagesRequestStageEntrySource(): string { + const sourceUrl = new URL( + "../packages/vinext/src/server/pages-request-stage-entry.ts", + import.meta.url, + ); + if (fs.existsSync(sourceUrl)) return fs.readFileSync(sourceUrl, "utf-8"); + return fs.readFileSync( + new URL("../packages/vinext/src/server/pages-request-stage-entry.js", import.meta.url), + "utf-8", + ); +} + +export function readPagesResponseStageEntrySource(): string { + const sourceUrl = new URL( + "../packages/vinext/src/server/pages-response-stage-entry.ts", + import.meta.url, + ); + if (fs.existsSync(sourceUrl)) return fs.readFileSync(sourceUrl, "utf-8"); + return fs.readFileSync( + new URL("../packages/vinext/src/server/pages-response-stage-entry.js", import.meta.url), + "utf-8", + ); +}