diff --git a/packages/cloudflare/src/cacheability-artifact.ts b/packages/cloudflare/src/cacheability-artifact.ts index 50ab4820df..4865d23f44 100644 --- a/packages/cloudflare/src/cacheability-artifact.ts +++ b/packages/cloudflare/src/cacheability-artifact.ts @@ -3,6 +3,8 @@ import path from "node:path"; import { Buffer } from "node:buffer"; import { CACHEABILITY_MANIFEST_MODULE, + CACHEABILITY_REQUEST_PROJECTION_MODULE, + projectCacheabilityManifestForRequestStage, type CacheabilityManifest, } from "vinext/internal/server/cacheability-manifest"; import { @@ -223,7 +225,7 @@ function resolveBuildOutputArtifact(root: string): GeneratedWorkerArtifact { return { configPath, main, serverDirectory: path.join(path.dirname(configPath), "bundle") }; } -function assertManifestModuleReachable(artifact: GeneratedWorkerArtifact): void { +function assertModuleReachable(artifact: GeneratedWorkerArtifact, moduleName: string): void { const { main, serverDirectory } = artifact; const mainPath = path.resolve(serverDirectory, main); if (!fs.existsSync(mainPath) || !fs.lstatSync(mainPath).isFile()) { @@ -253,16 +255,11 @@ function assertManifestModuleReachable(artifact: GeneratedWorkerArtifact): void if (!entry || typeof entry.file !== "string") continue; const modulePath = path.resolve(serverDirectory, entry.file); if (fs.existsSync(modulePath) && fs.lstatSync(modulePath).isFile()) { - const relativeManifest = path - .relative( - path.dirname(modulePath), - path.join(serverDirectory, CACHEABILITY_MANIFEST_MODULE), - ) + const relativeModule = path + .relative(path.dirname(modulePath), path.join(serverDirectory, moduleName)) .split(path.sep) .join("/"); - const specifier = relativeManifest.startsWith(".") - ? relativeManifest - : `./${relativeManifest}`; + const specifier = relativeModule.startsWith(".") ? relativeModule : `./${relativeModule}`; reachable = hasStaticModuleSpecifier(fs.readFileSync(modulePath, "utf8"), specifier); } for (const references of [entry.imports, entry.dynamicImports]) { @@ -273,15 +270,31 @@ function assertManifestModuleReachable(artifact: GeneratedWorkerArtifact): void } if (!reachable) { throw new Error( - `Two-stage CDN warming requires the generated Worker graph to statically import ${CACHEABILITY_MANIFEST_MODULE}.`, + `Two-stage CDN warming requires the generated Worker graph to statically import ${moduleName}.`, ); } } +function writeStringModule(modulePath: string, value: string): void { + const source = `export default ${JSON.stringify(value)};\n`; + const pendingPath = `${modulePath}.${process.pid}.tmp`; + try { + fs.writeFileSync(pendingPath, source, "utf8"); + fs.renameSync(pendingPath, modulePath); + } finally { + if (fs.existsSync(pendingPath)) fs.unlinkSync(pendingPath); + } +} + /** * Write the version-specific manifest into the built Worker artifact. * The application build already imports this stable module asset, so the * completed dist directory remains the exact input to the final upload. + * + * App Router builds also emit the request stage's projection module. It + * carries the App page routes that can admit a query-free entry, so the + * request stage can strip the query from those dispatches without loading the + * full manifest. */ export function writeCacheabilityManifestArtifact( root: string, @@ -293,7 +306,7 @@ export function writeCacheabilityManifestArtifact( deploymentTool === "cf" ? resolveBuildOutputArtifact(root) : resolveGeneratedWranglerArtifact(root, configuredPath); - assertManifestModuleReachable(artifact); + assertModuleReachable(artifact, CACHEABILITY_MANIFEST_MODULE); const { configPath, serverDirectory } = artifact; const manifestPath = path.join(serverDirectory, CACHEABILITY_MANIFEST_MODULE); if (!fs.existsSync(manifestPath) || !fs.lstatSync(manifestPath).isFile()) { @@ -307,13 +320,26 @@ export function writeCacheabilityManifestArtifact( throw cacheabilityManifestByteLimitError(manifestBytes); } - const manifestSource = `export default ${JSON.stringify(serializedManifest)};\n`; - const pendingManifestPath = `${manifestPath}.${process.pid}.tmp`; - try { - fs.writeFileSync(pendingManifestPath, manifestSource, "utf8"); - fs.renameSync(pendingManifestPath, manifestPath); - } finally { - if (fs.existsSync(pendingManifestPath)) fs.unlinkSync(pendingManifestPath); + const projectionPath = path.join(serverDirectory, CACHEABILITY_REQUEST_PROJECTION_MODULE); + const hasProjectionModule = + fs.existsSync(projectionPath) && fs.lstatSync(projectionPath).isFile(); + // Without the projection, the request stage would never drop the query for + // the App page paths this manifest certifies. + if (Object.values(manifest.routes).some((route) => route.kind === "app-page")) { + if (!hasProjectionModule) { + throw new Error( + `Two-stage CDN warming requires ${CACHEABILITY_REQUEST_PROJECTION_MODULE} in the generated Worker artifact. Rebuild the app before deploying.`, + ); + } + assertModuleReachable(artifact, CACHEABILITY_REQUEST_PROJECTION_MODULE); + } + + writeStringModule(manifestPath, serializedManifest); + if (hasProjectionModule) { + writeStringModule( + projectionPath, + JSON.stringify(projectCacheabilityManifestForRequestStage(manifest)), + ); } return path.relative(root, configPath); } diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index d8722c746e..a41f1e2c32 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -10,6 +10,7 @@ import { normalizeCacheabilityRoutePathname, type CacheabilityManifest, type CacheabilityManifestRoute, + type CacheabilityRepresentation, } from "vinext/internal/server/cacheability-manifest"; import type { PrerenderRoutePattern } from "vinext/internal/build/prerender-paths"; import { @@ -32,6 +33,8 @@ const DEFAULT_CACHEABILITY_PROBE_REQUEST_TIMEOUT_MS = 30_000; const MAX_CACHEABILITY_PROBE_ENVELOPE_BYTES = 64 * 1024; type ProbePayload = { + dynamicUsage?: true; + explicitConfigCachePolicy?: true; kind?: string; pattern?: string; reason?: string; @@ -98,27 +101,31 @@ function sharedPathPrefix(pathnames: readonly string[]): string | null { function compactManifestRoutePaths(route: CacheabilityManifestRoute): CacheabilityManifestRoute { const pathnames = [ ...(route.runtimePaths ?? []), + ...Object.values(route.runtimeRepresentationPaths ?? {}).flatMap((paths) => paths ?? []), ...Object.values(route.staticPaths ?? {}).flatMap((paths) => paths ?? []), ]; const pathPrefix = sharedPathPrefix(pathnames); if (!pathPrefix) return route; + const compactPathLists = ( + lists: Partial>, + ): Partial> => + Object.fromEntries( + Object.entries(lists).map(([representation, paths]) => [ + representation, + paths!.map((pathname) => pathname.slice(pathPrefix.length)), + ]), + ); const compacted: CacheabilityManifestRoute = { ...route, pathPrefix, ...(route.runtimePaths ? { runtimePaths: route.runtimePaths.map((pathname) => pathname.slice(pathPrefix.length)) } : {}), - ...(route.staticPaths - ? { - staticPaths: Object.fromEntries( - Object.entries(route.staticPaths).map(([representation, paths]) => [ - representation, - paths!.map((pathname) => pathname.slice(pathPrefix.length)), - ]), - ), - } + ...(route.runtimeRepresentationPaths + ? { runtimeRepresentationPaths: compactPathLists(route.runtimeRepresentationPaths) } : {}), + ...(route.staticPaths ? { staticPaths: compactPathLists(route.staticPaths) } : {}), }; return Buffer.byteLength(JSON.stringify(compacted)) < Buffer.byteLength(JSON.stringify(route)) ? compacted @@ -383,6 +390,10 @@ export async function probeStagedWorkerCacheability(options: { let dynamicPathCount = 0; type ConcretePathResult = { + /** The render used a dynamic API. OR-merged across groups sharing a result key. */ + dynamicUsage: boolean; + /** A next.config header policy applied. OR-merged across groups sharing a result key. */ + explicitConfigCachePolicy: boolean; rendererStatic: boolean; representation: CdnWarmTarget["kind"]; state: Exclude; @@ -400,6 +411,8 @@ export async function probeStagedWorkerCacheability(options: { }; type ConcretePathGroup = { deferred: boolean; + /** An unlisted path whose render failed; it is left to the route rule. */ + dropped?: boolean; pattern: PatternClassification; primary: CdnWarmTarget; result?: ConcretePathResult; @@ -472,12 +485,41 @@ export async function probeStagedWorkerCacheability(options: { group.targets.push(target); targetGroups.set(concreteKey, group); } + // A path is listed when the route that owns it at runtime lists it in its + // own static generation; discovery marks every other path unlisted, even one + // another route generates. A path the request stage moves, to another route + // or another pathname, counts as listed only when its destination route lists + // the resolved pathname. Those build-time facts are keyed by route and + // pathname, and fixed before any probe runs: groups move between patterns as + // route-moving probes complete, so the listing is never read back from them. + // Discovery's fact wins over the pattern's shape: a literal route that isn't + // static or SSG doesn't list its own path. Only a literal route discovery has + // no fact for lists it. + const buildTimeListing = new Map(); + const isListedAt = ( + route: Pick, + routePathname: string, + ): boolean => { + const key = cacheabilityManifestRouteKey(route.kind, route.pattern); + const listed = buildTimeListing.get(`${key}\0${routePathname}`); + if (listed !== undefined) return listed; + return ( + !/(^|\/):/.test(route.pattern) && + normalizeCacheabilityRoutePathname(route.pattern) === routePathname + ); + }; + const isListedGroup = (group: ConcretePathGroup): boolean => + isListedAt(group.pattern.route, group.routePathname); const groups: ConcretePathGroup[] = Array.from(targetGroups.values(), (targetGroup) => { targetGroup.targets.sort((first, second) => { const preference = targetPreference(first) - targetPreference(second); return preference || first.sourcePathname.localeCompare(second.sourcePathname); }); const group = { ...targetGroup, deferred: false, primary: targetGroup.targets[0] }; + buildTimeListing.set( + `${group.pattern.key}\0${group.routePathname}`, + group.primary.route!.cacheabilityProbe?.unlisted !== true, + ); targetGroup.pattern.groups.push(group); return group; }); @@ -654,6 +696,9 @@ export async function probeStagedWorkerCacheability(options: { result.scope !== "identity" || !group.pattern.requestStageMayTerminate)) || (result.rendererStatic !== undefined && typeof result.rendererStatic !== "boolean") || + (result.dynamicUsage !== undefined && result.dynamicUsage !== true) || + (result.explicitConfigCachePolicy !== undefined && + result.explicitConfigCachePolicy !== true) || (result.retryable !== undefined && result.retryable !== true) || (result.retryable === true && result.state !== "probe-failed") || !Number.isInteger(result.status) || @@ -665,6 +710,52 @@ export async function probeStagedWorkerCacheability(options: { reportProgress(); return "done"; } + if ( + result.state === "probe-failed" && + result.kind === "app-page" && + result.status! >= 500 && + result.reason === `route returned HTTP ${result.status}` && + target.route && + // A route the request stage may not move to, or a move without its + // concrete pathname, fails as any other resolution below. + ((result.kind === target.route.kind && result.pattern === target.route.pattern) || + (target.route.cacheabilityProbe?.routeMayResolve === true && + result.routePathname !== undefined)) && + !isListedAt( + { kind: result.kind, pattern: result.pattern }, + result.routePathname === undefined + ? group.routePathname + : normalizeCacheabilityRoutePathname(result.routePathname), + ) + ) { + // Next.js's build never renders an unlisted App page path, so its + // render error doesn't fail the deploy. The path is neither classified + // nor warmed. Listing is judged under the route the request resolved. + // A failure the request stage moved proves only its own destination, so + // the paired representations stay at the original route, as they do + // after a moved success, and only the failed primary is dropped. + if ( + target.route.cacheabilityProbe?.routeMayResolve === true && + (result.kind !== target.route.kind || + result.pattern !== target.route.pattern || + (result.routePathname !== undefined && + normalizeCacheabilityRoutePathname(result.routePathname) !== group.routePathname)) + ) { + deferPairedRepresentationsAtOriginalRoute(group); + } + group.dropped = true; + if ( + !group.pattern.groups.some( + (candidate) => candidate !== group && candidate.resultKey === group.resultKey, + ) + ) { + group.pattern.resultKeys.delete(group.resultKey); + } + skippedPathCount += 1; + completedPathCount += 1; + reportProgress(); + return "done"; + } if (result.state === "probe-failed") { failures.push(`${target.label}: ${result.reason ?? "probe failed"}`); completedPathCount += 1; @@ -715,6 +806,8 @@ export async function probeStagedWorkerCacheability(options: { } const classification: ConcretePathResult = { + dynamicUsage: result.dynamicUsage === true, + explicitConfigCachePolicy: result.explicitConfigCachePolicy === true, rendererStatic: result.rendererStatic === true, representation: target.kind, state: result.state, @@ -722,15 +815,21 @@ export async function probeStagedWorkerCacheability(options: { }; group.result = classification; const previousClassification = group.pattern.results.get(group.resultKey); - if ( + const retainedClassification = !previousClassification || (previousClassification.state === "static-candidate" && classification.state === "dynamic") || (previousClassification.state === classification.state && previousClassification.rendererStatic && !classification.rendererStatic) - ) { - group.pattern.results.set(group.resultKey, classification); - } + ? classification + : previousClassification; + group.pattern.results.set(group.resultKey, { + ...retainedClassification, + dynamicUsage: classification.dynamicUsage || previousClassification?.dynamicUsage === true, + explicitConfigCachePolicy: + classification.explicitConfigCachePolicy || + previousClassification?.explicitConfigCachePolicy === true, + }); const patternIsDefinitelyDynamic = result.state === "dynamic" && result.scope === "pattern" && group.pattern.canPrune; if (patternIsDefinitelyDynamic) { @@ -871,10 +970,12 @@ export async function probeStagedWorkerCacheability(options: { classified += 1; } if (limitFailure) throw limitFailure; - // Next.js classifies every generateStaticParams result independently. Store - // each observed concrete path exactly once, then compact the shared route - // prefix. Paired HTML/RSC or HTML/data representations reuse the path's - // membership but must pass their own completed-render admission check. + // Next.js classifies every generateStaticParams result independently. A + // path appears at most once per representation list, and never both + // runtime-checked and static. A certified-static App page is listed under + // HTML and its full RSC payload, which Next.js serves from one render. + // Paired representations must still pass their own completed-render + // admission check. The shared route prefix is compacted last. for (const pattern of patterns.values()) { if (pattern.pruned) { classified += 1; @@ -896,15 +997,81 @@ export async function probeStagedWorkerCacheability(options: { } continue; } - if (pattern.results.size === 0 && !pattern.groups.some((group) => group.deferred)) continue; + const hasStaticFallback = fallbackRoutes.has(pattern.key); + if (pattern.results.size === 0 && !pattern.groups.some((group) => group.deferred)) { + // Every probed path moved to another route or was dropped, so the route + // keeps its fallback-only entry. + if (hasStaticFallback) { + const route: CacheabilityManifestRoute = { + kind: pattern.route.kind, + pattern: pattern.route.pattern, + state: "static-candidate", + }; + if (!addRouteWithinManifestLimits(pattern.key, route)) break; + classified += 1; + } + continue; + } classified += 1; if (Array.from(pattern.results.values()).some((result) => result.state === "dynamic")) { dynamic += 1; } + // App pages follow Next.js's build. Unknown paths of a dynamic-segment + // route get on-demand ISR only with a static fallback, or when the route + // lists paths and none it rendered used a dynamic API. A path that used a + // dynamic API, and an unlisted path of a route without on-demand ISR, get + // no state in any representation: never admitted and not warmed. A path + // cacheable only through a next.config policy stays runtime-checked. + const isAppPage = pattern.route.kind === "app-page"; + const listedResults = pattern.groups.flatMap((group) => { + const result = + !group.deferred && !group.dropped && isListedGroup(group) + ? pattern.results.get(group.resultKey) + : undefined; + return result ? [result] : []; + }); + const hasOnDemandIsr = + hasStaticFallback || + (/(^|\/):/.test(pattern.route.pattern) && + listedResults.length > 0 && + !listedResults.some((result) => result.dynamicUsage)); + const hasNoState = (group: ConcretePathGroup): boolean => { + const result = pattern.results.get(group.resultKey); + if (!isAppPage || !result) return false; + if ( + result.state === "static-candidate" && + !result.rendererStatic && + result.explicitConfigCachePolicy + ) { + return false; + } + return result.dynamicUsage || (!hasOnDemandIsr && !isListedGroup(group)); + }; + const rendererStaticTargets = new Map(); const runtimePathSet = new Set(); + const loadingShellRuntimePathSet = new Set(); for (const group of pattern.groups) { + if (group.dropped) continue; + if (!group.deferred && hasNoState(group)) { + // A dynamic API can sit below the loading boundary, so the loading + // shell of a path the route would otherwise keep stays warmable. Its + // completed render decides admission. + const loadingShellTargets = group.targets.filter( + (target) => target.kind === "rsc-loading-shell", + ); + if ( + loadingShellTargets.length > 0 && + pattern.results.get(group.resultKey)?.dynamicUsage && + (hasOnDemandIsr || isListedGroup(group)) + ) { + loadingShellRuntimePathSet.add(group.routePathname); + cacheableTargets.push(...loadingShellTargets); + speculativeTargets.push(...loadingShellTargets); + } + continue; + } if (group.deferred) { runtimePathSet.add(group.routePathname); cacheableTargets.push(...group.targets); @@ -938,30 +1105,60 @@ export async function probeStagedWorkerCacheability(options: { speculativeTargets.push(...pairedTargets); } } + // The HTML probe ran SSR, so it saw every read the full RSC render can + // make. The loading shell renders the loading boundary, which a static + // page's render may never reach, so a path listed static elsewhere leaves + // its loading shell runtime-checked. A path probed only through RSC keeps + // its single listing. + const staticRepresentations = ( + representation: CdnWarmTarget["kind"], + ): CdnWarmTarget["kind"][] => + isAppPage && representation === "html" ? ["html", "rsc-full"] : [representation]; const staticPaths: CacheabilityManifestRoute["staticPaths"] = {}; for (const [routePathname, staticTarget] of rendererStaticTargets) { // Conflicting observations for one resolved route identity must retain // runtime admission rather than certifying the static observation. if (runtimePathSet.has(routePathname)) continue; - const paths = staticPaths[staticTarget.kind] ?? []; - paths.push(routePathname); - staticPaths[staticTarget.kind] = paths; + for (const representation of staticRepresentations(staticTarget.kind)) { + const paths = staticPaths[representation] ?? []; + paths.push(routePathname); + staticPaths[representation] = paths; + } } for (const paths of Object.values(staticPaths)) paths?.sort(); + const loadingShellRuntimePaths = Array.from(loadingShellRuntimePathSet) + .filter( + (routePathname) => + !runtimePathSet.has(routePathname) && + !staticPaths["rsc-loading-shell"]?.includes(routePathname), + ) + .sort(); + const runtimeRepresentationPaths: CacheabilityManifestRoute["runtimeRepresentationPaths"] = + loadingShellRuntimePaths.length > 0 + ? { "rsc-loading-shell": loadingShellRuntimePaths } + : undefined; const allObservedPathsStatic = pattern.results.size === pattern.resultKeys.size && Array.from(pattern.results.values()).every((result) => result.state === "static-candidate"); const allObservedPathsStaticallyGenerated = allObservedPathsStatic && Array.from(pattern.results.values()).every((result) => result.rendererStatic); - const hasStaticFallback = fallbackRoutes.has(pattern.key); + const allowsUnknown = isAppPage ? hasOnDemandIsr : allObservedPathsStaticallyGenerated; const soleGroup = pattern.groups.length === 1 ? pattern.groups[0] : null; const literalPatternNamesSolePath = soleGroup !== null && !/(^|\/):/.test(pattern.route.pattern) && normalizeCacheabilityRoutePathname(pattern.route.pattern) === soleGroup.routePathname; let route: CacheabilityManifestRoute; - if (literalPatternNamesSolePath) { + if (literalPatternNamesSolePath && !soleGroup.deferred && hasNoState(soleGroup)) { + if (!runtimeRepresentationPaths) continue; + route = { + kind: pattern.route.kind, + pattern: pattern.route.pattern, + runtimeRepresentation: "rsc-loading-shell", + state: "runtime-check", + }; + } else if (literalPatternNamesSolePath) { const result = pattern.results.get(soleGroup.resultKey); route = result?.state === "static-candidate" @@ -971,32 +1168,55 @@ export async function probeStagedWorkerCacheability(options: { pattern: pattern.route.pattern, state: "static-candidate", } - : result.rendererStatic - ? { - kind: pattern.route.kind, - pattern: pattern.route.pattern, - state: "runtime-check", - staticRepresentation: result.representation, - } - : { + : result.rendererStatic && isAppPage && result.representation === "html" + ? compactManifestRoutePaths({ kind: pattern.route.kind, pattern: pattern.route.pattern, state: "runtime-check", - } + staticPaths: Object.fromEntries( + staticRepresentations("html").map((representation) => [ + representation, + [soleGroup.routePathname], + ]), + ), + }) + : result.rendererStatic + ? { + kind: pattern.route.kind, + pattern: pattern.route.pattern, + state: "runtime-check", + staticRepresentation: result.representation, + } + : { + kind: pattern.route.kind, + pattern: pattern.route.pattern, + state: "runtime-check", + } : { kind: pattern.route.kind, pattern: pattern.route.pattern, state: "runtime-check", }; + } else if ( + isAppPage && + !hasOnDemandIsr && + runtimePathSet.size === 0 && + !runtimeRepresentationPaths && + Object.keys(staticPaths).length === 0 + ) { + // Without path lists or on-demand ISR, a runtime-check entry would admit + // every path of the route. + continue; } else { route = compactManifestRoutePaths({ kind: pattern.route.kind, pattern: pattern.route.pattern, state: "runtime-check", - ...(hasStaticFallback || allObservedPathsStaticallyGenerated + ...(hasStaticFallback || allowsUnknown ? { allowUnknown: true, unknownState: "static-candidate" as const } : {}), ...(runtimePathSet.size > 0 ? { runtimePaths: Array.from(runtimePathSet).sort() } : {}), + ...(runtimeRepresentationPaths ? { runtimeRepresentationPaths } : {}), ...(Object.keys(staticPaths).length > 0 ? { staticPaths } : {}), }); } diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 8a98f83d56..9565100488 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -213,7 +213,9 @@ function readPrerenderPathManifest(manifestPath: string): PrerenderPathManifest (route.cacheabilityProbe.routeMayResolve === undefined || typeof route.cacheabilityProbe.routeMayResolve === "boolean") && (route.cacheabilityProbe.requestStageMayTerminate === undefined || - typeof route.cacheabilityProbe.requestStageMayTerminate === "boolean"))), + typeof route.cacheabilityProbe.requestStageMayTerminate === "boolean") && + (route.cacheabilityProbe.unlisted === undefined || + typeof route.cacheabilityProbe.unlisted === "boolean"))), ))) || (manifest.loadingShellPaths !== undefined && (!Array.isArray(manifest.loadingShellPaths) || diff --git a/packages/vinext/src/build/client-build-config.ts b/packages/vinext/src/build/client-build-config.ts index 2d2f1c501f..f6560d6f31 100644 --- a/packages/vinext/src/build/client-build-config.ts +++ b/packages/vinext/src/build/client-build-config.ts @@ -339,7 +339,8 @@ export function createMultiStageCodeSplittingConfig( /** * Keep router stage chunks beside the server entry so their generated - * `./vinext-client-assets.js` external continues to resolve. Other chunks keep + * `./vinext-client-assets.js` and deploy-filled cacheability externals continue + * to resolve. Other chunks keep * the host's existing output pattern (or vinext's server-assets default). */ export function createMultiStageChunkFileNames( @@ -359,6 +360,7 @@ export function createMultiStageChunkFileNames( "pages-request-stage-entry", "pages-response-stage-entry", "virtual_vinext-rsc-entry", + "virtual_vinext-request-stage", "virtual_vinext-response-stage", ].some((entryName) => name.includes(entryName)) ) { diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 5d1e6298ad..68b3e8d58d 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -11,6 +11,7 @@ import { appRouteHasMainTreeLoadingBoundary, appRouter, matchAppRoute, + type AppRoute, } from "../routing/app-router.js"; import { apiRouter, matchRoute, pagesRouter } from "../routing/pages-router.js"; import { @@ -23,12 +24,15 @@ import { classifyAppRoute, classifyAppRouteHandler, classifyPagesRoute, + extractExportConstNumber, extractExportConstString, extractMiddlewareMatcherConfig, + hasRuntimeExportedName, } from "./report.js"; import { buildUrlFromParams, resolveParentParams, type StaticParamsMap } from "./prerender.js"; import { readPrerenderSecret } from "./server-manifest.js"; import { startProdServer } from "../server/prod-server.js"; +import { loadMdxEsmReader } from "../utils/mdx-scan.js"; import { findDir } from "../utils/project.js"; import { BLOCKED_PAGES, PHASE_PRODUCTION_BUILD } from "vinext/shims/constants"; import { VINEXT_PRERENDER_SECRET_HEADER } from "../server/headers.js"; @@ -37,7 +41,15 @@ import { enterPrerenderPhase } from "./prerender-phase.js"; import type { CdnCacheAdapterCapabilities } from "../cache/cache-adapters-virtual.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 { + collectAppPageStaticGenerationRuntimes, + hasAppPageGenerateStaticParamsAtLastDynamicSegment, + isAppPageStaticEligible, + isEdgeRuntime, + resolveAppPageDynamicConfig, + resolveAppPageSegmentConfig, + resolveAppPageStaticGenerationRuntime, +} 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"; @@ -60,6 +72,14 @@ export type PrerenderRoutePattern = { routeMayResolve?: boolean; /** A request representation may terminate before reaching the response stage. */ requestStageMayTerminate?: boolean; + /** + * Not listed by the route's own static generation (`generateStaticParams`, + * `getStaticPaths` or a route without dynamic segments): picked from + * traffic, or, for an App page route, not listed by this route itself, + * which includes a route that isn't static or SSG and a path another + * route generates. A path both listed and picked counts as listed. + */ + unlisted?: boolean; }; }; export type PrerenderPathManifest = { @@ -724,6 +744,100 @@ function extractPagesStaticPathLocale( return { explicitLocalePrefix: parts[0], locale, url: `${rest || "/"}${query}` }; } +/** + * Whether Next.js classifies an App page route as static or SSG, read from its + * layout, page and parallel-slot sources with the helpers dispatch applies to + * the loaded modules. Only such a route has listed paths. + * + * An MDX source read without the MDX parser has unknown exports, so its route + * is "unreadable". Its paths are still listed: the built runtime reads the real + * exports and never stores a route that isn't static, while an unlisted path's + * render failure would be dropped instead of failing. But no probe renders an + * unreadable route's fallback, so it never certifies one. + */ +function classifyAppPageRouteStaticEligibility( + route: AppRoute, + readMdxEsm: ((source: string) => string) | null, +): "eligible" | "ineligible" | "unreadable" { + let unreadable = false; + const readSegmentConfig = (filePath: string | null | undefined) => { + if (!filePath) return null; + let code = fs.readFileSync(filePath, "utf8"); + if (filePath.toLowerCase().endsWith(".mdx")) { + let esm: string | null = null; + try { + esm = readMdxEsm?.(code) ?? null; + } catch { + // A source MDX can't parse can't build either. + } + unreadable ||= esm === null; + code = esm ?? ""; + } + const dynamic = extractExportConstString(code, "dynamic"); + const revalidate = extractExportConstNumber(code, "revalidate"); + const runtime = extractExportConstString(code, "runtime"); + return { + ...(dynamic === null ? {} : { dynamic }), + ...(hasRuntimeExportedName(code, "generateStaticParams") + ? { generateStaticParams() {} } + : {}), + ...(revalidate === null ? {} : { revalidate }), + ...(runtime === null ? {} : { runtime }), + }; + }; + const layouts = route.layouts.map(readSegmentConfig); + const page = readSegmentConfig(route.pagePath); + const parallelBranches = route.parallelSlots.map((slot) => ({ + configLayouts: (slot.configLayoutPaths ?? []).map(readSegmentConfig), + configLayoutTreePositions: slot.configLayoutTreePositions ?? [], + isDefault: !slot.pagePath, + layout: readSegmentConfig(slot.layoutPath), + name: slot.name, + ownerTreePosition: slot.ownerTreePosition ?? null, + page: readSegmentConfig(slot.pagePath ?? slot.defaultPath), + routeSegments: slot.routeSegments, + })); + if (unreadable) return "unreadable"; + const segmentConfig = resolveAppPageSegmentConfig({ + layouts, + layoutTreePositions: route.layoutTreePositions, + page, + parallelBranches, + routeSegments: route.routeSegments, + }); + const eligible = isAppPageStaticEligible({ + dynamicConfig: segmentConfig.dynamicConfig, + hasGenerateStaticParams: hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + childrenSlot: route.childrenSlot ?? null, + layouts, + layoutTreePositions: route.layoutTreePositions, + page, + parallelBranches, + routeSegments: route.routeSegments, + }), + isDynamicRoute: route.isDynamic, + isStaticGenerationEdgeRuntime: isEdgeRuntime( + resolveAppPageStaticGenerationRuntime( + collectAppPageStaticGenerationRuntimes({ + childrenSlot: route.childrenSlot ?? null, + layouts, + layoutTreePositions: route.layoutTreePositions, + page, + parallelBranches, + routeSegments: route.routeSegments, + }), + ), + ), + revalidateSeconds: segmentConfig.revalidateSeconds, + }); + return eligible ? "eligible" : "ineligible"; +} + +/** Keys a path an App page route lists by the route and the pathname together. */ +function appPageListingKey(routePattern: string, pathname: string): string { + return `${routePattern}\0${pathname}`; +} + async function collectAppPaths(options: { appDir: string; baseUrl: string | null; @@ -731,12 +845,18 @@ async function collectAppPaths(options: { enumerateDynamicPaths: boolean; pageExtensions: readonly string[]; retryOptions?: PathDiscoveryRetryOptions; + root: string; secretHeaders: Record; }): Promise<{ fallbackRoutePatterns: PrerenderRoutePattern[]; nonDynamicPaths: string[]; paths: string[]; routeHandlerPaths: string[]; + /** + * Keys (`appPageListingKey`) of the paths each static or SSG App page route + * lists in its own static generation. + */ + listedRoutePaths: Set; }> { const routes = await appRouter(options.appDir, options.pageExtensions); const paths: string[] = []; @@ -746,6 +866,7 @@ async function collectAppPaths(options: { const fallbackRoutePatterns: PrerenderRoutePattern[] = []; const nonDynamicPaths: string[] = []; const seenNonDynamicPaths = new Set(); + const listedRoutePaths = new Set(); const staticParamsCache = new Map[] | null>>(); let requireNonEmptyStaticParams = false; const staticParamsMap = new Proxy({} as StaticParamsMap, { @@ -802,6 +923,10 @@ async function collectAppPaths(options: { }, }); + const readMdxEsm = + !options.cacheComponents && options.pageExtensions.includes("mdx") + ? await loadMdxEsmReader(options.root) + : null; for (const route of routes) { const isRouteHandler = route.routePath !== null && route.pagePath === null; const renderEntryPath = isRouteHandler ? route.routePath : getAppRouteRenderEntryPath(route); @@ -813,6 +938,16 @@ async function collectAppPaths(options: { const { type } = classifyAppRoute(renderEntryPath, route.routePath, route.isDynamic); if (type === "api") continue; } + // Next.js's build lists paths only for a static or SSG page route, and + // renders each under the route that generated it. Paths discovered for any + // other route, for example through a sibling page's generateStaticParams, + // stay warm paths but aren't listed. A cacheComponents build keeps every + // page route eligible, as dispatch does. + const staticEligibility = + isRouteHandler || options.cacheComponents + ? "eligible" + : classifyAppPageRouteStaticEligibility(route, readMdxEsm); + const isStaticEligible = staticEligibility !== "ineligible"; const addDiscoveredPath = (pathname: string): void => { if (isRouteHandler) { @@ -820,6 +955,7 @@ async function collectAppPaths(options: { return; } addPath(paths, seen, pathname); + if (isStaticEligible) listedRoutePaths.add(appPageListingKey(route.pattern, pathname)); }; if (!route.isDynamic) { @@ -909,7 +1045,7 @@ async function collectAppPaths(options: { }); const hasStaticFallback = paramSets !== null || dynamicConfig === "force-static" || dynamicConfig === "error"; - if (hasStaticFallback && !hasDynamicSegment) { + if (hasStaticFallback && !hasDynamicSegment && staticEligibility === "eligible") { fallbackRoutePatterns.push({ kind: "app-page", pattern: route.pattern }); } continue; @@ -929,6 +1065,7 @@ async function collectAppPaths(options: { nonDynamicPaths, paths, routeHandlerPaths, + listedRoutePaths, }; } @@ -1120,6 +1257,7 @@ function annotateCacheabilityProbeSafety( config: Pick, routeMayResolve: ReadonlySet, requestStageMayTerminate: ReadonlySet, + unlisted: ReadonlySet, isResponsePolicyHeader: (name: string) => boolean, ): Record { const cachePolicyRules = config.headers.filter((rule) => @@ -1171,6 +1309,7 @@ function annotateCacheabilityProbeSafety( canPrunePattern, ...(routeMayResolve.has(pathname) ? { routeMayResolve: true } : {}), ...(requestStageMayTerminate.has(pathname) ? { requestStageMayTerminate: true } : {}), + ...(unlisted.has(pathname) ? { unlisted: true } : {}), }, }, ]; @@ -1332,6 +1471,8 @@ export async function discoverPrerenderPathManifest( const discoveredRouteHandlerPaths: string[] = []; const seenRouteHandlerPaths = new Set(); const discoveredNonDynamicPathSet = new Set(); + const appListedRoutePaths = new Set(); + const candidateOnlyPathSet = new Set(); const fallbackRoutePatterns: PrerenderRoutePattern[] = []; await withPrerenderEndpoints(async () => { let prodServer: { server: HttpServer; port: number } | null = null; @@ -1397,6 +1538,7 @@ export async function discoverPrerenderPathManifest( enumerateDynamicPaths: options.candidatePathsOnly !== true, pageExtensions: config.pageExtensions, retryOptions: pathDiscoveryRetryOptions, + root, secretHeaders, }); for (const pathname of appPathResult.paths) { @@ -1409,6 +1551,7 @@ export async function discoverPrerenderPathManifest( discoveredNonDynamicPathSet.add(pathname); } fallbackRoutePatterns.push(...appPathResult.fallbackRoutePatterns); + for (const key of appPathResult.listedRoutePaths) appListedRoutePaths.add(key); } if (pagesDir) { @@ -1451,6 +1594,7 @@ export async function discoverPrerenderPathManifest( pathname = pathname.slice(config.basePath.length); else continue; } + if (!seen.has(pathname)) candidateOnlyPathSet.add(pathname); addPath(paths, seen, pathname); if (pagesDir) addPath(discoveredPagesPaths, seenPagesPaths, pathname); } @@ -1591,11 +1735,28 @@ export async function discoverPrerenderPathManifest( "", ), ); + // A path's listing belongs to the route that owns it at runtime. A path + // another route generates, for example a catch-all generating a path a more + // specific route owns, is build-rendered under the generating route, never + // under its owner. Pages pages and Route Handlers keep a traffic-picked path + // unlisted. + const unlistedPathSet = new Set( + Object.entries(appOwnedWarmPaths.routePatterns).flatMap(([pathname, route]) => + ( + route.kind === "app-page" + ? !appListedRoutePaths.has(appPageListingKey(route.pattern, pathname)) + : candidateOnlyPathSet.has(pathname) + ) + ? [pathname] + : [], + ), + ); const routePatterns = annotateCacheabilityProbeSafety( appOwnedWarmPaths.routePatterns, config, routeMayResolveWarmPathSet, requestStageMayTerminateWarmPathSet, + unlistedPathSet, (name) => name.trim().toLowerCase() === "cache-control" || options.isResponsePolicyHeader?.(name) === true, diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 4b308079de..92d42d0a48 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -162,6 +162,15 @@ export function hasExportedName(code: string, name: string): boolean { return false; } +/** + * Whether the module's runtime value exports include the given name. Type-only + * exports and re-export aliases under another name don't count. + */ +export function hasRuntimeExportedName(code: string, name: string): boolean { + const program = parseRouteModule(code); + return program !== null && hasRuntimeExportedNameInProgram(program, name); +} + function hasNamedExportInProgram(program: Program, name: string): boolean { for (const node of program.body) { if (node.type !== "ExportNamedDeclaration") continue; diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 0427b65af4..c3df056e14 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -361,6 +361,7 @@ export function generateAppRequestRscEntry( return ` import ${JSON.stringify(serverGlobalsPath)}; import { createAppRscRequestHandler } from "vinext/server/app-rsc-handler"; +import __cacheabilityRequestProjection from "virtual:vinext-cacheability-request-projection"; 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"; @@ -466,6 +467,7 @@ ${generateDevOriginCheckCode(config?.allowedDevOrigins)} const __requestHandler = createAppRscRequestHandler({ basePath: __basePath, buildId: process.env.__VINEXT_BUILD_ID ?? null, + cacheabilityRequestProjection: __cacheabilityRequestProjection, clearRequestContext: __clearRequestContext, configHeaders: ${JSON.stringify(config?.headers ?? [])}, configRedirects: ${JSON.stringify(config?.redirects ?? [])}, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 2cda7a3c81..a3703fc10d 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -55,7 +55,10 @@ import { isImageOptimizationPath, resolveDevImageRedirect, } from "./server/image-optimization.js"; -import { CACHEABILITY_MANIFEST_MODULE } from "./server/cacheability-manifest.js"; +import { + CACHEABILITY_MANIFEST_MODULE, + CACHEABILITY_REQUEST_PROJECTION_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"; @@ -1203,6 +1206,9 @@ 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_CACHEABILITY_REQUEST_PROJECTION = "virtual:vinext-cacheability-request-projection"; +const RESOLVED_CACHEABILITY_REQUEST_PROJECTION = + VIRTUAL_PREFIX + VIRTUAL_CACHEABILITY_REQUEST_PROJECTION; 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"; @@ -4510,6 +4516,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } return RESOLVED_CACHEABILITY_MANIFEST; } + if (cleanId === VIRTUAL_CACHEABILITY_REQUEST_PROJECTION) { + if ( + hasAppDir && + this.environment?.name === "rsc" && + this.environment.config?.command === "build" + ) { + return { id: `./${CACHEABILITY_REQUEST_PROJECTION_MODULE}`, external: true }; + } + return RESOLVED_CACHEABILITY_REQUEST_PROJECTION; + } if (cleanId === VIRTUAL_PREGENERATED_CONCRETE_PATHS) { const isWorkerBuildEnvironment = hasAppDir ? this.environment?.name === "rsc" @@ -4675,7 +4691,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return `export default ${JSON.stringify(metadata)};`; } // App Router virtual modules - if (id === RESOLVED_CACHEABILITY_MANIFEST) { + if ( + id === RESOLVED_CACHEABILITY_MANIFEST || + id === RESOLVED_CACHEABILITY_REQUEST_PROJECTION + ) { return "export default null;"; } if (id === RESOLVED_PREGENERATED_CONCRETE_PATHS) { @@ -5217,6 +5236,11 @@ export const loadServerActionClient = ${ source: "export default null;\n", }); if (hasAppDir) { + this.emitFile({ + type: "asset", + fileName: CACHEABILITY_REQUEST_PROJECTION_MODULE, + source: "export default null;\n", + }); this.emitFile({ type: "asset", fileName: PREGENERATED_CONCRETE_PATHS_MODULE, diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 233be66762..f3f4f25019 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -100,7 +100,13 @@ import { isRouteCacheabilityIdentityProbe, isRouteCacheabilityProbe, markRouteCacheabilityPatternDynamic, + readRouteCacheabilityState, } from "vinext/shims/cacheability-classification"; +import { + cacheabilityManifestPageState, + type CacheabilityManifest, + type CacheabilityRepresentation, +} from "./cacheability-manifest.js"; import type { AppRenderErrorContextOverrides } from "./app-rsc-error-handler.js"; import { traceResponseStart } from "./response-start-tracing.js"; @@ -652,6 +658,28 @@ function toInterceptOptions( }; } +/** + * Workers Cache never admits a path its deploy manifest gives no state: a + * listed path that used a dynamic API, or an unlisted path of a route without + * on-demand ISR. Next.js serves those per request, so they render normally, + * with real values. Without a manifest, admission uses the `runtime` policy + * and every path keeps candidate mode, as on core. + */ +function hasNoManifestAdmissionState(routePattern: string): boolean { + const admission = readRouteCacheabilityState()?.admission; + return ( + admission?.policy === "manifest" && + admission.representation !== undefined && + admission.routePathname !== undefined && + cacheabilityManifestPageState( + admission.manifest as CacheabilityManifest, + { kind: "app-page", pattern: routePattern }, + admission.representation as CacheabilityRepresentation, + admission.routePathname, + ) === null + ); +} + export async function dispatchAppPage( options: DispatchAppPageOptions, ): Promise { @@ -706,10 +734,26 @@ async function dispatchAppPageInner( const currentRevalidateSeconds = options.revalidateSeconds ?? (hasStaticRevalidateDefault || options.hasAnyGenerateStaticParams ? Infinity : null); - if (isRouteCacheabilityProbe() && (isForceDynamic || currentRevalidateSeconds === 0)) { - markRouteCacheabilityPatternDynamic( - isForceDynamic ? 'dynamic = "force-dynamic"' : "revalidate = 0", - ); + if (isRouteCacheabilityProbe()) { + // A route that isn't static or SSG is never stored, so its probe reports + // the whole pattern dynamic, whichever of its paths discovery listed. + const isRouteStaticEligible = + options.pprRuntime !== undefined || + isAppPageStaticEligible({ + dynamicConfig: options.dynamicConfig, + hasGenerateStaticParams: options.hasGenerateStaticParams, + isDynamicRoute: route.isDynamic, + isStaticGenerationEdgeRuntime: options.isStaticGenerationEdgeRuntime === true, + revalidateSeconds: options.revalidateSeconds ?? null, + }); + const patternDynamicReason = isForceDynamic + ? 'dynamic = "force-dynamic"' + : currentRevalidateSeconds === 0 + ? "revalidate = 0" + : !isRouteStaticEligible + ? "route is not statically generated" + : null; + if (patternDynamicReason) markRouteCacheabilityPatternDynamic(patternDynamicReason); } const isPrerender = process.env.VINEXT_PRERENDER === "1"; let traceOperation: "prerender" | "render" = isPrerender ? "prerender" : "render"; @@ -797,8 +841,7 @@ async function dispatchAppPageInner( }); } - const shouldReadCache = - !isRouteCacheabilityProbe() && + const isCacheEligibleRender = options.bypassInterceptionContextCache !== true && shouldReadAppPageCache({ isDraftMode, @@ -809,10 +852,16 @@ async function dispatchAppPageInner( revalidateSeconds: currentRevalidateSeconds, scriptNonce: options.scriptNonce, }); + const shouldReadCache = !isRouteCacheabilityProbe() && isCacheEligibleRender; // A render that may be stored, and so must not let the request's query reach - // its output unless it turns out dynamic. PPR fallback shells follow - // cacheComponents' model instead. - const isCacheCandidate = shouldReadCache && isStaticEligible && options.pprRuntime === undefined; + // its output unless it turns out dynamic. The Workers Cache deploy probe + // renders this way too, so its manifest matches the runtime. PPR fallback + // shells follow cacheComponents' model instead. + const isCacheCandidate = + isCacheEligibleRender && + isStaticEligible && + options.pprRuntime === undefined && + !hasNoManifestAdmissionState(route.pattern); if (shouldReadCache && isStaticEligible) { traceOperation = resolveAppPageTraceOperation({ hasRequestSearchParams, diff --git a/packages/vinext/src/server/app-response-stage-entry.ts b/packages/vinext/src/server/app-response-stage-entry.ts index 9d167ec969..2876bf039e 100644 --- a/packages/vinext/src/server/app-response-stage-entry.ts +++ b/packages/vinext/src/server/app-response-stage-entry.ts @@ -108,6 +108,7 @@ export async function handleResponseStage( policyHeaders: props.cacheability.policyHeaders, probeMode: props.cacheability.probeMode, rawManifest: __cacheabilityManifest, + recomposesRequestScopedHeaders: props.kind === "app-page" && props.isRscRequest, registerCacheAdapters: () => registerConfiguredCacheAdapters(env), request, representation: props.cacheability.representation, diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index c99127744d..3c64c19b5c 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -145,13 +145,16 @@ import { APP_METADATA_RESPONSE_STAGE_NO_MATCH_HEADER, APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, createSharedAppPageCacheIdentity, + isStaticCandidateAppPageDispatch, prepareSharedAppPageDispatch, + withoutAppPageDispatchQuery, type AppMatchedWorkerResponseStageProps, type AppWorkerResponseStageProps, type DispatchAppWorkerResponseStage, type RenderAppWorkerResponseStageLocally, } from "./app-worker-stages.js"; import type { VinextCacheabilityProbeMode } from "./multi-stage.js"; +import { parseCacheabilityManifest, type CacheabilityManifest } from "./cacheability-manifest.js"; import { consumePagesResponseStagePolicyOwner, withoutResponseStageVary, @@ -250,6 +253,31 @@ function requestOptsOutOfWorkerResponseStage( return false; } +let parsedCacheabilityRequestProjection: + | { buildId: string; manifest: CacheabilityManifest | null; raw: string } + | undefined; + +function readCacheabilityRequestProjection( + options: Pick< + CreateAppRscHandlerOptions, + "buildId" | "cacheabilityRequestProjection" + >, +): CacheabilityManifest | null { + const raw = options.cacheabilityRequestProjection; + if (!raw || !options.buildId) return null; + if ( + parsedCacheabilityRequestProjection?.raw !== raw || + parsedCacheabilityRequestProjection.buildId !== options.buildId + ) { + parsedCacheabilityRequestProjection = { + buildId: options.buildId, + manifest: parseCacheabilityManifest(raw, options.buildId), + raw, + }; + } + return parsedCacheabilityRequestProjection.manifest; +} + function adapterUsesQueryFreeCacheIdentity(): boolean { const adapter = getCdnCacheAdapter(); // The identity is query-free, so it is safe only behind completed-response @@ -479,6 +507,12 @@ type NavigationContextValue = { export type CreateAppRscHandlerOptions = { basePath: string; buildId: string | null; + /** + * Serialized request-stage projection of the Workers Cache manifest. Shared + * App page dispatches whose manifest state is `static-candidate` drop the + * user query. Null outside a `vinext deploy` artifact. + */ + cacheabilityRequestProjection?: string | null; clearRequestContext: () => void; configHeaders: NextHeader[]; configRedirects: NextRedirect[]; @@ -1143,6 +1177,7 @@ async function handleAppRscRequest( props.kind === "app-page" ? prepareSharedAppPageDispatch(stageRequest, cache) : stageRequest; + let canonicalRsc: { headers: Headers; navigation: boolean } | null = null; if ( cache === "shared" && props.kind === "app-page" && @@ -1160,23 +1195,46 @@ async function handleAppRscRequest( ? canonicalizeLoadingShellRscRequestHeaders(headers) : false; if (canonicalized) { - const rscPath = - props.renderMode === "navigation" - ? createCanonicalRscRequestUrl(dispatchRequest.url) - : await createRscRequestUrl(dispatchRequest.url, headers); - dispatchRequest = cloneRequestWithUrl( - cloneRequestWithHeaders(dispatchRequest, headers), - new URL(rscPath, dispatchRequest.url).toString(), - ); + canonicalRsc = { headers, navigation: props.renderMode === "navigation" }; + dispatchRequest = cloneRequestWithHeaders(dispatchRequest, headers); } } - const stageProps: AppWorkerResponseStageProps = { + let stageProps: AppWorkerResponseStageProps = { ...props, cacheability: { ...props.cacheability, policyHeaders: responseStagePolicy, }, }; + // Workers Cache keys a dispatch by its URL and props, and a hit runs + // no code, so a static-candidate path drops the user query from the + // dispatch itself. A next.config public policy keeps the full URL, + // as the query-free cache identity below does. The canonical RSC + // URL is built afterwards, because it keeps the request's search. + if ( + cache === "shared" && + responseStagePolicy === null && + stageProps.kind === "app-page" + ) { + const projection = readCacheabilityRequestProjection(options); + if ( + projection && + isStaticCandidateAppPageDispatch(projection, dispatchRequest, stageProps) + ) { + const queryFree = withoutAppPageDispatchQuery(dispatchRequest.url, stageProps); + dispatchRequest = cloneRequestWithUrl(dispatchRequest, queryFree.url); + stageProps = queryFree.props; + } + } + if (canonicalRsc) { + const rscPath = canonicalRsc.navigation + ? createCanonicalRscRequestUrl(dispatchRequest.url) + : await createRscRequestUrl(dispatchRequest.url, canonicalRsc.headers); + dispatchRequest = cloneRequestWithUrl( + dispatchRequest, + new URL(rscPath, dispatchRequest.url).toString(), + ); + } // Shared dispatches are GET/HEAD only. A next.config public policy // is admitted whatever the render read, so it keeps the full-URL // identity, as Next.js CDN caching does. Interception and mounted-slot diff --git a/packages/vinext/src/server/app-worker-stages.ts b/packages/vinext/src/server/app-worker-stages.ts index a79e1ab5ce..f63b44c664 100644 --- a/packages/vinext/src/server/app-worker-stages.ts +++ b/packages/vinext/src/server/app-worker-stages.ts @@ -1,5 +1,11 @@ import { keepOnlyValidatedRscCacheBustingSearchParam } from "./app-rsc-cache-busting.js"; import { VINEXT_RSC_VARY_HEADER } from "./app-rsc-vary.js"; +import { + cacheabilityManifestPageState, + cacheabilityRequestIdentity, + cacheabilityRoutePathname, + type CacheabilityManifest, +} from "./cacheability-manifest.js"; import type { AppRscRenderMode } from "./app-rsc-render-mode.js"; import type { VinextResponseStageCacheability, @@ -129,8 +135,7 @@ export function createSharedAppPageCacheIdentity( request: Request, props: AppMatchedWorkerResponseStageProps, ): NonNullable { - const url = new URL(request.url); - keepOnlyValidatedRscCacheBustingSearchParam(url, props.isRscRequest); + const queryFree = withoutAppPageDispatchQuery(request.url, props); const headers = new Headers(request.headers); if (!props.isRscRequest) { // HTML dispatch reads none of the RSC selectors: its render mode is fixed @@ -138,16 +143,57 @@ export function createSharedAppPageCacheIdentity( // props. Transports key these Vary fields, so keep them out of the key. for (const name of VINEXT_RSC_VARY_HEADER.split(",")) headers.delete(name.trim()); } + return { + props: queryFree.props, + request: new Request(queryFree.url, { headers, method: request.method }), + }; +} + +/** + * Drop the user query from an App page dispatch URL and its `resolvedUrl`, + * keeping an RSC request's validated `_rsc`, the rule the query-free cache + * identity uses. + */ +export function withoutAppPageDispatchQuery( + requestUrl: string, + props: AppMatchedWorkerResponseStageProps, +): { props: AppMatchedWorkerResponseStageProps; url: string } { + const url = new URL(requestUrl); + keepOnlyValidatedRscCacheBustingSearchParam(url, props.isRscRequest); const searchIndex = props.resolvedUrl.indexOf("?"); return { props: { ...props, resolvedUrl: searchIndex === -1 ? props.resolvedUrl : props.resolvedUrl.slice(0, searchIndex), }, - request: new Request(url, { headers, method: request.method }), + url: url.toString(), }; } +/** + * Whether the Workers Cache request-stage projection gives a shared App page + * dispatch the `static-candidate` state, so its dispatch drops the user query. + * It is decided from the inputs completed-response admission uses: the + * header-canonicalized request's representation, the pathname after rewrites + * and the matched route pattern. + */ +export function isStaticCandidateAppPageDispatch( + projection: CacheabilityManifest, + request: Request, + props: AppMatchedWorkerResponseStageProps, +): boolean { + const identity = cacheabilityRequestIdentity(request, props.cacheability.representation); + if (!identity) return false; + return ( + cacheabilityManifestPageState( + projection, + { kind: "app-page", pattern: props.routePattern }, + identity.representation, + cacheabilityRoutePathname(props.cacheability.resolvedRoutePathname, identity.representation), + ) === "static-candidate" + ); +} + function isAppPageParams(value: unknown): value is AppPageParams { if (!value || typeof value !== "object" || Array.isArray(value)) return false; for (const param of Object.values(value)) { diff --git a/packages/vinext/src/server/cacheability-manifest.ts b/packages/vinext/src/server/cacheability-manifest.ts index 039775e826..ea71f498f5 100644 --- a/packages/vinext/src/server/cacheability-manifest.ts +++ b/packages/vinext/src/server/cacheability-manifest.ts @@ -15,6 +15,8 @@ import { import { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL } from "./app-rsc-render-mode.js"; export const CACHEABILITY_MANIFEST_MODULE = "__vinext_cacheability_manifest.js"; +/** Request-stage projection of the manifest: the App page routes that can admit a query-free entry. */ +export const CACHEABILITY_REQUEST_PROJECTION_MODULE = "__vinext_cacheability_request_projection.js"; export type CacheabilityRouteKind = "app-page" | "app-route" | "pages-page"; export type CacheabilityRepresentation = @@ -42,7 +44,9 @@ export type CacheabilityManifestRoute = { staticRepresentation?: CacheabilityRepresentation; /** Exact dynamic paths observed in a mixed or pattern-dynamic route. */ runtimePaths?: string[]; - /** Exact paths statically certified by the representation that was probed. */ + /** Exact paths runtime-checked only in the listed representation. */ + runtimeRepresentationPaths?: Partial>; + /** Exact paths statically certified per representation. A path may appear in several lists. */ staticPaths?: Partial>; }; @@ -138,6 +142,10 @@ function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | nu route.runtimePaths === undefined ? undefined : parsePathList(route.runtimePaths, pathPrefix); const staticPaths = route.staticPaths === undefined ? undefined : parseStaticPaths(route.staticPaths, pathPrefix); + const runtimeRepresentationPaths = + route.runtimeRepresentationPaths === undefined + ? undefined + : parseStaticPaths(route.runtimeRepresentationPaths, pathPrefix); const staticRepresentation = isRepresentation(route.staticRepresentation) ? route.staticRepresentation : undefined; @@ -157,16 +165,19 @@ function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | nu pathPrefix !== undefined || staticRepresentation !== undefined || runtimePaths !== undefined || - staticPaths !== undefined)) || + staticPaths !== undefined || + runtimeRepresentationPaths !== undefined)) || (staticRepresentation !== undefined && (route.state !== "runtime-check" || /(^|\/):/.test(route.pattern) || runtimePaths !== undefined || - staticPaths !== undefined)) || - (pathPrefix !== undefined && !runtimePaths && !staticPaths) || + staticPaths !== undefined || + runtimeRepresentationPaths !== undefined)) || + (pathPrefix !== undefined && !runtimePaths && !staticPaths && !runtimeRepresentationPaths) || (route.runtimePaths !== undefined && !runtimePaths) || (route.staticPaths !== undefined && !staticPaths) || - ((runtimePaths || staticPaths || route.allowUnknown === true) && + (route.runtimeRepresentationPaths !== undefined && !runtimeRepresentationPaths) || + ((runtimePaths || staticPaths || runtimeRepresentationPaths || route.allowUnknown === true) && route.state !== "runtime-check") ) { return null; @@ -183,15 +194,29 @@ function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | nu ...(runtimeRepresentation ? { runtimeRepresentation } : {}), ...(staticRepresentation ? { staticRepresentation } : {}), ...(runtimePaths ? { runtimePaths } : {}), + ...(runtimeRepresentationPaths ? { runtimeRepresentationPaths } : {}), ...(staticPaths ? { staticPaths } : {}), }; - const observedPaths = new Set(); - for (const tokens of [runtimePaths, ...Object.values(staticPaths ?? {})]) { + // A certified App page path is listed under each representation that shares + // its render (HTML and its RSC versions). Each list is sorted and unique, and + // a runtime-checked path is never also certified static. + const runtimePathSet = new Set( + (runtimePaths ?? []).map((token) => expandPathToken(pathPrefix, token)!), + ); + for (const tokens of Object.values(staticPaths ?? {})) { + for (const token of tokens ?? []) { + if (runtimePathSet.has(expandPathToken(pathPrefix, token)!)) return null; + } + } + // A representation-only runtime path is neither runtime-checked in every + // representation nor certified static in its own. + for (const [representation, tokens] of Object.entries(runtimeRepresentationPaths ?? {})) { + const staticTokens = new Set(staticPaths?.[representation as CacheabilityRepresentation]); for (const token of tokens ?? []) { - const pathname = expandPathToken(pathPrefix, token)!; - if (observedPaths.has(pathname)) return null; - observedPaths.add(pathname); + if (runtimePathSet.has(expandPathToken(pathPrefix, token)!) || staticTokens.has(token)) { + return null; + } } } return key === cacheabilityManifestRouteKey(parsed.kind, parsed.pattern) ? parsed : null; @@ -365,7 +390,15 @@ export function cacheabilityManifestRouteState( if (route.runtimeRepresentation !== undefined) { return representation === route.runtimeRepresentation ? route.state : null; } - if (!route.staticPaths && !route.runtimePaths && route.allowUnknown !== true) { + if (representation && includesPath(route.runtimeRepresentationPaths?.[representation])) { + return route.state; + } + if ( + !route.staticPaths && + !route.runtimePaths && + !route.runtimeRepresentationPaths && + route.allowUnknown !== true + ) { return route.state; } if (includesPath(route.runtimePaths)) return route.state; @@ -385,3 +418,83 @@ export function findCacheabilityManifestRoute( ): CacheabilityManifestRoute | null { return manifest.routes[cacheabilityManifestRouteKey(kind, pattern)] ?? null; } + +export function resolveCacheabilityRepresentation( + representation: CacheabilityRepresentation, + routeKind: "app-page" | "app-route" | "pages-api" | "pages-page", +): CacheabilityRepresentation { + // Accept describes the representation a caller would prefer; it does not + // determine whether the resolved pathname belongs to an App Page or a Route + // Handler. Browser fetch() uses Accept: */* by default, while Route Handlers + // may legitimately be requested with Accept: text/html. Once routing has + // resolved the owner, make that result authoritative for non-RSC requests. + if (representation !== "html" && representation !== "app-route") { + return representation; + } + return routeKind === "app-route" || routeKind === "pages-api" ? "app-route" : "html"; +} + +/** Whether completed-response admission can store a page route under this representation. */ +export function cacheabilityRepresentationMatchesPageRoute( + routeKind: "app-page" | "pages-page", + representation: CacheabilityRepresentation, +): boolean { + return routeKind === "app-page" + ? representation === "html" || + representation === "rsc-full" || + representation === "rsc-loading-shell" + : representation === "html" || representation === "pages-data"; +} + +/** + * The manifest state completed-response admission gives a page request, or + * null when admission refuses it. `requestRepresentation` is the request + * identity's, and `routePathname` is built from the resolved pathname with + * `cacheabilityRoutePathname`, as admission builds it. The Workers Cache + * request stage decides its query-free dispatch with this same function, so + * the dispatch and admission always see the same state. + */ +export function cacheabilityManifestPageState( + manifest: CacheabilityManifest, + route: { kind: "app-page" | "pages-page"; pattern: string }, + requestRepresentation: CacheabilityRepresentation, + routePathname: string, +): CacheabilityManifestRouteState | null { + const representation = resolveCacheabilityRepresentation(requestRepresentation, route.kind); + if (!cacheabilityRepresentationMatchesPageRoute(route.kind, representation)) return null; + const manifestRoute = findCacheabilityManifestRoute(manifest, route.kind, route.pattern); + return manifestRoute + ? cacheabilityManifestRouteState(manifestRoute, routePathname, representation) + : null; +} + +/** Whether any path or representation of a route can resolve to `static-candidate`. */ +function canResolveStaticCandidate(route: CacheabilityManifestRoute): boolean { + return ( + route.state === "static-candidate" || + route.unknownState === "static-candidate" || + route.staticRepresentation !== undefined || + route.staticPaths !== undefined + ); +} + +/** + * Project the manifest onto the Workers Cache request stage, which strips the + * query from App page dispatches whose manifest state is `static-candidate`. + * The projection keeps every App page route record that can resolve to that + * state, unchanged, so a lookup against it returns `static-candidate` exactly + * when the full manifest does. Every other route is left out. + */ +export function projectCacheabilityManifestForRequestStage( + manifest: CacheabilityManifest, +): CacheabilityManifest { + return { + buildId: manifest.buildId, + routes: Object.fromEntries( + Object.entries(manifest.routes).filter( + ([, route]) => route.kind === "app-page" && canResolveStaticCandidate(route), + ), + ), + version: 1, + }; +} diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index 90c86ab430..7503f79d10 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -28,11 +28,14 @@ import { CACHEABILITY_PROBE_TIMEOUT_MS, } from "./cacheability-limits.js"; import { + cacheabilityManifestPageState, cacheabilityManifestRouteState, + cacheabilityRepresentationMatchesPageRoute, cacheabilityRequestIdentity, cacheabilityRoutePathname, findCacheabilityManifestRoute, parseCacheabilityManifest, + resolveCacheabilityRepresentation, type CacheabilityManifest, type CacheabilityManifestRoute, type CacheabilityRouteKind, @@ -48,6 +51,10 @@ type CacheabilityProbeRouteState = type CacheabilityProbeResult = { cacheControl?: string; + /** The render used a dynamic API, a private cache or a route-wide dynamic config. */ + dynamicUsage?: true; + /** A matching next.config header policy applied to the response. */ + explicitConfigCachePolicy?: true; kind?: "app-page" | "app-route" | "pages-api" | "pages-page"; pattern?: string; reason?: string; @@ -248,21 +255,6 @@ function readState(ctx: ExecutionContextLike): RouteCacheabilityState | null { ); } -function resolveCacheabilityRepresentation( - representation: CacheabilityRepresentation, - routeKind: "app-page" | "app-route" | "pages-api" | "pages-page", -): CacheabilityRepresentation { - // Accept describes the representation a caller would prefer; it does not - // determine whether the resolved pathname belongs to an App Page or a Route - // Handler. Browser fetch() uses Accept: */* by default, while Route Handlers - // may legitimately be requested with Accept: text/html. Once routing has - // resolved the owner, make that result authoritative for non-RSC requests. - if (representation !== "html" && representation !== "app-route") { - return representation; - } - return routeKind === "app-route" || routeKind === "pages-api" ? "app-route" : "html"; -} - /** Apply request-stage-vetted positive config policy inside the admission boundary. */ export function applyResponseStageCachePolicy( response: Response, @@ -312,14 +304,22 @@ function probeResponse( routeState: CacheabilityProbeRouteState, outcome: RouteCacheabilityOutcome, status: number, - rendererStatic?: boolean, + renderer?: { dynamicUsage: boolean; static: boolean }, ): Response { + // Next.js's build treats a private cache and a route-wide dynamic config as + // dynamic usage too. + const dynamicUsage = + renderer?.dynamicUsage === true || + outcome.dynamicUsage === true || + state.patternDynamicReason !== undefined; const body: CacheabilityProbeResult = { cacheControl: outcome.cacheControl, + ...(dynamicUsage ? { dynamicUsage: true as const } : {}), + ...(state.explicitConfigCachePolicy ? { explicitConfigCachePolicy: true as const } : {}), kind: state.route?.kind, pattern: state.route?.pattern, reason: outcome.reason, - ...(rendererStatic !== undefined ? { rendererStatic } : {}), + ...(renderer ? { rendererStatic: renderer.static } : {}), ...(outcome.retryable ? { retryable: true as const } : {}), ...(state.resolvedRoutePathname ? { routePathname: state.resolvedRoutePathname } : {}), ...(routeState === "dynamic" @@ -830,17 +830,10 @@ async function finalizeWorkerCacheabilityAdmission( ) { return responseWithCachePolicy(response, response.body, null); } - const representation = resolveCacheabilityRepresentation( - admission.representation as CacheabilityRepresentation, - state.route.kind, - ); - const representationMatchesRoute = - state.route.kind === "app-page" - ? representation === "html" || - representation === "rsc-full" || - representation === "rsc-loading-shell" - : representation === "html" || representation === "pages-data"; - if (!representationMatchesRoute) { + const pageRoute = { kind: state.route.kind, pattern: state.route.pattern }; + const requestRepresentation = admission.representation as CacheabilityRepresentation; + const representation = resolveCacheabilityRepresentation(requestRepresentation, pageRoute.kind); + if (!cacheabilityRepresentationMatchesPageRoute(pageRoute.kind, representation)) { return responseWithCachePolicy(response, response.body, null); } @@ -848,11 +841,15 @@ async function finalizeWorkerCacheabilityAdmission( let manifestRouteState: ReturnType = null; if (admission.policy === "manifest") { const manifest = admission.manifest as CacheabilityManifest; - manifestRoute = findCacheabilityManifestRoute(manifest, state.route.kind, state.route.pattern); - manifestRouteState = - manifestRoute && admission.routePathname - ? cacheabilityManifestRouteState(manifestRoute, admission.routePathname, representation) - : null; + manifestRoute = findCacheabilityManifestRoute(manifest, pageRoute.kind, pageRoute.pattern); + manifestRouteState = admission.routePathname + ? cacheabilityManifestPageState( + manifest, + pageRoute, + requestRepresentation, + admission.routePathname, + ) + : null; if (!manifestRoute || !manifestRouteState) { return responseWithCachePolicy(response, response.body, null); } @@ -1028,6 +1025,9 @@ export async function finalizeWorkerCacheabilityResponse( : "dynamic", outcome, response.status, - rendererOutcome?.cacheable === true && rendererOutcome.dynamicUsage !== true, + { + dynamicUsage: rendererOutcome?.dynamicUsage === true, + static: rendererOutcome?.cacheable === true && rendererOutcome.dynamicUsage !== true, + }, ); } diff --git a/packages/vinext/src/server/response-stage-cacheability.ts b/packages/vinext/src/server/response-stage-cacheability.ts index deef23b0e2..065ca545e8 100644 --- a/packages/vinext/src/server/response-stage-cacheability.ts +++ b/packages/vinext/src/server/response-stage-cacheability.ts @@ -7,6 +7,8 @@ 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"; +import { preserveFullyBufferedBodyMetadata } from "./fully-buffered-response.js"; +import { VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER } from "./headers.js"; export type ResponseStageCacheabilityOptions = { buildId: string | null | undefined; @@ -22,6 +24,12 @@ export type ResponseStageCacheabilityOptions = { resolvedRoutePathname?: string; /** Trusted representation retained when request-stage normalization changes the URL shape. */ representation?: CacheabilityRepresentation; + /** + * The request stage recomposes `X-Vinext-Params` and + * `X-Vinext-Rendered-Path-And-Search` per request (App page RSC), so a + * shared response drops them before admission and storage. + */ + recomposesRequestScopedHeaders?: boolean; /** Generated adapter registration, deferred until the response stage executes. */ registerCacheAdapters(): void; request: Request; @@ -68,7 +76,14 @@ export async function withResponseStageCacheability( ); } - if (!cacheability) return render(context); + const stripsRequestScopedHeaders = + options.recomposesRequestScopedHeaders === true && + options.cache === "shared" && + !options.probeMode; + if (!cacheability) { + const rendered = await render(context); + return stripsRequestScopedHeaders ? withoutRequestScopedHeaders(rendered) : rendered; + } if (options.policyHeadersAppliedBeforeRender) { cacheability.recordResponseStageCachePolicy(context, options.policyHeaders); } @@ -78,7 +93,10 @@ export async function withResponseStageCacheability( if (options.probeMode && options.forceDynamic && !options.policyHeaders?.length && state) { state.patternDynamicReason = 'dynamic = "force-dynamic"'; } - const rendered = await render(context); + const renderedResponse = await render(context); + const rendered = stripsRequestScopedHeaders + ? withoutRequestScopedHeaders(renderedResponse) + : renderedResponse; const response = options.policyHeadersAppliedBeforeRender ? rendered : cacheability.applyResponseStageCachePolicy(rendered, context, options.policyHeaders); @@ -95,3 +113,23 @@ export async function withResponseStageCacheability( } return complete(response); } + +function withoutRequestScopedHeaders(response: Response): Response { + if ( + !response.headers.has(VINEXT_PARAMS_HEADER) && + !response.headers.has(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER) + ) { + return response; + } + const headers = new Headers(response.headers); + headers.delete(VINEXT_PARAMS_HEADER); + headers.delete(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER); + return preserveFullyBufferedBodyMetadata( + response, + new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }), + ); +} diff --git a/packages/vinext/src/utils/mdx-scan.ts b/packages/vinext/src/utils/mdx-scan.ts index 03d2cce8d3..9bbdc2e714 100644 --- a/packages/vinext/src/utils/mdx-scan.ts +++ b/packages/vinext/src/utils/mdx-scan.ts @@ -1,4 +1,6 @@ import fs from "node:fs"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; import path from "pathslash"; /** Module-level cache for hasMdxFiles — avoids re-scanning per Vite environment. */ @@ -38,3 +40,45 @@ function scanDirForMdx(dir: string): boolean { } return false; } + +type MdxProcessorModule = { + createProcessor(): { + parse(source: string): { children: { type: string; value?: unknown }[] }; + }; +}; + +/** + * Load a reader that keeps only the ESM of an MDX module, so the JavaScript + * export helpers can read it. It takes the ESM nodes from the MDX parser that + * `@mdx-js/rollup` compiles with, resolved beside the plugin vinext + * auto-injects or one the app installs, or from the app itself. Returns null + * when that parser isn't installed. + */ +export async function loadMdxEsmReader(root: string): Promise<((source: string) => string) | null> { + const fromVinext = createRequire(import.meta.url); + // An app that registers its own MDX plugin builds with the parser it installs. + const fromRoot = createRequire(path.join(root, "package.json")); + const candidates = [ + () => createRequire(fromVinext.resolve("@mdx-js/rollup")).resolve("@mdx-js/mdx"), + () => createRequire(fromRoot.resolve("@mdx-js/rollup")).resolve("@mdx-js/mdx"), + () => fromRoot.resolve("@mdx-js/mdx"), + ]; + let mdx: MdxProcessorModule | null = null; + for (const resolveEntry of candidates) { + try { + mdx = (await import(pathToFileURL(resolveEntry()).href)) as MdxProcessorModule; + break; + } catch { + // Try the next place the parser can be installed. + } + } + if (!mdx) return null; + const processor = mdx.createProcessor(); + return (source) => + processor + .parse(source) + .children.flatMap((node) => + node.type === "mdxjsEsm" && typeof node.value === "string" ? [node.value] : [], + ) + .join("\n\n"); +} diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index 98d3a9d910..bfcb2f6c24 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -47,6 +47,10 @@ import { CACHEABILITY_REQUEST_STATE, type RouteCacheabilityState, } from "../packages/vinext/src/shims/cacheability-classification.js"; +import { + cacheabilityManifestRouteKey, + parseCacheabilityManifest, +} from "../packages/vinext/src/server/cacheability-manifest.js"; import { createRequestContext, runWithRequestContext, @@ -992,6 +996,151 @@ describe("app page dispatch", () => { } }); + // The deploy probe must render the way the runtime does, or the manifest it + // produces would certify a render the runtime never serves. + it("renders the Workers Cache deploy probe in cache-candidate mode without reading the cache", async () => { + const isrGet = vi.fn(async () => null); + const ssrOptions: { isCacheCandidate?: boolean }[] = []; + const context: ExecutionContextLike = { waitUntil() {} }; + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 10_000, + mode: "probe", + }; + Reflect.set(context, CACHEABILITY_REQUEST_STATE, state); + const { options } = createDispatchOptions({ + isProduction: true, + isrGet, + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, handleSsrOptions) { + ssrOptions.push({ isCacheCandidate: handleSsrOptions?.isCacheCandidate }); + return createStream(["page"]); + }, + }), + revalidateSeconds: 60, + }); + + const response = await runWithExecutionContext(context, () => dispatchAppPage(options)); + await response.text(); + + expect(ssrOptions).toEqual([{ isCacheCandidate: true }]); + expect(isrGet).not.toHaveBeenCalled(); + }); + + // Discovery can list paths of a route Next.js classifies as dynamic, such as + // one whose only generateStaticParams sits above its last dynamic segment. + // The probe must report the whole pattern dynamic so it gets no entry. + it("reports a route that isn't statically generated as pattern-dynamic to the deploy probe", async () => { + const cases: [Partial[0]>, string | undefined][] = [ + [ + { + hasAnyGenerateStaticParams: true, + hasGenerateStaticParams: false, + route: createRoute({ isDynamic: true, params: ["slug"] }), + }, + "route is not statically generated", + ], + [{ isStaticGenerationEdgeRuntime: true }, "route is not statically generated"], + [ + { + hasGenerateStaticParams: true, + route: createRoute({ isDynamic: true, params: ["slug"] }), + }, + undefined, + ], + [{}, undefined], + ]; + for (const [overrides, expected] of cases) { + const context: ExecutionContextLike = { waitUntil() {} }; + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 10_000, + mode: "probe", + }; + Reflect.set(context, CACHEABILITY_REQUEST_STATE, state); + const { options } = createDispatchOptions({ isProduction: true, ...overrides }); + + const response = await runWithExecutionContext(context, () => dispatchAppPage(options)); + await response.text(); + + expect(state.patternDynamicReason).toBe(expected); + } + }); + + // Next.js serves a path its build never certified per request, so a Workers + // Cache path its manifest gives no state renders with real values from the + // start. A certified path, and every path without a manifest, keeps + // candidate mode. + it("renders a path its Workers Cache manifest gives no state outside cache-candidate mode", async () => { + const manifest = parseCacheabilityManifest( + JSON.stringify({ + buildId: "build-a", + routes: { + [cacheabilityManifestRouteKey("app-page", "/posts/[slug]")]: { + kind: "app-page", + pattern: "/posts/[slug]", + state: "runtime-check", + staticPaths: { html: ["/posts/listed"] }, + }, + }, + version: 1, + }), + "build-a", + ); + const cases: [RouteCacheabilityState["admission"], string, boolean][] = [ + [ + { manifest, policy: "manifest", representation: "html", routePathname: "/posts/hello" }, + "/posts/hello", + false, + ], + [ + { manifest, policy: "manifest", representation: "html", routePathname: "/posts/listed" }, + "/posts/listed", + true, + ], + [ + // A curl-style request maps to html at admission, as it does here. + { + manifest, + policy: "manifest", + representation: "app-route", + routePathname: "/posts/listed", + }, + "/posts/listed", + true, + ], + [ + { policy: "runtime", representation: "html", routePathname: "/posts/hello" }, + "/posts/hello", + true, + ], + ]; + for (const [admission, cleanPathname, expected] of cases) { + const ssrOptions: { isCacheCandidate?: boolean }[] = []; + const context: ExecutionContextLike = { waitUntil() {} }; + const state: RouteCacheabilityState = { + admission, + captureDeadlineAt: Date.now() + 10_000, + mode: "admit", + }; + Reflect.set(context, CACHEABILITY_REQUEST_STATE, state); + const { options } = createDispatchOptions({ + cleanPathname, + isProduction: true, + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, handleSsrOptions) { + ssrOptions.push({ isCacheCandidate: handleSsrOptions?.isCacheCandidate }); + return createStream(["page"]); + }, + }), + revalidateSeconds: 60, + searchParams: new URLSearchParams("q=1"), + }); + + const response = await runWithExecutionContext(context, () => dispatchAppPage(options)); + await response.text(); + expect(ssrOptions, JSON.stringify(admission)).toEqual([{ isCacheCandidate: expected }]); + } + }); + it("writes HTML-captured RSC data under the plain key when interception context is absent", async () => { const isrSet = vi.fn(async () => {}); const waitUntilPromises: Promise[] = []; diff --git a/tests/app-page-render.test.ts b/tests/app-page-render.test.ts index 3fad6dd060..bfb701ccc7 100644 --- a/tests/app-page-render.test.ts +++ b/tests/app-page-render.test.ts @@ -27,8 +27,11 @@ import { createClientPageSsrSearchParamsSource } from "../packages/vinext/src/se import { ClientPageRoot } from "../packages/vinext/src/shims/client-page-root.js"; import { setNavigationContext, + useSearchParams, type NavigationContext, } from "../packages/vinext/src/shims/navigation.js"; +import { startCandidateSearchParamsGate } from "../packages/vinext/src/server/app-ssr-search-params-gate.js"; +import { finalizeWorkerCacheabilityResponse } from "../packages/vinext/src/server/cacheability-request.js"; import { BailoutToCSRError } from "../packages/vinext/src/shims/navigation-errors.js"; import { consumeDynamicUsage, @@ -1342,7 +1345,10 @@ describe("app page render lifecycle", () => { async function renderClassifying( render: ClassifyingRender, tree: ReactNode, - { readsSearchParams = false }: { readsSearchParams?: boolean } = {}, + { + isCacheCandidate = false, + readsSearchParams = false, + }: { isCacheCandidate?: boolean; readsSearchParams?: boolean } = {}, ) { const common = createCommonOptions(); const state: RouteCacheabilityState = { @@ -1376,13 +1382,14 @@ describe("app page render lifecycle", () => { params: { slug: "post" }, }; }, + isCacheCandidate, isPrerender: render !== "probe", isSpeculativePrerender: render === "speculative prerender", isProduction: true, revalidateSeconds: Infinity, async loadSsrHandler() { return { - handleSsr(_rscStream, navContext, _fontData, options) { + handleSsr(rscStream, navContext, _fontData, options) { return runWithNavigationContext(async () => { if (options?.capturedRscDataRef) { options.capturedRscDataRef.value = Promise.resolve( @@ -1390,9 +1397,21 @@ describe("app page render lifecycle", () => { ); if (options.sideStream) void options.sideStream.getReader().cancel(); } + // A candidate render gates useSearchParams() until SSR has + // read the whole Flight response, as handleSsr does. + const searchParamsGate = + options?.isCacheCandidate === true && + options.isStaticGeneration !== true && + options.isForceStatic !== true + ? startCandidateSearchParamsGate() + : null; + if (searchParamsGate) { + void new Response(searchParamsGate.settleWhenConsumed(rscStream)).text(); + } const ssrNavigationContext = navContext as NavigationContext; setNavigationContext({ ...ssrNavigationContext, + searchParamsGate: searchParamsGate?.gate, getClientPageSearchParams: createClientPageSsrSearchParamsSource( ssrNavigationContext.searchParams, {}, @@ -1413,7 +1432,11 @@ describe("app page render lifecycle", () => { }, }); }); - return { response, completion: render === "probe" ? state.completion : undefined }; + return { + response, + completion: render === "probe" ? state.completion : undefined, + executionContext, + }; } async function classify(render: ClassifyingRender, tree: ReactNode) { @@ -1474,6 +1497,34 @@ describe("app page render lifecycle", () => { expect(await read.completion).not.toHaveProperty("searchParamsUnread"); }); + // The Workers Cache deploy probe renders a static page in candidate + // mode. Next.js prerenders a page whose useSearchParams() sits inside + // Suspense once, with the fallback, and serves it for every query, so the + // probe must certify it static and keep the query out of its render. + it("certifies a static page with useSearchParams() inside Suspense in a candidate probe", async () => { + function SearchValue(): ReactNode { + return React.createElement("p", null, `q:${useSearchParams().get("q") ?? ""}`); + } + const { response, executionContext } = await renderClassifying( + "probe", + React.createElement( + React.Suspense, + { fallback: React.createElement("p", null, "search-fallback") }, + React.createElement(SearchValue), + ), + { isCacheCandidate: true }, + ); + const html = await response.clone().text(); + const envelope = (await ( + await finalizeWorkerCacheabilityResponse(response, executionContext) + ).json()) as Record; + + expect(html).toContain("search-fallback"); + expect(html).not.toContain("secret"); + expect(envelope).toMatchObject({ rendererStatic: true, state: "static-candidate" }); + expect(envelope).not.toHaveProperty("dynamicUsage"); + }); + it("stops waiting for a speculative prerender's SSR once it turns dynamic", async () => { // A boundary that never resolves doesn't hold a render that is already // known to be dynamic. diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 1eac7c76ed..fc8c59bc42 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -71,6 +71,15 @@ import { markFrameworkLinkHeaders, serializeResponseStageLinkProvenance, } from "../packages/vinext/src/server/app-response-header-provenance.js"; +import { + cacheabilityManifestPageState, + cacheabilityManifestRouteKey, + projectCacheabilityManifestForRequestStage, + type CacheabilityManifest, + type CacheabilityManifestRoute, + type CacheabilityRepresentation, +} from "../packages/vinext/src/server/cacheability-manifest.js"; +import { createWorkerCacheabilityAdmissionContext } from "../packages/vinext/src/server/cacheability-request.js"; import { registerFrameworkTracingIntegration } from "../packages/vinext/src/server/tracer.js"; import type { ResolvedFrameworkSpanDescriptor } from "../packages/vinext/src/server/framework-tracer.js"; import { workUnitAsyncStorage } from "../packages/vinext/src/shims/internal/work-unit-async-storage.js"; @@ -153,6 +162,7 @@ function createHandler(overrides: Partial = {}) { return createAppRscHandler({ basePath: "/docs", buildId: overrides.buildId ?? "build-id", + cacheabilityRequestProjection: overrides.cacheabilityRequestProjection, clearRequestContext: overrides.clearRequestContext ?? (() => {}), configHeaders: overrides.configHeaders ?? [ { @@ -981,6 +991,472 @@ describe("createAppRscHandler", () => { }); }); + describe("Workers Cache query-free dispatch", () => { + const aboutRoute: CacheabilityManifestRoute = { + kind: "app-page", + pattern: "/about", + state: "runtime-check", + staticPaths: { html: ["/about"], "rsc-full": ["/about"], "rsc-loading-shell": ["/about"] }, + }; + const productsRoute: CacheabilityManifestRoute = { + kind: "app-page", + pattern: "/products/:id", + pathPrefix: "/products/", + runtimePaths: ["dynamic"], + state: "runtime-check", + staticPaths: { html: ["static"] }, + }; + + function manifest(...routes: CacheabilityManifestRoute[]): CacheabilityManifest { + return { + buildId: "build-id", + routes: Object.fromEntries( + routes.map((route) => [cacheabilityManifestRouteKey(route.kind, route.pattern), route]), + ), + version: 1, + }; + } + + function projection(...routes: CacheabilityManifestRoute[]): string { + return JSON.stringify(manifest(...routes)); + } + + function pathAndSearch(url: string): string { + const parsed = new URL(url); + return `${parsed.pathname}${parsed.search}`; + } + + function createProductsHandler(overrides: Partial = {}) { + const about = createPageRoute({ canUseCanonicalLoadingShell: true }); + const products = createPageRoute({ + isDynamic: true, + params: ["id"], + pattern: "/products/:id", + routeSegments: ["products", "[id]"], + }); + const matchRoute = ( + pathname: string, + ): { params: Record; route: TestRoute } | null => { + if (pathname === "/about") return { params: {}, route: about }; + if (pathname.startsWith("/products/")) { + return { params: { id: pathname.slice("/products/".length) }, route: products }; + } + return null; + }; + return createHandler({ + configHeaders: [], + configRewrites: { + afterFiles: [], + beforeFiles: [{ source: "/promo", destination: "/products/static" }], + fallback: [], + }, + matchRequestRoute: matchRoute, + matchRoute, + ...overrides, + }); + } + + async function navigationRequest(pathAndQuery: string): Promise { + const headers = createRscRequestHeaders({ + routerState: { pathAndSearch: "/source", routeId: "route:/source" }, + }); + return new Request( + new URL(await createRscRequestUrl(pathAndQuery, headers), "https://example.test"), + { headers }, + ); + } + + async function loadingShellRequest(pathAndQuery: string): Promise { + const headers = createRscRequestHeaders({ + prefetchRouterState: { pathAndSearch: "/source", routeId: "route:/source" }, + renderMode: "prefetch-loading-shell", + }); + return new Request( + new URL(await createRscRequestUrl(pathAndQuery, headers), "https://example.test"), + { headers }, + ); + } + + it("drops the user query from shared dispatches of static-candidate App page paths", async () => { + const dispatchResponseStage = vi.fn( + async () => new Response("page"), + ); + const handler = createProductsHandler({ + cacheabilityRequestProjection: projection(aboutRoute), + }); + + for (const init of [ + { headers: { Accept: "text/html" } }, + { headers: { Accept: "text/html" }, method: "HEAD" }, + // A curl-style request has no text/html Accept. Admission maps it to + // html once the route resolves to an App page, and so does the strip. + {}, + ]) { + dispatchResponseStage.mockClear(); + await handler( + new Request("https://example.test/docs/about?tab=latest&utm_source=x", init), + null, + false, + dispatchResponseStage, + ); + + const [request, props, options] = dispatchResponseStage.mock.calls[0]!; + expect(request.url).toBe("https://example.test/docs/about"); + expect(request.method).toBe("GET"); + expect(props).toMatchObject({ + cacheability: { resolvedRoutePathname: "/about" }, + kind: "app-page", + resolvedUrl: "/about", + }); + expect(options).toEqual({ cache: "shared" }); + } + }); + + it("builds the canonical RSC and loading-shell URLs from the query-free dispatch", async () => { + const dispatchResponseStage = vi.fn( + async () => new Response("rsc"), + ); + const handler = createProductsHandler({ + cacheabilityRequestProjection: projection(aboutRoute), + }); + + const navigation = await handler( + await navigationRequest("/docs/about?tab=latest"), + null, + false, + dispatchResponseStage, + ); + let [request, props] = dispatchResponseStage.mock.calls[0]!; + expect(pathAndSearch(request.url)).toBe("/docs/about?_rsc"); + expect(props).toMatchObject({ renderMode: "navigation", resolvedUrl: "/about" }); + // The request stage still describes the routed request, query included. + expect(navigation.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBe( + encodeURIComponent("/about?tab=latest"), + ); + + dispatchResponseStage.mockClear(); + await handler( + await loadingShellRequest("/docs/about?tab=latest"), + null, + false, + dispatchResponseStage, + ); + [request, props] = dispatchResponseStage.mock.calls[0]!; + const shellUrl = new URL(request.url); + expect([...shellUrl.searchParams.keys()]).toEqual([VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM]); + expect(shellUrl.searchParams.get(VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM)).toBe( + await computeRscCacheBustingSearchParam(request.headers), + ); + expect(props).toMatchObject({ renderMode: "prefetch-loading-shell", resolvedUrl: "/about" }); + + dispatchResponseStage.mockClear(); + await handler( + new Request("https://example.test/docs/about.rsc?tab=latest", { + headers: createRscRequestHeaders(), + }), + null, + false, + dispatchResponseStage, + ); + [request, props] = dispatchResponseStage.mock.calls[0]!; + expect(pathAndSearch(request.url)).toBe("/docs/about.rsc?_rsc"); + expect(props).toMatchObject({ resolvedUrl: "/about" }); + }); + + it("judges a rewritten request by its resolved route pathname", async () => { + const dispatchResponseStage = vi.fn( + async () => new Response("page"), + ); + const handler = createProductsHandler({ + cacheabilityRequestProjection: projection(productsRoute), + }); + + for (const path of ["/docs/promo", "/docs/products/static", "/docs/products/dynamic"]) { + await handler( + new Request(`https://example.test${path}?tab=latest`, { + headers: { Accept: "text/html" }, + }), + null, + false, + dispatchResponseStage, + ); + } + + expect( + dispatchResponseStage.mock.calls.map(([request, props]) => [ + pathAndSearch(request.url), + "resolvedUrl" in props ? props.resolvedUrl : null, + ]), + ).toEqual([ + ["/docs/promo", "/products/static"], + ["/docs/products/static", "/products/static"], + ["/docs/products/dynamic?tab=latest", "/products/dynamic?tab=latest"], + ]); + }); + + it("keeps the query where the projection gives no static-candidate state", async () => { + const handlerRoute = createPageRoute({ + __loadPage: undefined, + __loadRouteHandler() {}, + page: null, + pattern: "/route", + routeHandler: { GET: () => new Response("get") }, + routeSegments: ["route"], + }); + const fallbackAbout = createPageRoute({ canUseCanonicalLoadingShell: true }); + const matchRoute = (pathname: string) => { + if (pathname === "/about") return { params: {}, route: fallbackAbout }; + if (pathname === "/route") return { params: {}, route: handlerRoute }; + return null; + }; + const dispatchResponseStage = vi.fn( + async () => new Response("payload"), + ); + const htmlOnly = createProductsHandler({ + cacheabilityRequestProjection: projection({ + ...aboutRoute, + staticPaths: { html: ["/about"] }, + }), + }); + // A fallback-only record answers static-candidate for any representation. + const fallbackOnly = createHandler({ + cacheabilityRequestProjection: projection( + { kind: "app-page", pattern: "/about", state: "static-candidate" }, + { kind: "app-page", pattern: "/route", state: "static-candidate" }, + ), + configHeaders: [], + matchRequestRoute: matchRoute, + matchRoute, + }); + + // Only the HTML version of the path is certified. + await htmlOnly( + await navigationRequest("/docs/about?tab=latest"), + null, + false, + dispatchResponseStage, + ); + // A mounted-slot request has no representation, so admission refuses it. + const mountedHeaders = createRscRequestHeaders({ mountedSlotsHeader: "slot:modal:/" }); + await fallbackOnly( + new Request( + new URL( + await createRscRequestUrl("/docs/about?tab=latest", mountedHeaders), + "https://example.test", + ), + { headers: mountedHeaders }, + ), + null, + false, + dispatchResponseStage, + ); + // Route handlers read the query without tracking it. + await fallbackOnly( + new Request("https://example.test/docs/route?tab=latest"), + null, + false, + dispatchResponseStage, + ); + // The route has no record in the projection. + await createProductsHandler({ cacheabilityRequestProjection: projection(productsRoute) })( + new Request("https://example.test/docs/about?tab=latest", { + headers: { Accept: "text/html" }, + }), + null, + false, + dispatchResponseStage, + ); + + expect( + dispatchResponseStage.mock.calls.map(([request]) => + new URL(request.url).searchParams.get("tab"), + ), + ).toEqual(["latest", "latest", "latest", "latest"]); + }); + + it("keeps the query of a Pages data URL that a root catch-all App page matches", async () => { + // app/[...path]/page.tsx with generateStaticParams returning [] gets a + // fallback-only record, which answers static-candidate for any + // representation. A pages-data request isn't an App page representation. + const catchAll = createPageRoute({ + isDynamic: true, + params: ["path"], + pattern: "/:path+", + routeSegments: ["[...path]"], + }); + const matchRoute = (pathname: string) => ({ + params: { path: pathname.slice(1).split("/") }, + route: catchAll, + }); + const dispatchResponseStage = vi.fn( + async () => new Response("page"), + ); + const handler = createHandler({ + cacheabilityRequestProjection: projection({ + kind: "app-page", + pattern: "/:path+", + state: "static-candidate", + }), + configHeaders: [], + matchRequestRoute: matchRoute, + matchRoute, + }); + + for (const headers of [{}, { Accept: "application/json" }] as Record[]) { + await handler( + new Request("https://example.test/docs/_next/data/build-id/foo.json?q=1", { headers }), + null, + false, + dispatchResponseStage, + ); + } + // The same route's HTML is stripped. + await handler( + new Request("https://example.test/docs/foo?q=1", { headers: { Accept: "text/html" } }), + null, + false, + dispatchResponseStage, + ); + + expect( + dispatchResponseStage.mock.calls.map(([request]) => pathAndSearch(request.url)), + ).toEqual([ + "/docs/_next/data/build-id/foo.json?q=1", + "/docs/_next/data/build-id/foo.json?q=1", + "/docs/foo", + ]); + }); + + it("keeps the full URL under a next.config policy, for bypassed dispatches and without a projection", async () => { + const dispatchResponseStage = vi.fn( + async () => new Response("page"), + ); + const html = (headers: Record = {}) => + new Request("https://example.test/docs/about?tab=latest", { + headers: { Accept: "text/html", ...headers }, + }); + + await createProductsHandler({ + cacheabilityRequestProjection: projection(aboutRoute), + configHeaders: [ + { source: "/about", headers: [{ key: "Cache-Control", value: "public, s-maxage=60" }] }, + ], + })(html(), null, false, dispatchResponseStage); + const handler = createProductsHandler({ + cacheabilityRequestProjection: projection(aboutRoute), + }); + await handler( + html({ Cookie: "__prerender_bypass=test-draft-secret" }), + null, + false, + dispatchResponseStage, + ); + // A request carrying a CSP nonce renders per request. + await handler( + html({ "Content-Security-Policy": "script-src 'nonce-request-nonce'" }), + null, + false, + dispatchResponseStage, + ); + await handler(html(), null, false, dispatchResponseStage, "probe"); + await createProductsHandler()(html(), null, false, dispatchResponseStage); + await createProductsHandler({ + buildId: "other-build", + cacheabilityRequestProjection: projection(aboutRoute), + })(html(), null, false, dispatchResponseStage); + + expect( + dispatchResponseStage.mock.calls.map(([request, props, options]) => [ + pathAndSearch(request.url), + "resolvedUrl" in props ? props.resolvedUrl : null, + options.cache, + ]), + ).toEqual([ + ["/docs/about?tab=latest", "/about?tab=latest", "shared"], + ["/docs/about?tab=latest", "/about?tab=latest", "bypass"], + ["/docs/about?tab=latest", "/about?tab=latest", "bypass"], + ["/docs/about?tab=latest", "/about?tab=latest", "bypass"], + ["/docs/about?tab=latest", "/about?tab=latest", "shared"], + ["/docs/about?tab=latest", "/about?tab=latest", "shared"], + ]); + }); + + it("strips exactly the dispatches completed-response admission gives static-candidate", async () => { + const full = manifest( + { ...aboutRoute, staticPaths: { html: ["/about"], "rsc-full": ["/about"] } }, + productsRoute, + { kind: "app-route", pattern: "/about", state: "static-candidate" }, + ); + const dispatchResponseStage = vi.fn( + async () => new Response("payload"), + ); + const handler = createProductsHandler({ + cacheabilityRequestProjection: JSON.stringify( + projectCacheabilityManifestForRequestStage(full), + ), + }); + const html = (path: string) => + new Request(`https://example.test${path}?tab=latest`, { + headers: { Accept: "text/html" }, + }); + const requests: Array<[string, Request]> = [ + ["html", html("/docs/about")], + ["curl", new Request("https://example.test/docs/about?tab=latest")], + ["rsc", await navigationRequest("/docs/about?tab=latest")], + ["loading shell", await loadingShellRequest("/docs/about?tab=latest")], + ["listed static", html("/docs/products/static")], + ["rewritten", html("/docs/promo")], + ["listed static rsc", await navigationRequest("/docs/products/static?tab=latest")], + ["listed dynamic", new Request("https://example.test/docs/products/dynamic?tab=latest")], + ["unlisted", html("/docs/products/unlisted")], + ]; + + const results: Array<[string, boolean, string | null]> = []; + for (const [label, request] of requests) { + dispatchResponseStage.mockClear(); + await handler(request, null, false, dispatchResponseStage); + const [dispatched, props] = dispatchResponseStage.mock.calls[0]!; + if (props.kind !== "app-page") throw new Error(`${label} did not dispatch a page`); + const admission = Reflect.get( + createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + dispatched, + JSON.stringify(full), + "build-id", + true, + undefined, + props.cacheability.resolvedRoutePathname, + props.cacheability.representation, + ), + CACHEABILITY_REQUEST_STATE, + ).admission as NonNullable; + const admissionState = + admission.policy === "manifest" && admission.routePathname + ? cacheabilityManifestPageState( + admission.manifest as CacheabilityManifest, + { kind: "app-page", pattern: props.routePattern }, + admission.representation as CacheabilityRepresentation, + admission.routePathname, + ) + : null; + const stripped = !new URL(dispatched.url).searchParams.has("tab"); + expect(stripped, label).toBe(admissionState === "static-candidate"); + results.push([label, stripped, admissionState]); + } + expect(results).toEqual([ + ["html", true, "static-candidate"], + ["curl", true, "static-candidate"], + ["rsc", true, "static-candidate"], + ["loading shell", false, "runtime-check"], + ["listed static", true, "static-candidate"], + ["rewritten", true, "static-candidate"], + ["listed static rsc", false, "runtime-check"], + ["listed dynamic", false, "runtime-check"], + ["unlisted", false, null], + ]); + }); + }); + 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({ diff --git a/tests/app-worker-stages.test.ts b/tests/app-worker-stages.test.ts index fae88a1591..9ec22a3d8b 100644 --- a/tests/app-worker-stages.test.ts +++ b/tests/app-worker-stages.test.ts @@ -15,7 +15,9 @@ import { } from "../packages/vinext/src/shims/cdn-cache.js"; import { VINEXT_EXPECTED_WORKER_VERSION_HEADER, + VINEXT_PARAMS_HEADER, VINEXT_PRERENDER_READINESS_HEADER, + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, } from "../packages/vinext/src/server/headers.js"; import { markFrameworkLinkHeaders } from "../packages/vinext/src/server/app-response-header-provenance.js"; import { setFrameworkRequestRoute } from "../packages/vinext/src/server/request-tracing.js"; @@ -213,6 +215,57 @@ describe("App Worker response stage", () => { expect(response.headers.get("x-vinext-app-stage-post-config-link")).toBe("1"); }); + it("drops the request-scoped headers of a shared App page RSC response", async () => { + const pageStage = { + ...notFoundStage, + bypassInterceptionContextCache: false, + cachePathname: "/missing", + canUseCanonicalLoadingShell: false, + interceptionContext: null, + interceptionId: null, + kind: "app-page" as const, + matchKind: "request" as const, + params: {}, + routePattern: "/missing", + routePathname: "/missing", + } satisfies AppWorkerResponseStageProps; + const rendered = () => + new Response("rsc", { + headers: { + [VINEXT_PARAMS_HEADER]: encodeURIComponent("{}"), + [VINEXT_RENDERED_PATH_AND_SEARCH_HEADER]: encodeURIComponent("/missing?q=1"), + }, + }); + const render = (props: AppWorkerResponseStageProps, cache: "bypass" | "shared") => { + stages.renderResponse.mockImplementationOnce(async () => rendered()); + return handleResponseStage( + new Request("https://example.com/missing?q=1"), + undefined, + undefined, + props, + async () => new Response("request-stage"), + { cache }, + ); + }; + + const shared = await render({ ...pageStage, isRscRequest: true }, "shared"); + expect(shared.headers.has(VINEXT_PARAMS_HEADER)).toBe(false); + expect(shared.headers.has(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBe(false); + await expect(shared.text()).resolves.toBe("rsc"); + + // Bypassed responses reach no shared cache, and HTML responses and + // not-found renders are not recomposed by the request stage. + for (const response of [ + await render({ ...pageStage, isRscRequest: true }, "bypass"), + await render(pageStage, "shared"), + await render({ ...notFoundStage, isRscRequest: true }, "shared"), + ]) { + expect(response.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBe( + encodeURIComponent("/missing?q=1"), + ); + } + }); + it("rejects matched-stage payloads missing interception cache-safety fields", () => { const matchedStage = { ...notFoundStage, diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index e84de3bac2..bee1865959 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -4157,6 +4157,12 @@ describe("createMultiStageChunkFileNames", () => { 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: "_virtual_vinext-request-stage" } as never)).toBe( + "_virtual_vinext-request-stage-[hash].js", + ); + expect(fileName({ name: "vinext-stage-runtime~virtual_vinext-request-stage" } as never)).toBe( + "vinext-stage-runtime~virtual_vinext-request-stage-[hash].js", + ); expect(fileName({ name: "request-runtime" } as never)).toBe( "_next/static/request-runtime-[hash].js", ); diff --git a/tests/cache-adapters-build.test.ts b/tests/cache-adapters-build.test.ts index 84204d3285..b68e6a8561 100644 --- a/tests/cache-adapters-build.test.ts +++ b/tests/cache-adapters-build.test.ts @@ -16,6 +16,8 @@ import { pathToFileURL } from "node:url"; import { createBuilder } from "vite"; import { afterEach, describe, expect, it } from "vite-plus/test"; import { workersCacheCdnAdapter } from "../packages/cloudflare/src/cache/workers-cache-cdn-adapter.js"; +import { writeCacheabilityManifestArtifact } from "../packages/cloudflare/src/cacheability-artifact.js"; +import { cacheabilityManifestRouteKey } from "../packages/vinext/src/server/cacheability-manifest.js"; import vinext from "../packages/vinext/src/index.js"; const tmpDirs: string[] = []; @@ -538,5 +540,38 @@ export default { expect(readStaticEntryClosure(root, "virtual:vinext-response-stage")).toContain( LOCAL_ADAPTER_MARKER, ); + // The request stage reads the deploy's manifest projection, never the full + // manifest. The build emits a null placeholder that `vinext deploy` fills. + expect(readStaticEntryClosure(root, "virtual:vinext-request-stage")).toContain( + "__vinext_cacheability_request_projection.js", + ); + expect(readStaticEntryClosure(root, "virtual:vinext-request-stage")).not.toContain( + "__vinext_cacheability_manifest.js", + ); + expect( + fs.readFileSync( + path.join(root, "dist/server/__vinext_cacheability_request_projection.js"), + "utf8", + ), + ).toBe("export default null;\n"); + // The deploy only fills the projection that the Worker graph statically + // imports, so the emitted graph must pass that check. + writeCacheabilityManifestArtifact(root, "dist/server/wrangler.json", { + buildId: "build-a", + routes: { + [cacheabilityManifestRouteKey("app-page", "/about")]: { + kind: "app-page", + pattern: "/about", + state: "static-candidate", + }, + }, + version: 1, + }); + expect( + fs.readFileSync( + path.join(root, "dist/server/__vinext_cacheability_request_projection.js"), + "utf8", + ), + ).toContain("/about"); }, 60_000); }); diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index 79f00a899b..65bb3548be 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -1949,6 +1949,50 @@ describe("cacheability probe finalization", () => { }); }); + it("reports dynamic usage and an applied next.config policy", async () => { + const envelope = async (state: Omit) => + (await finalizeWorkerCacheabilityResponse( + new Response("body", { headers: { "Cache-Control": "s-maxage=60" } }), + contextWith({ captureDeadlineAt: Date.now() + 1_000, mode: "probe", ...state }), + ).then((response) => response.json())) as Record; + const route = { kind: "app-page" as const, pattern: "/posts/:slug" }; + + const staticEnvelope = await envelope({ + outcome: { cacheable: true, cacheControl: "s-maxage=60" }, + route, + }); + expect(staticEnvelope).toMatchObject({ rendererStatic: true, state: "static-candidate" }); + expect(staticEnvelope).not.toHaveProperty("dynamicUsage"); + expect(staticEnvelope).not.toHaveProperty("explicitConfigCachePolicy"); + + await expect( + envelope({ + explicitConfigCachePolicy: true, + frameworkResponseCachePolicy: new Headers({ "Cache-Control": "no-store" }), + outcome: { cacheable: false, dynamicUsage: true }, + route, + }), + ).resolves.toMatchObject({ + dynamicUsage: true, + explicitConfigCachePolicy: true, + rendererStatic: false, + state: "static-candidate", + }); + + const outcome = { + cacheable: false, + dynamicUsage: true, + reason: '"use cache: private" requires request-time execution', + }; + await expect( + envelope({ outcome, probeBailout: { kind: "private-cache", outcome }, route }), + ).resolves.toMatchObject({ dynamicUsage: true, state: "dynamic" }); + + await expect( + envelope({ patternDynamicReason: 'dynamic = "force-dynamic"', route }), + ).resolves.toMatchObject({ dynamicUsage: true, scope: "pattern", state: "dynamic" }); + }); + it("does not let ordinary dynamic usage hide a route 500", async () => { const state: RouteCacheabilityState = { captureDeadlineAt: Date.now() + 1_000, diff --git a/tests/cacheability-manifest.test.ts b/tests/cacheability-manifest.test.ts index 7dfa2ef971..8dc13b7185 100644 --- a/tests/cacheability-manifest.test.ts +++ b/tests/cacheability-manifest.test.ts @@ -163,6 +163,40 @@ describe("cacheability manifest", () => { ).toBeNull(); }); + it("certifies one path under each representation of its static render", () => { + const promotedRoute: CacheabilityManifestRoute = { + ...route, + pathPrefix: "/products/", + runtimePaths: ["dynamic"], + state: "runtime-check", + staticPaths: { html: ["static"], "rsc-full": ["static"], "rsc-loading-shell": ["static"] }, + }; + const manifest = parseCacheabilityManifest( + JSON.stringify({ buildId: "build-a", routes: { [key]: promotedRoute }, version: 1 }), + "build-a", + ); + expect(manifest).not.toBeNull(); + const parsed = findCacheabilityManifestRoute(manifest!, route.kind, route.pattern)!; + for (const representation of ["html", "rsc-full", "rsc-loading-shell"] as const) { + expect(cacheabilityManifestRouteState(parsed, "/products/static", representation)).toBe( + "static-candidate", + ); + } + + // A path is still listed once per list, and never both runtime-checked and static. + for (const malformedRoute of [ + { ...promotedRoute, staticPaths: { html: ["static"], "rsc-full": ["dynamic"] } }, + { ...promotedRoute, staticPaths: { html: ["static"], "rsc-full": ["static", "static"] } }, + ]) { + expect( + parseCacheabilityManifest( + JSON.stringify({ buildId: "build-a", routes: { [key]: malformedRoute }, version: 1 }), + "build-a", + ), + ).toBeNull(); + } + }); + it("maps Pages data and HTML requests to one concrete route pathname", () => { expect(cacheabilityRoutePathname("/docs/products/one?currency=gbp", "html")).toBe( "/docs/products/one", @@ -233,6 +267,49 @@ describe("cacheability manifest", () => { expect(cacheabilityManifestRouteState(shellRoute, "/posts/one", "html")).toBeNull(); }); + it("authorizes a runtime representation only at its listed paths", () => { + const mixedRoute: CacheabilityManifestRoute = { + kind: "app-page", + pathPrefix: "/posts/", + pattern: "/posts/:slug", + runtimeRepresentationPaths: { "rsc-loading-shell": ["two"] }, + state: "runtime-check", + staticPaths: { html: ["one"], "rsc-full": ["one"], "rsc-loading-shell": ["one"] }, + }; + const mixedKey = cacheabilityManifestRouteKey(mixedRoute.kind, mixedRoute.pattern); + const parse = (value: CacheabilityManifestRoute) => + parseCacheabilityManifest( + JSON.stringify({ buildId: "build-a", routes: { [mixedKey]: value }, version: 1 }), + "build-a", + ); + expect(parse(mixedRoute)?.routes[mixedKey]).toEqual(mixedRoute); + expect(cacheabilityManifestRouteState(mixedRoute, "/posts/two", "rsc-loading-shell")).toBe( + "runtime-check", + ); + expect(cacheabilityManifestRouteState(mixedRoute, "/posts/two", "rsc-full")).toBeNull(); + expect(cacheabilityManifestRouteState(mixedRoute, "/posts/two", "html")).toBeNull(); + expect(cacheabilityManifestRouteState(mixedRoute, "/posts/one", "rsc-loading-shell")).toBe( + "static-candidate", + ); + expect( + cacheabilityManifestRouteState(mixedRoute, "/posts/three", "rsc-loading-shell"), + ).toBeNull(); + // A path is never both representation-only and runtime-checked or static. + expect(parse({ ...mixedRoute, runtimePaths: ["two"] })).toBeNull(); + expect( + parse({ ...mixedRoute, runtimeRepresentationPaths: { "rsc-loading-shell": ["one"] } }), + ).toBeNull(); + expect( + parse({ + kind: "app-page", + pattern: "/posts/:slug", + runtimeRepresentation: "rsc-loading-shell", + runtimeRepresentationPaths: { "rsc-loading-shell": ["/posts/two"] }, + state: "runtime-check", + }), + ).toBeNull(); + }); + it("keeps HTML query variants and RSC representations distinct", () => { expect( cacheabilityRequestIdentity( diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index da872f804e..474fc73552 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -5,9 +5,11 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { probeStagedWorkerCacheability } from "../packages/cloudflare/src/cacheability-probe.js"; import { VINEXT_CDN_BUILD_ID_HEADER } from "../packages/cloudflare/src/cache/cdn-build-id.js"; +import type { CdnWarmTarget } from "../packages/cloudflare/src/cdn-warm.js"; import { cacheabilityManifestRouteState, cacheabilityManifestRouteKey, + parseCacheabilityManifest, type CacheabilityManifestRoute, } from "../packages/vinext/src/server/cacheability-manifest.js"; import { @@ -173,7 +175,7 @@ describe("staged Worker cacheability probes", () => { unknownState: "static-candidate", pattern: "/cached/:slug", state: "runtime-check", - staticPaths: { html: ["/cached/intro"] }, + staticPaths: { html: ["/cached/intro"], "rsc-full": ["/cached/intro"] }, }); expect(result.cacheableTargets).toEqual([target]); }); @@ -244,6 +246,330 @@ describe("staged Worker cacheability probes", () => { expect(result.failures).toEqual(["/broken: probe returned HTTP 500"]); }); + it("drops an unlisted path whose render fails, but fails the deploy for a listed one", async () => { + const root = createProbeRoot(); + const route = optimizableRoute("/posts/:slug"); + const pickedRoute = { + ...route, + cacheabilityProbe: { ...route.cacheabilityProbe, unlisted: true }, + }; + const probe = (brokenPathname: string) => + probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + return pathname === brokenPathname + ? Response.json({ + kind: "app-page", + pattern: route.pattern, + reason: "route returned HTTP 500", + state: "probe-failed", + status: 500, + version: 1, + }) + : staticProbeResponse(route.pattern); + }, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [ + { ...target("/posts/listed"), route }, + { ...target("/posts/picked"), route: pickedRoute }, + ], + }); + + const dropped = await probe("/posts/picked"); + expect(dropped).toMatchObject({ failures: [], skipped: 0 }); + expect(dropped.cacheableTargets.map((warm) => warm.pathname)).toEqual(["/posts/listed"]); + const routeRecord = + dropped.manifest.routes[cacheabilityManifestRouteKey("app-page", route.pattern)]; + expect(routeRecord?.runtimePaths).toBeUndefined(); + expect(cacheabilityManifestRouteState(routeRecord!, "/posts/picked", "html")).not.toBe( + "runtime-check", + ); + + const failed = await probe("/posts/listed"); + expect(failed.failures).toEqual(["/posts/listed: route returned HTTP 500"]); + }); + + it("keeps discovery's unlisted state for a literal App page route's own path", async () => { + // A literal force-dynamic or edge-runtime page isn't build-rendered, so + // discovery marks its own path unlisted. + const literalRoute = (unlisted: boolean) => ({ + cacheabilityProbe: { canPrunePattern: true, ...(unlisted ? { unlisted: true } : {}) }, + kind: "app-page" as const, + pattern: "/dynamic", + }); + const failed500 = (pattern: string, routePathname?: string) => + Response.json({ + kind: "app-page", + pattern, + reason: "route returned HTTP 500", + ...(routePathname ? { routePathname } : {}), + state: "probe-failed", + status: 500, + version: 1, + }); + const probe = (targets: CdnWarmTarget[], fetchImpl: typeof fetch) => + probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 1, + fetchImpl, + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets, + }); + + const unlisted = await probe([{ ...target("/dynamic"), route: literalRoute(true) }], async () => + failed500("/dynamic"), + ); + expect(unlisted).toMatchObject({ cacheableTargets: [], failures: [] }); + expect(unlisted.manifest.routes).toEqual({}); + + const listed = await probe([{ ...target("/dynamic"), route: literalRoute(false) }], async () => + failed500("/dynamic"), + ); + expect(listed.failures).toEqual(["/dynamic: route returned HTTP 500"]); + + // A failure the request stage moves to the literal route is judged by the + // same discovery fact, whichever probe completes first. + const alias = { + ...target("/rewrite-me"), + route: { + cacheabilityProbe: { canPrunePattern: true, routeMayResolve: true, unlisted: true }, + kind: "app-page" as const, + pattern: "/rewrite-me", + }, + }; + const destination = { ...target("/dynamic"), route: literalRoute(true) }; + for (const targets of [ + [alias, destination], + [destination, alias], + ]) { + const moved = await probe(targets, async () => failed500("/dynamic", "/dynamic")); + expect(moved.failures).toEqual([]); + } + }); + + it("judges an unlisted render failure under the route the request stage resolved", async () => { + const sourceRoute = { + cacheabilityProbe: { canPrunePattern: true, routeMayResolve: true, unlisted: true }, + kind: "app-page" as const, + pattern: "/rewrite-me/:slug", + }; + const probe = ( + resolved: { kind: string; pattern: string; routePathname: string }, + extraTargets: CdnWarmTarget[] = [], + ) => + probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + return pathname === "/rewrite-me/a" + ? Response.json({ + ...resolved, + reason: "route returned HTTP 500", + state: "probe-failed", + status: 500, + version: 1, + }) + : staticProbeResponse("/posts/:slug"); + }, + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets: [{ ...target("/rewrite-me/a"), route: sourceRoute }, ...extraTargets], + }); + const failure = ["/rewrite-me/a: route returned HTTP 500"]; + + // Rewritten to a Pages page or Route Handler, the failure fails the deploy. + for (const kind of ["pages-page", "app-route"]) { + const result = await probe({ kind, pattern: "/legacy/:slug", routePathname: "/legacy/a" }); + expect(result.failures).toEqual(failure); + } + // Rewritten to a path the destination App page route lists, it does too. + const listed = await probe( + { kind: "app-page", pattern: "/posts/:slug", routePathname: "/posts/a" }, + [{ ...target("/posts/a"), route: optimizableRoute("/posts/:slug") }], + ); + expect(listed.failures).toEqual(failure); + // Rewritten to a path the destination doesn't list, it's dropped. + const unlisted = await probe( + { kind: "app-page", pattern: "/posts/:slug", routePathname: "/posts/b" }, + [{ ...target("/posts/a"), route: optimizableRoute("/posts/:slug") }], + ); + expect(unlisted.failures).toEqual([]); + }); + + it("judges a failure moved to another pathname of its own route by that pathname's listing", async () => { + // The request stage rewrites the listed /posts/a to /posts/b, which the same + // route renders. Only /posts/b's own listing says whether Next.js's build + // rendered it. + const movedRoute = { + cacheabilityProbe: { canPrunePattern: true, routeMayResolve: true }, + kind: "app-page" as const, + pattern: "/posts/:slug", + }; + const probe = (extraTargets: CdnWarmTarget[]) => + probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 1, + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + return pathname === "/posts/a" + ? Response.json({ + kind: "app-page", + pattern: "/posts/:slug", + reason: "route returned HTTP 500", + routePathname: "/posts/b", + state: "probe-failed", + status: 500, + version: 1, + }) + : staticProbeResponse("/posts/:slug"); + }, + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets: [{ ...target("/posts/a"), route: movedRoute }, ...extraTargets], + }); + + expect((await probe([])).failures).toEqual([]); + const unlistedDestination = { + ...movedRoute, + cacheabilityProbe: { canPrunePattern: true, unlisted: true }, + }; + expect((await probe([{ ...target("/posts/b"), route: unlistedDestination }])).failures).toEqual( + [], + ); + expect( + (await probe([{ ...target("/posts/b"), route: optimizableRoute("/posts/:slug") }])).failures, + ).toEqual(["/posts/a: route returned HTTP 500"]); + }); + + it("judges a moved failure against the destination's listing whichever probe completes first", async () => { + const sourceRoute = { + cacheabilityProbe: { canPrunePattern: true, routeMayResolve: true, unlisted: true }, + kind: "app-page" as const, + pattern: "/rewrite-me/:slug", + }; + // The listed /posts/a is itself moved elsewhere by the request stage, which + // takes its group off the /posts/:slug pattern once its probe completes. + const listedRoute = { + cacheabilityProbe: { canPrunePattern: true, routeMayResolve: true }, + kind: "app-page" as const, + pattern: "/posts/:slug", + }; + const probe = (targets: CdnWarmTarget[]) => + probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 1, + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + return pathname === "/rewrite-me/a" + ? Response.json({ + kind: "app-page", + pattern: "/posts/:slug", + reason: "route returned HTTP 500", + routePathname: "/posts/a", + state: "probe-failed", + status: 500, + version: 1, + }) + : Response.json({ + kind: "app-page", + pattern: "/other/:slug", + rendererStatic: true, + routePathname: "/other/a", + state: "static-candidate", + status: 200, + version: 1, + }); + }, + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets, + }); + const alias = { ...target("/rewrite-me/a"), route: sourceRoute }; + const listed = { ...target("/posts/a"), route: listedRoute }; + const failure = ["/rewrite-me/a: route returned HTTP 500"]; + + expect((await probe([alias, listed])).failures).toEqual(failure); + expect((await probe([listed, alias])).failures).toEqual(failure); + }); + + it("keeps paired representations at the original route when a moved unlisted failure is dropped", async () => { + const { html, route, rsc } = pairedRouteTargets(); + // Header-sensitive routing sends only the HTML request to an unlisted, + // failing App page; the RSC request stays on the static source route. + const fetchImpl = vi.fn(async () => + Response.json({ + kind: "app-page", + pattern: "/posts/:slug", + reason: "route returned HTTP 500", + routePathname: "/posts/unlisted", + state: "probe-failed", + status: 500, + version: 1, + }), + ); + + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl, + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets: [rsc, html], + }); + + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ + cacheableTargets: [rsc], + failures: [], + speculativeTargets: [rsc], + }); + expect(Object.keys(result.manifest.routes)).toEqual([ + cacheabilityManifestRouteKey(route.kind, route.pattern), + ]); + const sourceManifestRoute = + result.manifest.routes[cacheabilityManifestRouteKey(route.kind, route.pattern)]; + expect(cacheabilityManifestRouteState(sourceManifestRoute!, "/source", "rsc-full")).toBe( + "runtime-check", + ); + }); + + it("fails the deploy for an unlisted Pages or Route Handler path whose render fails", async () => { + for (const kind of ["pages-page", "app-route"] as const) { + const route = { ...optimizableRoute("/posts/:slug"), kind }; + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async () => + Response.json({ + kind, + pattern: route.pattern, + reason: "route returned HTTP 500", + state: "probe-failed", + status: 500, + version: 1, + }), + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets: [ + { + ...target("/posts/picked"), + route: { ...route, cacheabilityProbe: { ...route.cacheabilityProbe, unlisted: true } }, + }, + ], + }); + + expect(result.failures).toEqual(["/posts/picked: route returned HTTP 500"]); + } + }); + it("retries a malformed successful probe envelope", async () => { const root = createProbeRoot(); const fetchImpl = vi @@ -764,7 +1090,7 @@ describe("staged Worker cacheability probes", () => { unknownState: "static-candidate", pattern: route.pattern, state: "runtime-check", - staticPaths: { html: ["/posts/one"] }, + staticPaths: { html: ["/posts/one"], "rsc-full": ["/posts/one"] }, }), ]); }); @@ -1138,7 +1464,7 @@ describe("staged Worker cacheability probes", () => { expect.objectContaining({ pattern: route.pattern, state: "runtime-check", - staticRepresentation: "html", + staticPaths: { html: ["/missing"], "rsc-full": ["/missing"] }, }), ]); }); @@ -1306,11 +1632,446 @@ describe("staged Worker cacheability probes", () => { pattern: route.pattern, runtimePaths: ["/posts/conditionally-dynamic"], state: "runtime-check", - staticPaths: { html: ["/posts/static"] }, + staticPaths: { html: ["/posts/static"], "rsc-full": ["/posts/static"] }, }), ]); }); + describe("App page classification per route", () => { + const listedRoute = optimizableRoute("/posts/:slug"); + const pickedRoute = { + ...listedRoute, + cacheabilityProbe: { ...listedRoute.cacheabilityProbe, unlisted: true }, + }; + const dynamicApi = { + dynamicUsage: true, + rendererStatic: false, + scope: "identity", + state: "dynamic", + }; + const veto = { rendererStatic: false, scope: "identity", state: "dynamic" }; + const configOnly = { + dynamicUsage: true, + explicitConfigCachePolicy: true, + rendererStatic: false, + }; + + const pageTargets = (pathname: string, route: typeof listedRoute) => [ + { ...target(pathname), route }, + { + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-full" as const, + label: `${pathname} (RSC full)`, + pathname: `${pathname}?_rsc`, + route, + sourcePathname: pathname, + }, + ]; + + const probe = ( + targets: readonly CdnWarmTarget[], + fieldsByPathname: Record>, + options: Partial[0]> = {}, + ) => + probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + const route = targets.find( + (candidate) => candidate.pathname.split("?")[0] === pathname, + )!.route!; + return Response.json({ + kind: route.kind, + pattern: route.pattern, + rendererStatic: true, + state: "static-candidate", + status: 200, + version: 1, + ...fieldsByPathname[pathname], + }); + }, + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets, + ...options, + }); + + const warmed = (result: Awaited>) => + result.cacheableTargets.map((warm) => `${warm.kind} ${warm.sourcePathname}`).sort(); + + it("gives unknown paths on-demand ISR when no listed path used a dynamic API", async () => { + const result = await probe( + [ + ...pageTargets("/posts/a", listedRoute), + ...pageTargets("/posts/vetoed", listedRoute), + ...pageTargets("/posts/picked-static", pickedRoute), + ...pageTargets("/posts/picked-dynamic", pickedRoute), + ...pageTargets("/posts/picked-vetoed", pickedRoute), + ], + { + "/posts/picked-dynamic": dynamicApi, + "/posts/picked-vetoed": veto, + "/posts/vetoed": veto, + }, + ); + + expect(result.failures).toEqual([]); + const route = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]!; + const state = (pathname: string) => cacheabilityManifestRouteState(route, pathname, "html"); + expect(state("/posts/a")).toBe("static-candidate"); + expect(state("/posts/picked-static")).toBe("static-candidate"); + // Left out of both lists, so it gets the static-to-dynamic 500, as in Next.js. + expect(state("/posts/picked-dynamic")).toBe("static-candidate"); + expect(route.runtimePaths?.map((token) => `${route.pathPrefix ?? ""}${token}`)).toEqual([ + "/posts/picked-vetoed", + "/posts/vetoed", + ]); + expect(state("/posts/unprobed")).toBe("static-candidate"); + expect(warmed(result)).toEqual([ + "html /posts/a", + "html /posts/picked-static", + "rsc-full /posts/a", + "rsc-full /posts/picked-static", + "rsc-full /posts/picked-vetoed", + "rsc-full /posts/vetoed", + ]); + }); + + it("never admits dynamic listed paths or the unlisted paths of their route", async () => { + const result = await probe( + [ + ...pageTargets("/posts/a", listedRoute), + ...pageTargets("/posts/dynamic", listedRoute), + ...pageTargets("/posts/vetoed", listedRoute), + ...pageTargets("/posts/picked-static", pickedRoute), + ...pageTargets("/posts/picked-vetoed", pickedRoute), + ...pageTargets("/posts/picked-config", pickedRoute), + ], + { + "/posts/dynamic": dynamicApi, + "/posts/picked-config": configOnly, + "/posts/picked-vetoed": veto, + "/posts/vetoed": veto, + }, + ); + + expect(result.failures).toEqual([]); + const route = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]!; + const state = (pathname: string) => cacheabilityManifestRouteState(route, pathname, "html"); + expect(state("/posts/a")).toBe("static-candidate"); + expect(state("/posts/dynamic")).toBeNull(); + expect(cacheabilityManifestRouteState(route, "/posts/dynamic", "rsc-full")).toBeNull(); + expect(state("/posts/vetoed")).toBe("runtime-check"); + expect(state("/posts/picked-static")).toBeNull(); + expect(state("/posts/picked-vetoed")).toBeNull(); + // Cacheable only through a next.config policy: runtime-checked from any source. + expect(state("/posts/picked-config")).toBe("runtime-check"); + expect(state("/posts/unprobed")).toBeNull(); + expect(warmed(result)).toEqual([ + "html /posts/a", + "html /posts/picked-config", + "rsc-full /posts/a", + "rsc-full /posts/picked-config", + "rsc-full /posts/vetoed", + ]); + }); + + it("counts a config-policy listed path toward on-demand ISR by its dynamic usage", async () => { + for (const [config, onDemand] of [ + [configOnly, false], + [{ ...configOnly, dynamicUsage: undefined }, true], + ] as const) { + const result = await probe( + [ + ...pageTargets("/posts/a", listedRoute), + ...pageTargets("/posts/config", listedRoute), + ...pageTargets("/posts/picked", pickedRoute), + ], + { "/posts/config": config }, + ); + + const route = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]!; + const state = (pathname: string) => cacheabilityManifestRouteState(route, pathname, "html"); + expect(state("/posts/config")).toBe("runtime-check"); + expect(state("/posts/picked")).toBe(onDemand ? "static-candidate" : null); + expect(state("/posts/unprobed")).toBe(onDemand ? "static-candidate" : null); + } + }); + + it("gives no entry to routes whose paths all used a dynamic API", async () => { + const aboutRoute = optimizableRoute("/about"); + const fallbackRoute = { + ...optimizableRoute("/fallback/:id"), + cacheabilityProbe: { + canPrunePattern: true, + unlisted: true, + }, + }; + const result = await probe( + [ + ...pageTargets("/posts/dynamic", listedRoute), + ...pageTargets("/about", aboutRoute), + ...pageTargets("/fallback/broken", fallbackRoute), + ], + { + "/about": dynamicApi, + "/fallback/broken": { + reason: "route returned HTTP 500", + rendererStatic: undefined, + state: "probe-failed", + status: 500, + }, + "/posts/dynamic": dynamicApi, + }, + { fallbackRoutePatterns: [{ kind: "app-page", pattern: "/fallback/:id" }] }, + ); + + expect(result.failures).toEqual([]); + expect(result.cacheableTargets).toEqual([]); + // The fallback route keeps its fallback-only entry once its probed path drops out. + expect(result.manifest.routes).toEqual({ + [cacheabilityManifestRouteKey("app-page", "/fallback/:id")]: { + kind: "app-page", + pattern: "/fallback/:id", + state: "static-candidate", + }, + }); + }); + + it("OR-merges dynamic usage across probes of one path", async () => { + const source = { + ...target("/rewrite-me"), + route: { + cacheabilityProbe: { canPrunePattern: true, routeMayResolve: true }, + kind: "app-page" as const, + pattern: "/rewrite-me", + }, + }; + for (const delayedPathname of ["/rewrite-me", "/posts/a"]) { + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 2, + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + if (pathname === delayedPathname) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return Response.json({ + kind: "app-page", + pattern: "/posts/:slug", + routePathname: "/posts/a", + status: 200, + version: 1, + ...(pathname === "/rewrite-me" ? dynamicApi : veto), + }); + }, + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets: [source, { ...target("/posts/a"), route: listedRoute }], + }); + + expect(result.failures).toEqual([]); + expect(result.cacheableTargets).toEqual([]); + expect( + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")], + ).toBeUndefined(); + } + }); + + it("gives no entry to a route whose only generateStaticParams is above its last dynamic segment, even when discovery lists its paths", async () => { + // app/[category]/page.tsx lists `news` for app/[category]/details too. + const detailsRoute = optimizableRoute("/:category/details"); + const unlistedDetailsRoute = { + ...detailsRoute, + cacheabilityProbe: { ...detailsRoute.cacheabilityProbe, unlisted: true }, + }; + const notStaticallyGenerated = { + reason: "route is not statically generated", + rendererStatic: false, + state: "dynamic", + }; + const cases: [typeof unlistedDetailsRoute | typeof detailsRoute, Record][] = + [ + // The runtime reports the whole pattern dynamic. + [detailsRoute, { ...notStaticallyGenerated, dynamicUsage: true, scope: "pattern" }], + // Discovery marks the path unlisted. + [unlistedDetailsRoute, { ...notStaticallyGenerated, scope: "identity" }], + ]; + for (const [route, fields] of cases) { + const result = await probe([...pageTargets("/news/details", route)], { + "/news/details": fields, + }); + + expect(result.failures).toEqual([]); + expect(result.manifest.routes).toEqual({}); + expect(result.cacheableTargets).toEqual([]); + } + }); + + it("gives no entry to a route without generateStaticParams whose only paths are traffic-picked", async () => { + const itemsRoute = optimizableRoute("/items/:id"); + const pickedItemsRoute = { + ...itemsRoute, + cacheabilityProbe: { ...itemsRoute.cacheabilityProbe, unlisted: true }, + }; + const result = await probe( + [ + ...pageTargets("/items/a", pickedItemsRoute), + ...pageTargets("/items/b", pickedItemsRoute), + ], + {}, + ); + + expect(result.failures).toEqual([]); + expect(result.manifest.routes).toEqual({}); + expect(result.cacheableTargets).toEqual([]); + }); + + it("doesn't treat a static-candidate result without rendererStatic as a config-policy path", async () => { + // The listed path used a dynamic API, so the route has no on-demand ISR. + const targets = [ + ...pageTargets("/posts/dynamic", listedRoute), + ...pageTargets("/posts/picked", pickedRoute), + ]; + const state = async (fields: Record) => { + const result = await probe(targets, { + "/posts/dynamic": dynamicApi, + "/posts/picked": fields, + }); + const route = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]; + return route ? cacheabilityManifestRouteState(route, "/posts/picked", "html") : null; + }; + + expect(await state({ rendererStatic: false, state: "static-candidate" })).toBeNull(); + expect(await state(configOnly)).toBe("runtime-check"); + }); + + it("counts a listed path that middleware moves toward the destination's listed set only", async () => { + const movedRoute = { + ...listedRoute, + cacheabilityProbe: { ...listedRoute.cacheabilityProbe, routeMayResolve: true }, + }; + const otherRoute = optimizableRoute("/other/:id"); + const moved = { pattern: "/other/:id", routePathname: "/other/x" }; + for (const destinationListsPath of [false, true]) { + const result = await probe( + [ + ...pageTargets("/posts/moved", movedRoute), + ...pageTargets("/posts/picked", pickedRoute), + ...(destinationListsPath ? pageTargets("/other/x", otherRoute) : []), + ], + { "/posts/moved": moved }, + ); + + expect(result.failures).toEqual([]); + // The moved path never gives its origin route on-demand ISR. + const origin = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]; + expect(origin?.allowUnknown).toBeUndefined(); + for (const pathname of ["/posts/picked", "/posts/unprobed"]) { + expect( + origin ? cacheabilityManifestRouteState(origin, pathname, "html") : null, + ).toBeNull(); + } + const other = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/other/:id")]; + if (destinationListsPath) { + expect(cacheabilityManifestRouteState(other!, "/other/x", "html")).toBe( + "static-candidate", + ); + expect(cacheabilityManifestRouteState(other!, "/other/unprobed", "html")).toBe( + "static-candidate", + ); + } else { + expect(other).toBeUndefined(); + } + } + }); + + it("certifies the full RSC representation of a static HTML render", async () => { + const aboutRoute = optimizableRoute("/about"); + const rscOnly = pageTargets("/posts/rsc-only", listedRoute)[1]!; + const loadingShell = (pathname: string, route: typeof listedRoute) => ({ + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-loading-shell" as const, + label: `${pathname} (RSC loading shell)`, + pathname: `${pathname}?_rsc=loading`, + route, + sourcePathname: pathname, + }); + const postShell = loadingShell("/posts/a", listedRoute); + const aboutShell = loadingShell("/about", aboutRoute); + const result = await probe( + [ + ...pageTargets("/posts/a", listedRoute), + postShell, + rscOnly, + ...pageTargets("/about", aboutRoute), + aboutShell, + ], + {}, + ); + + expect(result.failures).toEqual([]); + const posts = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]!; + for (const representation of ["html", "rsc-full"] as const) { + expect(cacheabilityManifestRouteState(posts, "/posts/a", representation)).toBe( + "static-candidate", + ); + } + // Probed only through RSC, so only its RSC render is certified. + expect(cacheabilityManifestRouteState(posts, "/posts/rsc-only", "rsc-full")).toBe( + "static-candidate", + ); + + const about = result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/about")]!; + expect(about.staticRepresentation).toBeUndefined(); + expect(cacheabilityManifestRouteState(about, "/about", "html")).toBe("static-candidate"); + expect(cacheabilityManifestRouteState(about, "/about", "rsc-full")).toBe("static-candidate"); + + // A static page's render may never reach its loading boundary, so the + // HTML probe proves nothing about the loading shell: its own completed + // render decides admission, and a dynamic API there isn't a 500. + for (const [entry, pathname] of [ + [posts, "/posts/a"], + [about, "/about"], + ] as const) { + expect(cacheabilityManifestRouteState(entry, pathname, "rsc-loading-shell")).toBe( + "runtime-check", + ); + } + expect(posts.staticPaths?.["rsc-loading-shell"]).toBeUndefined(); + expect(about.staticPaths?.["rsc-loading-shell"]).toBeUndefined(); + expect(result.speculativeTargets).toEqual(expect.arrayContaining([postShell, aboutShell])); + expect( + parseCacheabilityManifest(JSON.stringify(result.manifest), "application-build"), + ).toEqual(result.manifest); + }); + + it("keeps Pages Router classification unchanged", async () => { + const pagesRoute = { ...listedRoute, kind: "pages-page" as const }; + const result = await probe( + [ + { ...target("/posts/a"), route: pagesRoute }, + { ...target("/posts/dynamic"), route: pagesRoute }, + ], + { "/posts/dynamic": dynamicApi }, + ); + + const route = + result.manifest.routes[cacheabilityManifestRouteKey("pages-page", "/posts/:slug")]!; + expect(cacheabilityManifestRouteState(route, "/posts/dynamic", "html")).toBe("runtime-check"); + expect(cacheabilityManifestRouteState(route, "/posts/a", "html")).toBe("static-candidate"); + }); + }); + it("records a rewrite source under the concrete route resolved by the request stage", async () => { const root = createProbeRoot(); const source = { @@ -1346,13 +2107,12 @@ describe("staged Worker cacheability probes", () => { expect(result.failures).toEqual([]); expect(result).toMatchObject({ classified: 1, probed: 2 }); expect(result.cacheableTargets).toEqual([source, direct]); + // A route without dynamic segments has no unknown paths to admit. expect(result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/safe")]).toEqual({ - allowUnknown: true, kind: "app-page", pattern: "/safe", state: "runtime-check", - staticPaths: { html: ["/safe"] }, - unknownState: "static-candidate", + staticPaths: { html: ["/safe"], "rsc-full": ["/safe"] }, }); }); @@ -1407,7 +2167,7 @@ describe("staged Worker cacheability probes", () => { kind: "app-page", pattern: "/html-target", state: "runtime-check", - staticRepresentation: "html", + staticPaths: { html: ["/html-target"], "rsc-full": ["/html-target"] }, }); expect(cacheabilityManifestRouteState(sourceManifestRoute, "/source", "rsc-full")).toBe( "runtime-check", @@ -1419,22 +2179,34 @@ describe("staged Worker cacheability probes", () => { it.each([ { + // A literal route lists only its own path, so the resolved pathname is + // unlisted there and, without on-demand ISR, gets no state. change: "route pathname", expectedPattern: "/source", + expectedResolvedState: null, expectedRoutePathname: "/resolved", pattern: "/source", routePathname: "/resolved", }, { + // The destination route lists no paths, so the moved path is unlisted + // there and, without on-demand ISR, gets no state and no warm request. change: "route pattern", expectedPattern: "/destination/:slug", + expectedResolvedState: null, expectedRoutePathname: "/source", pattern: "/destination/:slug", routePathname: "/source", }, ])( "retains deferred representation ownership when only the $change changes", - async ({ expectedPattern, expectedRoutePathname, pattern, routePathname }) => { + async ({ + expectedPattern, + expectedResolvedState, + expectedRoutePathname, + pattern, + routePathname, + }) => { const root = createProbeRoot(); const { html, route, rsc } = pairedRouteTargets(); const result = await probeStagedWorkerCacheability({ @@ -1456,7 +2228,7 @@ describe("staged Worker cacheability probes", () => { }); expect(result).toMatchObject({ - cacheableTargets: [html, rsc], + cacheableTargets: expectedResolvedState ? [html, rsc] : [rsc], failures: [], probed: 1, speculativeTargets: [rsc], @@ -1469,8 +2241,10 @@ describe("staged Worker cacheability probes", () => { const resolvedManifestRoute = result.manifest.routes[cacheabilityManifestRouteKey(route.kind, expectedPattern)]; expect( - cacheabilityManifestRouteState(resolvedManifestRoute, expectedRoutePathname, "html"), - ).toBe("static-candidate"); + resolvedManifestRoute + ? cacheabilityManifestRouteState(resolvedManifestRoute, expectedRoutePathname, "html") + : null, + ).toBe(expectedResolvedState); }, ); @@ -1521,6 +2295,7 @@ describe("staged Worker cacheability probes", () => { buildId: "application-build", fetchImpl: async () => Response.json({ + explicitConfigCachePolicy: true, kind: "app-page", pattern: "/posts/:slug", rendererStatic: false, @@ -1755,7 +2530,7 @@ describe("staged Worker cacheability probes", () => { pattern: route.pattern, runtimePaths: ["/posts/z-ordinary"], state: "runtime-check", - staticPaths: { html: ["/posts/a-special"] }, + staticPaths: { html: ["/posts/a-special"], "rsc-full": ["/posts/a-special"] }, }), ]); }); @@ -1807,7 +2582,7 @@ describe("staged Worker cacheability probes", () => { expect.objectContaining({ pattern: route.pattern, state: "runtime-check", - staticRepresentation: "html", + staticPaths: { html: ["/conditional"], "rsc-full": ["/conditional"] }, }), ]); }); @@ -1908,6 +2683,88 @@ describe("staged Worker cacheability probes", () => { ]); }); + it("keeps the loading shell of a path whose full page used a dynamic API", async () => { + const root = createProbeRoot(); + const representations = (route: ReturnType, pathname: string) => ({ + html: { ...target(pathname), route }, + fullRsc: { + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-full" as const, + label: `${pathname} (RSC full)`, + pathname: `${pathname}?_rsc`, + route, + sourcePathname: pathname, + }, + loadingShell: { + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-loading-shell" as const, + label: `${pathname} (RSC loading shell)`, + pathname: `${pathname}?_rsc=loading`, + route, + sourcePathname: pathname, + }, + }); + const postsRoute = optimizableRoute("/posts/:slug"); + const dashboardRoute = optimizableRoute("/dashboard"); + const post = representations(postsRoute, "/posts/one"); + const dashboard = representations(dashboardRoute, "/dashboard"); + + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + // headers() below loading.tsx makes the full page dynamic. + return Response.json({ + dynamicUsage: true, + kind: "app-page", + pattern: pathname === "/dashboard" ? "/dashboard" : "/posts/:slug", + scope: "identity", + state: "dynamic", + status: 200, + version: 1, + }); + }, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [ + post.loadingShell, + post.fullRsc, + post.html, + dashboard.loadingShell, + dashboard.fullRsc, + dashboard.html, + ], + }); + + expect(result.probed).toBe(2); + expect(result.cacheableTargets).toEqual([dashboard.loadingShell, post.loadingShell]); + expect(result.speculativeTargets).toEqual([dashboard.loadingShell, post.loadingShell]); + const postsEntry = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]; + const dashboardEntry = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/dashboard")]; + expect(postsEntry).toBeDefined(); + expect(dashboardEntry).toBeDefined(); + expect(parseCacheabilityManifest(JSON.stringify(result.manifest), "application-build")).toEqual( + result.manifest, + ); + for (const [entry, pathname] of [ + [postsEntry!, "/posts/one"], + [dashboardEntry!, "/dashboard"], + ] as const) { + expect(cacheabilityManifestRouteState(entry, pathname, "rsc-loading-shell")).toBe( + "runtime-check", + ); + expect(cacheabilityManifestRouteState(entry, pathname, "html")).toBeNull(); + expect(cacheabilityManifestRouteState(entry, pathname, "rsc-full")).toBeNull(); + } + // Only the listed path's loading shell is authorized. + expect( + cacheabilityManifestRouteState(postsEntry!, "/posts/two", "rsc-loading-shell"), + ).toBeNull(); + }); + it("classifies every nodejs.org path while storing one compact exact-path record", async () => { const root = createProbeRoot(); const pathCount = 2_272; @@ -1970,12 +2827,13 @@ describe("staged Worker cacheability probes", () => { state: "runtime-check", staticPaths: { html: Array.from({ length: pathCount - 1 }, (_, index) => `${index}`).sort(), + "rsc-full": Array.from({ length: pathCount - 1 }, (_, index) => `${index}`).sort(), }, }), ]); - // One exact path string per cacheable render is the irreducible safety - // information. It is still far smaller than per-HTML/RSC route records. - expect(Buffer.byteLength(JSON.stringify(result.manifest))).toBeLessThan(20 * 1024); + // One exact path string per certified representation is the irreducible + // safety information. It is still far smaller than per-path route records. + expect(Buffer.byteLength(JSON.stringify(result.manifest))).toBeLessThan(40 * 1024); expect(progress.at(-1)).toBe(pathCount); }); @@ -2059,7 +2917,7 @@ describe("staged Worker cacheability probes", () => { expect.objectContaining({ pattern: "/static", state: "runtime-check", - staticRepresentation: "html", + staticPaths: { html: ["/static"], "rsc-full": ["/static"] }, }), ]); }); @@ -2071,7 +2929,7 @@ describe("staged Worker cacheability probes", () => { kind: "app-page", pattern: firstTarget.pathname, state: "runtime-check", - staticRepresentation: "html", + staticPaths: { html: [firstTarget.pathname], "rsc-full": [firstTarget.pathname] }, }; const key = cacheabilityManifestRouteKey(route.kind, route.pattern); const exactBytes = Buffer.byteLength( diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index df0ba6b4ec..c46aea854f 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -12,8 +12,10 @@ import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "../packages/cloudflare/sr import { writeCacheabilityManifestArtifact } from "../packages/cloudflare/src/cacheability-artifact.js"; import { CACHEABILITY_MANIFEST_MODULE, + CACHEABILITY_REQUEST_PROJECTION_MODULE, cacheabilityManifestRouteKey, type CacheabilityManifest, + type CacheabilityManifestRoute, } from "../packages/vinext/src/server/cacheability-manifest.js"; import { VINEXT_CACHEABILITY_PROBE_HEADER, @@ -110,24 +112,40 @@ const OLD_VERSION = "11111111-1111-4111-8111-111111111111"; const PROBE_VERSION = "22222222-2222-4222-8222-222222222222"; const FINAL_VERSION = "33333333-3333-4333-8333-333333333333"; -function writeTwoStageWorkerArtifact(): void { +// An App Router build also emits the request stage's projection module, which +// its request stage imports. +function writeTwoStageWorkerArtifact({ + appRouter = true, + importsProjection = appRouter, +}: { appRouter?: boolean; importsProjection?: boolean } = {}): void { writeFile( "dist/server/wrangler.json", JSON.stringify({ main: "index.js", name: "my-worker", workers_dev: true }), ); - writeFile("dist/server/index.js", 'void import("./response-stage.js");\n'); + writeFile( + "dist/server/index.js", + 'void import("./request-stage.js");\nvoid import("./response-stage.js");\n', + ); + writeFile( + "dist/server/request-stage.js", + importsProjection ? `import "./${CACHEABILITY_REQUEST_PROJECTION_MODULE}";\n` : "", + ); writeFile("dist/server/response-stage.js", `import "./${CACHEABILITY_MANIFEST_MODULE}";\n`); writeFile( "dist/server/.vite/manifest.json", JSON.stringify({ "virtual:cloudflare/worker-entry": { - dynamicImports: ["virtual:vinext-response-stage"], + dynamicImports: ["virtual:vinext-request-stage", "virtual:vinext-response-stage"], file: "index.js", }, + "virtual:vinext-request-stage": { file: "request-stage.js" }, "virtual:vinext-response-stage": { file: "response-stage.js" }, }), ); writeFile(`dist/server/${CACHEABILITY_MANIFEST_MODULE}`, "export default null;\n"); + if (appRouter) { + writeFile(`dist/server/${CACHEABILITY_REQUEST_PROJECTION_MODULE}`, "export default null;\n"); + } writeFile( "dist/server/vinext-server.json", JSON.stringify({ prerenderSecret: "test-prerender-secret" }), @@ -578,6 +596,144 @@ describe("Cloudflare CDN warmup deploy flow", () => { ).toBe('export default "{\\"buildId\\":\\"build-a\\",\\"routes\\":{},\\"version\\":1}";\n'); }); + it("writes the request stage's projection of the App page routes that admit query-free entries", () => { + writeTwoStageWorkerArtifact(); + const routes: CacheabilityManifest["routes"] = {}; + const records: CacheabilityManifestRoute[] = [ + { + kind: "app-page", + pattern: "/blog/:slug", + state: "runtime-check", + allowUnknown: true, + unknownState: "static-candidate", + runtimePaths: ["/blog/vetoed"], + }, + { + kind: "app-page", + pattern: "/about", + state: "runtime-check", + staticPaths: { html: ["/about"], "rsc-full": ["/about"] }, + }, + { kind: "app-page", pattern: "/fallback/:id", state: "static-candidate" }, + { + kind: "app-page", + pattern: "/dynamic/:id", + state: "runtime-check", + runtimePaths: ["/dynamic/a"], + }, + { + kind: "app-page", + pattern: "/pruned/:id", + state: "runtime-check", + runtimeRepresentation: "rsc-loading-shell", + }, + { kind: "app-route", pattern: "/api/static", state: "static-candidate" }, + { + kind: "pages-page", + pattern: "/legacy", + state: "runtime-check", + staticRepresentation: "html", + }, + ]; + for (const route of records) { + routes[cacheabilityManifestRouteKey(route.kind, route.pattern)] = route; + } + + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", { + buildId: "build-a", + routes, + version: 1, + }); + + const source = fs.readFileSync( + path.join(tmpDir, "dist/server", CACHEABILITY_REQUEST_PROJECTION_MODULE), + "utf8", + ); + const projection = JSON.parse( + JSON.parse(source.slice("export default ".length, -";\n".length)), + ) as CacheabilityManifest; + // Route records stay unchanged, so a lookup agrees with the full manifest. + expect(projection).toEqual({ + buildId: "build-a", + routes: { + [cacheabilityManifestRouteKey("app-page", "/blog/:slug")]: + routes[cacheabilityManifestRouteKey("app-page", "/blog/:slug")], + [cacheabilityManifestRouteKey("app-page", "/about")]: + routes[cacheabilityManifestRouteKey("app-page", "/about")], + [cacheabilityManifestRouteKey("app-page", "/fallback/:id")]: + routes[cacheabilityManifestRouteKey("app-page", "/fallback/:id")], + }, + version: 1, + }); + }); + + it("writes no projection for a Pages Router build", () => { + writeTwoStageWorkerArtifact({ appRouter: false }); + const route: CacheabilityManifestRoute = { + kind: "pages-page", + pattern: "/legacy", + state: "static-candidate", + }; + + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", { + buildId: "build-a", + routes: { [cacheabilityManifestRouteKey(route.kind, route.pattern)]: route }, + version: 1, + }); + + expect( + fs.existsSync(path.join(tmpDir, "dist/server", CACHEABILITY_REQUEST_PROJECTION_MODULE)), + ).toBe(false); + }); + + it("rejects an App page manifest for an artifact without the request stage's projection module", () => { + // A stale or pre-built artifact would otherwise deploy RSC listings its + // request stage never strips the query for. + writeTwoStageWorkerArtifact({ appRouter: false }); + const route: CacheabilityManifestRoute = { + kind: "app-page", + pattern: "/about", + state: "static-candidate", + }; + + expect(() => + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", { + buildId: "build-a", + routes: { [cacheabilityManifestRouteKey(route.kind, route.pattern)]: route }, + version: 1, + }), + ).toThrow( + `requires ${CACHEABILITY_REQUEST_PROJECTION_MODULE} in the generated Worker artifact`, + ); + expect( + fs.readFileSync(path.join(tmpDir, "dist/server", CACHEABILITY_MANIFEST_MODULE), "utf8"), + ).toBe("export default null;\n"); + }); + + it("rejects an App page manifest when the Worker graph doesn't import the projection", () => { + // The file exists, but no request-stage module reads it, so the request + // stage would keep full-query dispatches the response stage admits. + writeTwoStageWorkerArtifact({ importsProjection: false }); + const route: CacheabilityManifestRoute = { + kind: "app-page", + pattern: "/about", + state: "static-candidate", + }; + + expect(() => + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", { + buildId: "build-a", + routes: { [cacheabilityManifestRouteKey(route.kind, route.pattern)]: route }, + version: 1, + }), + ).toThrow( + `requires the generated Worker graph to statically import ${CACHEABILITY_REQUEST_PROJECTION_MODULE}`, + ); + expect( + fs.readFileSync(path.join(tmpDir, "dist/server", CACHEABILITY_MANIFEST_MODULE), "utf8"), + ).toBe("export default null;\n"); + }); + it("accepts a manifest over one MiB with more than 10,000 route patterns", () => { writeTwoStageWorkerArtifact(); const routes = Object.fromEntries( @@ -868,7 +1024,10 @@ describe("Cloudflare CDN warmup deploy flow", () => { kind: "app-page", pattern: "/:slug", runtimePaths: ["/dynamic"], - staticPaths: { html: ["/about"] }, + staticPaths: { + html: ["/about"], + "rsc-full": ["/about"], + }, state: "runtime-check", }), expect.objectContaining({ diff --git a/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts b/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts index df65dfb1c9..7709d269af 100644 --- a/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts +++ b/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts @@ -202,3 +202,76 @@ test("useSearchParams() server-renders the real query once the page is dynamic", expect(headers["x-vinext-cache"]).not.toBe("HIT"); expect(headers["cf-cache-status"]).not.toBe("HIT"); }); + +test("Workers Cache serves every query of a static page from one entry", async ({ + baseURL, + request, +}) => { + test.skip(!baseURL?.startsWith("https://"), "requires a deployed Cloudflare Worker"); + test.skip(backend !== "workers-cache", "the query-free dispatch is specific to Workers Cache"); + if (!baseURL) throw new Error("deployed test requires a base URL"); + test.setTimeout(180_000); + + // Next.js serves a static page's one render for any query. Each request + // carries a query no earlier request used, so only an entry shared across + // queries can report a HIT with the previous response's render. No query + // ever reaches the shared render. + const expectSharedAcrossQueries = async ( + label: string, + urlFor: (query: string) => string, + headers: Record, + content: string, + renderOf: (body: string) => string | undefined, + ) => { + const queries: string[] = []; + let previousRender: string | undefined; + await expect + .poll( + async () => { + const query = randomUUID(); + queries.push(query); + const response = await request.get(urlFor(query), { headers }); + const responseHeaders = response.headers(); + const trace = JSON.stringify({ label, headers: responseHeaders }); + expect(response.ok(), trace).toBe(true); + const body = await response.text(); + expect(body, trace).toContain(content); + for (const sent of queries) expect(body, trace).not.toContain(sent); + const render = renderOf(body); + expect(render, trace).toBeTruthy(); + const shared = responseHeaders["cf-cache-status"] === "HIT" && render === previousRender; + previousRender = render; + return shared; + }, + { intervals: [1_000], timeout: 45_000 }, + ) + .toBe(true); + }; + + await expectSharedAcrossQueries( + "HTML", + (query) => `${baseURL}/cached/featured?q=${query}`, + { accept: "text/html" }, + "Post: featured", + (body) => /data-render-id-tag[^>]*>([^<]+) `${baseURL}/cached/featured?q=${query}&_rsc`, + { accept: "text/x-component", rsc: "1" }, + "Post: featured", + // A HIT returns the stored payload byte for byte. + (body) => body, + ); + // A static page that reads useSearchParams() inside Suspense: Next.js + // prerenders it once with the fallback, and the browser reads the query. + await expectSharedAcrossQueries( + "HTML with useSearchParams() inside Suspense", + (query) => `${baseURL}/search-params/suspense?q=${query}`, + { accept: "text/html" }, + 'data-testid="search-fallback"', + (body) => /search-suspense-render-id[^>]*>([^<]+) { ); expect(code).toContain('from "virtual:vinext-cdn-cache-adapter"'); expect(code).not.toContain('from "virtual:vinext-cache-adapters"'); + // The request stage reads only the Workers Cache manifest's projection. + expect(code).toContain( + 'import __cacheabilityRequestProjection from "virtual:vinext-cacheability-request-projection"', + ); + expect(code).toContain("cacheabilityRequestProjection: __cacheabilityRequestProjection,"); + expect(code).not.toContain("virtual:vinext-cacheability-manifest"); expect(code).toContain('dispatchPagesResponseStage(stageRequest, "api")'); expect(code).toContain( 'dispatchPagesResponseStage(stageRequest, "page", dataKind, __pagesRequestEntry.hasRequestAwareDocument)', diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 442822958c..cdf0939f48 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import fs from "node:fs"; import os from "node:os"; +import { createRequire } from "node:module"; import path from "node:path"; import { toSlash } from "pathslash"; import { resolveNextConfig } from "../packages/vinext/src/config/next-config.js"; @@ -150,7 +151,8 @@ describe("prerender path manifest", () => { pattern: "/cached/:slug", }, "/dynamic": { - cacheabilityProbe: { canPrunePattern: true }, + // force-dynamic, so Next.js's build never lists it. + cacheabilityProbe: { canPrunePattern: true, unlisted: true }, kind: "app-page", pattern: "/dynamic", }, @@ -210,6 +212,214 @@ describe("prerender path manifest", () => { }); }); + it("marks traffic-picked paths that the route's static generation doesn't list", 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/page.tsx", "export default function Page() { return null; }\n"); + writeFile( + "app/cached/[slug]/page.tsx", + [ + "export function generateStaticParams() { return [{ slug: 'intro' }, { slug: 'featured' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + + const { discoverPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await discoverPrerenderPathManifest({ + root: tmpDir, + candidatePaths: ["/cached/from-traffic", "/cached/intro", "/"], + responseVary: "verbatim", + }); + + const unlisted = (pathname: string) => + manifest?.routePatterns?.[pathname]?.cacheabilityProbe?.unlisted; + expect(unlisted("/cached/from-traffic")).toBe(true); + expect(unlisted("/cached/intro")).toBeUndefined(); + expect(unlisted("/cached/featured")).toBeUndefined(); + expect(unlisted("/")).toBeUndefined(); + }); + + it("lists paths only for App page routes that are static or SSG", 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"); + // The route's only generateStaticParams is a sibling page's, above its + // last dynamic segment, so Next.js classifies /[category]/details as ƒ. + writeFile( + "app/[category]/page.tsx", + [ + "export function generateStaticParams() { return [{ category: 'news' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile( + "app/[category]/details/page.tsx", + "export const revalidate = 60; export default function Page() { return null; }\n", + ); + // A layout's edge runtime disables static generation for its pages. + writeFile("app/edge/layout.tsx", "export const runtime = 'edge';\n"); + writeFile("app/edge/page.tsx", "export default function Page() { return null; }\n"); + writeFile( + "app/edge/[id]/page.tsx", + [ + "export function generateStaticParams() { return []; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + const defaultFetch = vi.mocked(fetch).getMockImplementation()!; + vi.mocked(fetch).mockImplementation(async (input, init) => + new URL(input instanceof Request ? input.url : String(input)).searchParams.get("pattern") === + "/edge/:id" + ? Response.json([]) + : defaultFetch(input, init), + ); + + const { discoverPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await discoverPrerenderPathManifest({ + root: tmpDir, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual(expect.arrayContaining(["/news", "/news/details", "/edge"])); + const unlisted = (pathname: string) => + manifest?.routePatterns?.[pathname]?.cacheabilityProbe?.unlisted; + expect(unlisted("/news")).toBeUndefined(); + expect(unlisted("/news/details")).toBe(true); + expect(unlisted("/edge")).toBe(true); + expect(manifest?.fallbackRoutePatterns).toBeUndefined(); + }); + + it("marks a path unlisted when a route other than its runtime owner generates it", 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"); + // Next.js's build renders each generated path under the route that + // generated it, so the static catch-all's paths are never build-rendered + // by the more specific routes that own them at runtime. + writeFile( + "app/[...slug]/page.tsx", + [ + "export function generateStaticParams() { return []; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile( + "app/specific/[id]/page.tsx", + "export const dynamic = 'force-dynamic'; export default function Page() { return null; }\n", + ); + writeFile( + "app/static/[id]/page.tsx", + [ + "export function generateStaticParams() { return [{ id: 'own' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + const defaultFetch = vi.mocked(fetch).getMockImplementation()!; + vi.mocked(fetch).mockImplementation(async (input, init) => { + const pattern = new URL( + input instanceof Request ? input.url : String(input), + ).searchParams.get("pattern"); + if (pattern === "/:slug+") { + return Response.json([ + { slug: ["specific", "value"] }, + { slug: ["static", "foreign"] }, + { slug: ["other", "value"] }, + ]); + } + if (pattern === "/static/:id") return Response.json([{ id: "own" }]); + return defaultFetch(input, init); + }); + + const { discoverPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await discoverPrerenderPathManifest({ + root: tmpDir, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual( + expect.arrayContaining(["/specific/value", "/static/foreign", "/other/value", "/static/own"]), + ); + const route = (pathname: string) => manifest?.routePatterns?.[pathname]; + expect(route("/specific/value")).toMatchObject({ + cacheabilityProbe: { unlisted: true }, + pattern: "/specific/:id", + }); + expect(route("/static/foreign")).toMatchObject({ + cacheabilityProbe: { unlisted: true }, + pattern: "/static/:id", + }); + expect(route("/static/own")?.cacheabilityProbe?.unlisted).toBeUndefined(); + expect(route("/other/value")).toMatchObject({ pattern: "/:slug+" }); + expect(route("/other/value")?.cacheabilityProbe?.unlisted).toBeUndefined(); + }); + + it("doesn't count a type-only generateStaticParams export toward static generation", 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/[category]/page.tsx", + [ + "export function generateStaticParams() { return [{ category: 'news' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile( + "app/[category]/details/page.tsx", + [ + "type generateStaticParams = () => unknown[];", + "export type { generateStaticParams };", + "export default function Page() { return null; }", + ].join("\n"), + ); + + const { discoverPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await discoverPrerenderPathManifest({ + root: tmpDir, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toContain("/news/details"); + expect(manifest?.routePatterns?.["/news/details"]?.cacheabilityProbe?.unlisted).toBe(true); + }); + + it("lists the paths of a route whose last dynamic segment's layout has generateStaticParams", 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/[category]/layout.tsx", + [ + "export function generateStaticParams() { return [{ category: 'news' }]; }", + "export default function Layout({ children }) { return children; }", + ].join("\n"), + ); + writeFile( + "app/[category]/details/page.tsx", + "export default function Page() { return null; }\n", + ); + + const { discoverPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await discoverPrerenderPathManifest({ + root: tmpDir, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toContain("/news/details"); + expect(manifest?.routePatterns?.["/news/details"]?.cacheabilityProbe?.unlisted).toBeUndefined(); + }); + it("keeps traffic paths that an uncached request stage rewrites", async () => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); @@ -1422,7 +1632,20 @@ describe("prerender path manifest", () => { writeFile("dist/server/index.js", "export default {};\n"); writeFile( "app/[slug]/page.mdx", - 'export function generateStaticParams() { return [{ slug: "hello" }] }\n\n# Hello\n', + [ + "```js", + "export const dynamic = 'force-dynamic'", + "```", + "", + // MDX keeps ESM open across a blank line until the JavaScript parses. + "export function generateStaticParams() {", + "", + ' return [{ slug: "hello" }]', + "}", + "", + "# Hello", + "", + ].join("\n"), ); vi.mocked(fetch).mockResolvedValue(Response.json([{ slug: "hello" }])); @@ -1442,6 +1665,236 @@ describe("prerender path manifest", () => { expect(manifest?.paths).toEqual(["/hello"]); expect(manifest?.rscPaths).toEqual(["/hello"]); + // The page's generateStaticParams lists it. The fenced code isn't ESM. + expect(manifest?.routePatterns?.["/hello"]?.cacheabilityProbe?.unlisted).toBeUndefined(); + }); + + it("doesn't take MDX paragraph text that looks like an export as ESM", 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/[slug]/page.mdx", + [ + "An export can't interrupt a paragraph, so this is text:", + "export function generateStaticParams() {}", + "", + "# Hello", + "", + ].join("\n"), + ); + vi.mocked(fetch).mockResolvedValue(Response.json([{ slug: "hello" }])); + + 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( + { pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"] }, + tmpDir, + ); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + nextConfig, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual(["/hello"]); + expect(manifest?.routePatterns?.["/hello"]?.cacheabilityProbe?.unlisted).toBe(true); + }); + + it("reads MDX with the parser beside an MDX plugin the app installs", async () => { + // vinext's own @mdx-js/rollup isn't installed; the app registers its own. + const rollupDir = path.dirname( + createRequire(new URL("../packages/vinext/package.json", import.meta.url)).resolve( + "@mdx-js/rollup", + ), + ); + vi.resetModules(); + vi.doMock("node:module", async (importOriginal) => { + const actual = await importOriginal(); + const createRequireWithoutVinextMdx = (base: string | URL) => { + const require = actual.createRequire(base); + if (!String(base).includes("/utils/mdx-scan.")) return require; + return Object.assign((id: string) => require(id), require, { + resolve: (id: string, options?: { paths?: string[] }) => { + if (id === "@mdx-js/rollup") throw new Error(`Cannot find module '${id}'`); + return require.resolve(id, options); + }, + }); + }; + return Object.assign({}, actual, { createRequire: createRequireWithoutVinextMdx }); + }); + try { + 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"); + fs.mkdirSync(path.join(tmpDir, "node_modules", "@mdx-js"), { recursive: true }); + fs.symlinkSync(rollupDir, path.join(tmpDir, "node_modules", "@mdx-js", "rollup"), "dir"); + writeFile( + "app/[slug]/page.mdx", + 'export function generateStaticParams() { return [{ slug: "hello" }] }\n\n# Hello\n', + ); + // Only a route read with the parser keeps its force-dynamic config. + writeFile( + "app/forced/[slug]/page.mdx", + [ + 'export const dynamic = "force-dynamic"', + 'export function generateStaticParams() { return [{ slug: "hello" }] }', + "", + "# Forced", + "", + ].join("\n"), + ); + vi.mocked(fetch).mockImplementation(async () => Response.json([{ slug: "hello" }])); + + 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( + { pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"] }, + tmpDir, + ); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + nextConfig, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual(["/forced/hello", "/hello"]); + expect(manifest?.routePatterns?.["/hello"]?.cacheabilityProbe?.unlisted).toBeUndefined(); + expect(manifest?.routePatterns?.["/forced/hello"]?.cacheabilityProbe?.unlisted).toBe(true); + } finally { + vi.doUnmock("node:module"); + vi.resetModules(); + } + }); + + it("lists an MDX route's paths without the MDX parser, so their render failures still fail", async () => { + vi.resetModules(); + vi.doMock("../packages/vinext/src/utils/mdx-scan.js", async (importOriginal) => ({ + ...(await importOriginal()), + loadMdxEsmReader: async () => null, + })); + try { + 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( + "dist/server/vinext-server.json", + JSON.stringify({ prerenderSecret: "probe-secret" }), + ); + writeFile( + "app/[slug]/page.mdx", + 'export function generateStaticParams() { return [{ slug: "hello" }] }\n\n# Hello\n', + ); + vi.mocked(fetch).mockResolvedValue(Response.json([{ slug: "hello" }])); + + const [ + { emitPrerenderPathManifest }, + { resolveNextConfig }, + { probeStagedWorkerCacheability }, + ] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + import("../packages/cloudflare/src/cacheability-probe.js"), + ]); + const nextConfig = await resolveNextConfig( + { pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"] }, + tmpDir, + ); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + nextConfig, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual(["/hello"]); + const route = manifest?.routePatterns?.["/hello"]; + expect(route?.cacheabilityProbe?.unlisted).toBeUndefined(); + + const result = await probeStagedWorkerCacheability({ + buildId: "build-a", + fetchImpl: async () => + Response.json({ + kind: "app-page", + pattern: route?.pattern, + reason: "route returned HTTP 500", + state: "probe-failed", + status: 500, + version: 1, + }), + retries: 0, + root: tmpDir, + targetUrl: "https://example.com", + targets: [ + { + headers: { Accept: "text/html" }, + kind: "html", + label: "/hello", + pathname: "/hello", + route, + sourcePathname: "/hello", + }, + ], + }); + expect(result.failures).toEqual(["/hello: route returned HTTP 500"]); + } finally { + vi.doUnmock("../packages/vinext/src/utils/mdx-scan.js"); + vi.resetModules(); + } + }); + + it("doesn't certify an unreadable MDX route's empty static params fallback", async () => { + vi.resetModules(); + vi.doMock("../packages/vinext/src/utils/mdx-scan.js", async (importOriginal) => ({ + ...(await importOriginal()), + loadMdxEsmReader: async () => null, + })); + try { + 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"); + // Without the parser, the layout's edge runtime can't be read, so no + // probe proves the fallback static. + writeFile( + "app/posts/layout.mdx", + 'export const runtime = "edge"\n\n# Posts\n\n{props.children}\n', + ); + writeFile( + "app/posts/[slug]/page.tsx", + [ + "export function generateStaticParams() { return []; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + vi.mocked(fetch).mockResolvedValue(Response.json([])); + + 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( + { pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"] }, + tmpDir, + ); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + nextConfig, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual([]); + expect(manifest?.fallbackRoutePatterns).toBeUndefined(); + } finally { + vi.doUnmock("../packages/vinext/src/utils/mdx-scan.js"); + vi.resetModules(); + } }); it("discovers dynamic Pages MDX paths from the built runtime", async () => { diff --git a/tests/response-stage-cacheability.test.ts b/tests/response-stage-cacheability.test.ts index 9cc1e09e77..d8683ee7a1 100644 --- a/tests/response-stage-cacheability.test.ts +++ b/tests/response-stage-cacheability.test.ts @@ -6,7 +6,11 @@ import { 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 { + VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER, + VINEXT_PARAMS_HEADER, + VINEXT_RENDERED_PATH_AND_SEARCH_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 { @@ -233,6 +237,84 @@ describe("response-stage cacheability", () => { await expect(response.text()).resolves.toContain("changed from static to dynamic"); }); + it("drops request-scoped headers before admitting a shared response", async () => { + const route = { kind: "app-page" as const, pattern: "/page", state: "static-candidate" }; + const rawManifest = JSON.stringify({ + buildId: "build-a", + routes: { [cacheabilityManifestRouteKey(route.kind, route.pattern)]: route }, + version: 1, + }); + const rscRequest = () => + new Request("https://example.com/page?_rsc", { + headers: { Accept: "text/x-component", RSC: "1" }, + }); + const admitted: Response[] = []; + const render = (options: { + cache: "bypass" | "shared"; + rawManifest: string | null; + recomposesRequestScopedHeaders: boolean; + }) => + withResponseStageCacheability( + { + ...options, + buildId: "build-a", + context: baseContext(), + registerCacheAdapters() {}, + request: rscRequest(), + }, + async (context) => { + const state = contextState(context); + if (state) { + state.route = { kind: "app-page", pattern: "/page" }; + state.outcome = { + cacheable: true, + cacheControl: "s-maxage=60", + searchParamsUnread: true, + }; + } + return new Response("rsc", { + headers: { + [VINEXT_PARAMS_HEADER]: encodeURIComponent('{"id":"one"}'), + [VINEXT_RENDERED_PATH_AND_SEARCH_HEADER]: encodeURIComponent("/page?q=1"), + }, + }); + }, + ); + setCdnCacheAdapter({ + ...admissionAdapter(), + deferCompletedPageResponseAdmission(response) { + admitted.push(response); + return null; + }, + }); + + // Manifest admission stores the response as returned. + const manifestAdmitted = await render({ + cache: "shared", + rawManifest, + recomposesRequestScopedHeaders: true, + }); + expect(manifestAdmitted.headers.get("Cache-Control")).toBe("s-maxage=60"); + expect(manifestAdmitted.headers.has(VINEXT_PARAMS_HEADER)).toBe(false); + expect(manifestAdmitted.headers.has(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBe(false); + await expect(manifestAdmitted.text()).resolves.toBe("rsc"); + + // Deferred runtime admission sees the response without them too. + await render({ cache: "shared", rawManifest: null, recomposesRequestScopedHeaders: true }); + expect(admitted).toHaveLength(1); + expect(admitted[0]!.headers.has(VINEXT_PARAMS_HEADER)).toBe(false); + expect(admitted[0]!.headers.has(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBe(false); + + for (const kept of [ + await render({ cache: "bypass", rawManifest, recomposesRequestScopedHeaders: true }), + await render({ cache: "shared", rawManifest, recomposesRequestScopedHeaders: false }), + ]) { + expect(kept.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBe( + encodeURIComponent("/page?q=1"), + ); + } + }); + it("runs authenticated probes even when the response transport bypasses caching", async () => { let closeBody!: () => void; let markRenderStarted!: () => void;