From ffee0e6e0aaca83f1dc6b78512425a93ed29db58 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 10:32:12 +0100 Subject: [PATCH 01/24] fix(cloudflare): scale cacheability probing --- packages/cloudflare/src/cacheability-probe.ts | 298 +++++++++++++----- packages/cloudflare/src/cdn-warm.ts | 47 ++- packages/cloudflare/src/cli.ts | 1 + packages/cloudflare/src/deploy-help.ts | 4 +- packages/cloudflare/src/deploy.ts | 67 +++- packages/cloudflare/src/version-deploy.ts | 45 ++- packages/vinext/src/build/prerender-paths.ts | 25 +- .../vinext/src/server/app-page-dispatch.ts | 6 + .../vinext/src/server/cacheability-request.ts | 19 ++ .../src/shims/cacheability-classification.ts | 9 + tests/cloudflare-cacheability-probe.test.ts | 277 +++++++++++++++- tests/cloudflare-cdn-warm-deploy.test.ts | 41 ++- tests/cloudflare-version-deploy.test.ts | 32 ++ tests/deploy.test.ts | 20 +- .../cacheability-probe.spec.ts | 25 ++ .../pattern-force-dynamic/page.tsx | 5 + .../pattern-revalidate-zero/layout.tsx | 7 + .../pattern-revalidate-zero/page.tsx | 3 + tests/prerender-paths.test.ts | 6 + 19 files changed, 813 insertions(+), 124 deletions(-) create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/pattern-force-dynamic/page.tsx create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/pattern-revalidate-zero/layout.tsx create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/pattern-revalidate-zero/page.tsx diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index 70610b02f..e2c7df39c 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -37,13 +37,27 @@ type ProbePayload = { status?: number; version?: number; phaseTimedOut?: boolean; + scope?: "identity" | "pattern"; +}; + +export type CacheabilityProbeProgress = { + completed: number; + dynamic: number; + failed: number; + probed: number; + skipped: number; + static: number; + total: number; }; export type CacheabilityProbeResult = { cacheableTargets: CdnWarmTarget[]; + classified: number; + dynamic: number; failures: string[]; manifest: CacheabilityManifest; probed: number; + skipped: number; }; function isProbeRouteState(value: unknown): value is CacheabilityManifestRoute["state"] { @@ -111,7 +125,7 @@ async function probeTarget(options: { headers?: HeadersInit; retries: number; retryDelayMs: number; - deadlineAt: number; + getDeadlineAt: () => number; phaseTimeoutMs: number; secret: string; target: CdnWarmTarget; @@ -128,12 +142,12 @@ async function probeTarget(options: { const probeId = randomUUID(); const phaseTimeoutPayload = (): ProbePayload => ({ phaseTimedOut: true, - reason: `cacheability probing exceeded its ${options.phaseTimeoutMs}ms phase deadline`, + reason: `cacheability probing made no progress for ${options.phaseTimeoutMs}ms`, state: "probe-failed", version: 1, }); for (let attempt = 0; attempt <= options.retries; attempt++) { - const remainingMs = options.deadlineAt - Date.now(); + const remainingMs = options.getDeadlineAt() - Date.now(); if (remainingMs <= 0) return phaseTimeoutPayload(); const controller = new AbortController(); @@ -177,12 +191,12 @@ async function probeTarget(options: { }, attemptTimeoutMs); }); const result = await Promise.race([request, timedOut]); - if (Date.now() >= options.deadlineAt) return phaseTimeoutPayload(); + if (Date.now() >= options.getDeadlineAt()) return phaseTimeoutPayload(); if (result.kind === "complete") return result.payload; reason = result.reason; retryable = result.retryable; } catch (error) { - if (Date.now() >= options.deadlineAt) return phaseTimeoutPayload(); + if (Date.now() >= options.getDeadlineAt()) return phaseTimeoutPayload(); reason = error instanceof Error && error.name === "AbortError" ? `probe timed out after ${attemptTimeoutMs}ms` @@ -194,7 +208,10 @@ async function probeTarget(options: { } if (!retryable || attempt === options.retries) break; if (options.retryDelayMs > 0) { - const delayMs = Math.min(options.retryDelayMs, Math.max(0, options.deadlineAt - Date.now())); + const delayMs = Math.min( + options.retryDelayMs, + Math.max(0, options.getDeadlineAt() - Date.now()), + ); if (delayMs <= 0) return phaseTimeoutPayload(); await delay(delayMs); } @@ -215,6 +232,7 @@ export async function probeStagedWorkerCacheability(options: { targetUrl: string; timeoutMs?: number; phaseTimeoutMs?: number; + onProgress?: (progress: CacheabilityProbeProgress) => void; /** @internal Apply stricter artifact bounds for focused coordinator tests. */ manifestLimits?: { maxBytes?: number; maxRoutes?: number }; }): Promise { @@ -230,7 +248,8 @@ export async function probeStagedWorkerCacheability(options: { 1, options.phaseTimeoutMs ?? DEFAULT_CACHEABILITY_PROBE_PHASE_TIMEOUT_MS, ); - const deadlineAt = Date.now() + phaseTimeoutMs; + let lastProgressAt = Date.now(); + const getDeadlineAt = () => lastProgressAt + phaseTimeoutMs; const routes: Record = {}; const cacheableTargets: CdnWarmTarget[] = []; const failures: string[] = []; @@ -252,6 +271,39 @@ export async function probeStagedWorkerCacheability(options: { let limitFailure: Error | null = null; let phaseTimedOut = false; let nextIndex = 0; + let probed = 0; + let skipped = 0; + let staticCount = 0; + let dynamicCount = 0; + const patternDynamic = new Set(); + const rscBySourcePath = new Map( + options.targets + .filter((target) => target.kind === "rsc-full") + .map((target) => [target.sourcePathname, target] as const), + ); + const htmlSources = new Set( + options.targets + .filter((target) => target.kind === "html") + .map((target) => target.sourcePathname), + ); + const targets = options.targets.filter( + (target) => target.kind !== "rsc-full" || !htmlSources.has(target.sourcePathname), + ); + + const routeKey = (target: CdnWarmTarget): string | null => + target.route ? `${target.route.kind}\0${target.route.pattern}` : null; + + const reportProgress = (): void => { + options.onProgress?.({ + completed: staticCount + dynamicCount + failures.length + skipped, + dynamic: dynamicCount, + failed: failures.length, + probed, + skipped, + static: staticCount, + total: options.targets.length, + }); + }; const addRouteWithinManifestLimits = (key: string, route: CacheabilityManifestRoute): boolean => { const previousBytes = routeEntryBytes.get(key); @@ -282,85 +334,180 @@ export async function probeStagedWorkerCacheability(options: { return true; }; + const classifyTarget = async (target: CdnWarmTarget): Promise => { + const knownPattern = routeKey(target); + if (knownPattern && patternDynamic.has(knownPattern)) { + skipped += 1; + reportProgress(); + return null; + } + + const request = new Request(new URL(target.pathname, options.targetUrl), { + headers: target.headers, + }); + const identity = cacheabilityRequestIdentity(request); + if (!identity || identity.representation !== target.kind) { + failures.push(`${target.label}: warm request does not have a cacheable request identity`); + reportProgress(); + return null; + } + + const result = await probeTarget({ + expectedBuildId: options.expectedResponseBuildId, + fetchImpl: options.fetchImpl ?? fetch, + getDeadlineAt, + headers: options.headers, + retries, + retryDelayMs, + phaseTimeoutMs, + secret, + target, + targetUrl: options.targetUrl, + timeoutMs, + }); + probed += 1; + lastProgressAt = Date.now(); + if (limitFailure) return null; + if (result.phaseTimedOut) { + phaseTimedOut = true; + return null; + } + if ( + result.version !== 1 || + (result.kind !== "app-page" && result.kind !== "app-route" && result.kind !== "pages-page") || + typeof result.pattern !== "string" || + !result.pattern.startsWith("/") || + !isProbeRouteState(result.state) || + (result.scope !== undefined && result.scope !== "identity" && result.scope !== "pattern") || + (result.scope === "pattern" && result.state !== "dynamic") || + !Number.isInteger(result.status) || + result.status! < 100 || + result.status! > 599 + ) { + failures.push(`${target.label}: ${result.reason ?? "probe returned an invalid envelope"}`); + reportProgress(); + return null; + } + if (result.state === "probe-failed") { + failures.push(`${target.label}: ${result.reason ?? "probe failed"}`); + reportProgress(); + return null; + } + + if (result.state !== "static-candidate") { + dynamicCount += 1; + if (result.scope === "pattern" && knownPattern === `${result.kind}\0${result.pattern}`) { + patternDynamic.add(`${result.kind}\0${result.pattern}`); + } + reportProgress(); + return result; + } + + const route: CacheabilityManifestRoute = { + kind: result.kind, + pattern: result.pattern, + representation: identity.representation, + requestKey: identity.requestKey, + state: result.state, + status: result.status!, + }; + const key = cacheabilityManifestRouteKey( + route.kind, + route.pattern, + route.representation, + route.requestKey, + ); + if (!addRouteWithinManifestLimits(key, route)) return null; + cacheableTargets.push(target); + staticCount += 1; + reportProgress(); + return result; + }; + const worker = async (): Promise => { - while (!limitFailure && !phaseTimedOut && nextIndex < options.targets.length) { - const target = options.targets[nextIndex++]; - const request = new Request(new URL(target.pathname, options.targetUrl), { - headers: target.headers, - }); - const identity = cacheabilityRequestIdentity(request); - if (!identity || identity.representation !== target.kind) { - failures.push(`${target.label}: warm request does not have a cacheable request identity`); + while (!limitFailure && !phaseTimedOut && nextIndex < targets.length) { + const target = targets[nextIndex++]; + const pairedRsc = target.kind === "html" ? rscBySourcePath.get(target.sourcePathname) : null; + const knownPattern = routeKey(target); + if (knownPattern && patternDynamic.has(knownPattern)) { + skipped += pairedRsc ? 2 : 1; + reportProgress(); continue; } + const result = await classifyTarget(target); + if (!result || limitFailure || phaseTimedOut) continue; - const result = await probeTarget({ - expectedBuildId: options.expectedResponseBuildId, - deadlineAt, - fetchImpl: options.fetchImpl ?? fetch, - headers: options.headers, - retries, - retryDelayMs, - phaseTimeoutMs, - secret, - target, - targetUrl: options.targetUrl, - timeoutMs, - }); - if (limitFailure) return; - if (result.phaseTimedOut) { - phaseTimedOut = true; - return; - } + if (!pairedRsc) continue; + // An HTML App Page render produces the RSC payload consumed by SSR, so a + // completed successful HTML render is a strict superset of the work done + // by the paired full-RSC request. Keep both exact CDN identities in the + // manifest, while avoiding a second user-code render. Runtime admission + // still rechecks the exact RSC identity, status, completed body, dynamic + // observations, and final response vetoes; a representation-specific + // dynamic observation therefore fails closed as static-to-dynamic. + // Terminal HTML and RSC requests can intentionally use different HTTP + // statuses, so classify those representations independently. if ( - result.version !== 1 || - (result.kind !== "app-page" && - result.kind !== "app-route" && - result.kind !== "pages-page") || - typeof result.pattern !== "string" || - !result.pattern.startsWith("/") || - !isProbeRouteState(result.state) || - !Number.isInteger(result.status) || - result.status! < 100 || - result.status! > 599 + result.state === "static-candidate" && + result.kind === "app-page" && + result.status! >= 200 && + result.status! < 300 && + pairedRsc.route?.kind === "app-page" && + pairedRsc.route.pattern === result.pattern ) { - failures.push(`${target.label}: ${result.reason ?? "probe returned an invalid envelope"}`); + const pairedRequest = new Request(new URL(pairedRsc.pathname, options.targetUrl), { + headers: pairedRsc.headers, + }); + const pairedIdentity = cacheabilityRequestIdentity(pairedRequest); + if (!pairedIdentity || pairedIdentity.representation !== "rsc-full") { + failures.push( + `${pairedRsc.label}: warm request does not have a cacheable request identity`, + ); + reportProgress(); + continue; + } + const pairedRoute: CacheabilityManifestRoute = { + kind: "app-page", + pattern: result.pattern!, + representation: pairedIdentity.representation, + requestKey: pairedIdentity.requestKey, + state: "static-candidate", + status: result.status!, + }; + const pairedKey = cacheabilityManifestRouteKey( + pairedRoute.kind, + pairedRoute.pattern, + pairedRoute.representation, + pairedRoute.requestKey, + ); + if (!addRouteWithinManifestLimits(pairedKey, pairedRoute)) continue; + cacheableTargets.push(pairedRsc); + staticCount += 1; + reportProgress(); continue; } - if (result.state === "probe-failed") { - failures.push(`${target.label}: ${result.reason ?? "probe failed"}`); + if (result.state === "static-candidate") { + await classifyTarget(pairedRsc); + continue; + } + const resultPatternKey = `${result.kind}\0${result.pattern}`; + if ( + result.state === "dynamic" && + (result.scope !== "pattern" || routeKey(target) !== resultPatternKey) + ) { + await classifyTarget(pairedRsc); + } else if (result.state === "dynamic") { + skipped += 1; + reportProgress(); } - - // Dynamic identities are represented by absence. The runtime treats a - // missing exact identity as private, which keeps the deployed asset small - // and prevents the final warm/certification pass from rendering it again. - if (result.state !== "static-candidate") continue; - - const route: CacheabilityManifestRoute = { - kind: result.kind, - pattern: result.pattern, - representation: identity.representation, - requestKey: identity.requestKey, - state: result.state, - status: result.status!, - }; - const key = cacheabilityManifestRouteKey( - route.kind, - route.pattern, - route.representation, - route.requestKey, - ); - if (!addRouteWithinManifestLimits(key, route)) return; - cacheableTargets.push(target); } }; - await Promise.all( - Array.from({ length: Math.min(concurrency, options.targets.length) }, () => worker()), - ); + reportProgress(); + await Promise.all(Array.from({ length: Math.min(concurrency, targets.length) }, () => worker())); if (limitFailure) throw limitFailure; - if (phaseTimedOut || Date.now() >= deadlineAt) { - throw new Error(`cacheability probing exceeded its ${phaseTimeoutMs}ms phase deadline`); + if (phaseTimedOut || Date.now() >= getDeadlineAt()) { + throw new Error(`cacheability probing made no progress for ${phaseTimeoutMs}ms`); } const sortedRoutes = Object.fromEntries( Object.entries(routes).sort(([first], [second]) => first.localeCompare(second)), @@ -372,8 +519,11 @@ export async function probeStagedWorkerCacheability(options: { ); return { cacheableTargets, + classified: staticCount + dynamicCount, + dynamic: dynamicCount, failures, manifest: { buildId: options.buildId, routes: sortedRoutes, version: 1 }, - probed: options.targets.length, + probed, + skipped, }; } diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 70ec76e53..f5b21559c 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -32,6 +32,9 @@ export type CdnWarmOptions = { pagesDataPaths?: readonly string[]; /** Statically eligible App Route Handler request identities. */ routeHandlerPaths?: readonly string[]; + routePatterns?: Readonly< + Record + >; /** App Router ISR paths whose definitive client-navigation payload is warmed. */ rscPaths?: readonly string[]; /** App Router paths whose deterministic loading-boundary payload is warmed. */ @@ -87,6 +90,10 @@ export type CdnWarmRequestPlan = { paths: string[]; rscPaths: string[]; routeHandlerPaths?: string[]; + routePatterns?: Record< + string, + { kind: "app-page" | "app-route" | "pages-page"; pattern: string } + >; }; export type CdnWarmReadinessResult = { ready: true } | { error: string; ready: false }; @@ -101,6 +108,10 @@ export type PrerenderWarmPlan = { pagesPaths?: string[]; paths: string[]; routeHandlerPaths?: string[]; + routePatterns?: Record< + string, + { kind: "app-page" | "app-route" | "pages-page"; pattern: string } + >; rscBuildId?: string; rscPaths: string[]; }; @@ -157,6 +168,22 @@ function readPrerenderPathManifest(manifestPath: string): PrerenderPathManifest (manifest.routeHandlerPaths !== undefined && (!Array.isArray(manifest.routeHandlerPaths) || !manifest.routeHandlerPaths.every((pathname) => typeof pathname === "string"))) || + (manifest.routePatterns !== undefined && + (!manifest.routePatterns || + typeof manifest.routePatterns !== "object" || + Array.isArray(manifest.routePatterns) || + !Object.entries(manifest.routePatterns).every( + ([pathname, route]) => + pathname.startsWith("/") && + route !== null && + typeof route === "object" && + !Array.isArray(route) && + (route.kind === "app-page" || + route.kind === "app-route" || + route.kind === "pages-page") && + typeof route.pattern === "string" && + route.pattern.startsWith("/"), + ))) || (manifest.loadingShellPaths !== undefined && (!Array.isArray(manifest.loadingShellPaths) || !manifest.loadingShellPaths.every((pathname) => typeof pathname === "string"))) || @@ -224,6 +251,14 @@ export function readPrerenderWarmPlan( manifest.rscPaths !== undefined && manifest.rscBuildId !== undefined; const applyConfig = (pathname: string) => applyWarmPathConfig(pathname, manifest); + const routePatterns = manifest.routePatterns + ? Object.fromEntries( + Object.entries(manifest.routePatterns).map(([pathname, route]) => [ + applyConfig(pathname), + route, + ]), + ) + : undefined; let htmlPaths = pathPlan.paths; if (options?.includeFallbackShells === true) { const prerenderManifest = readPrerenderManifest( @@ -264,6 +299,7 @@ export function readPrerenderWarmPlan( ...(manifest.routeHandlerPaths ? { routeHandlerPaths: manifest.routeHandlerPaths.map(applyConfig) } : {}), + ...(routePatterns ? { routePatterns } : {}), }; } @@ -378,6 +414,7 @@ export type CdnWarmTarget = { label: string; pathname: string; sourcePathname: string; + route?: { kind: "app-page" | "app-route" | "pages-page"; pattern: string }; }; export async function createCdnWarmTargets( @@ -389,6 +426,7 @@ export async function createCdnWarmTargets( | "pagesDataPaths" | "paths" | "routeHandlerPaths" + | "routePatterns" | "rscPaths" >, ): Promise { @@ -408,6 +446,7 @@ export async function createCdnWarmTargets( label: `${pathname} (RSC full)`, pathname: createCanonicalRscRequestUrl(pathname), sourcePathname: pathname, + route: options.routePatterns?.[pathname], }); } @@ -424,6 +463,7 @@ export async function createCdnWarmTargets( label: `${pathname} (RSC loading shell)`, pathname: await createRscRequestUrl(pathname, loadingHeaders), sourcePathname: pathname, + route: options.routePatterns?.[pathname], }); } } @@ -437,6 +477,7 @@ export async function createCdnWarmTargets( label: pathname, pathname, sourcePathname: pathname, + route: options.routePatterns?.[pathname], }); } for (const pathname of new Set(options.pagesDataPaths ?? [])) { @@ -448,6 +489,7 @@ export async function createCdnWarmTargets( label: `${pathname} (Pages data)`, pathname, sourcePathname: pathname, + route: options.routePatterns?.[pathname], }); } for (const pathname of new Set(options.routeHandlerPaths ?? [])) { @@ -459,12 +501,13 @@ export async function createCdnWarmTargets( label: `${pathname} (Route Handler)`, pathname, sourcePathname: pathname, + route: options.routePatterns?.[pathname], }); } return requests; } -class CdnWarmProgress { +export class CdnOperationProgress { private readonly isTTY = process.stderr.isTTY; private lastLineLength = 0; @@ -1158,7 +1201,7 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise { config: parsed.config, skipBuild: parsed.skipBuild, dryRun: parsed.dryRun, + verbose: parsed.verbose, name: parsed.name, prerenderAll: parsed.prerenderAll, prerenderConcurrency: parsed.prerenderConcurrency, diff --git a/packages/cloudflare/src/deploy-help.ts b/packages/cloudflare/src/deploy-help.ts index c3e0d51de..4211fa0a2 100644 --- a/packages/cloudflare/src/deploy-help.ts +++ b/packages/cloudflare/src/deploy-help.ts @@ -17,6 +17,7 @@ export function formatDeployHelp(): string { --config Wrangler config path (default: wrangler.jsonc/json/toml) --skip-build Skip the build step (use existing dist/) --dry-run Validate setup without building or deploying + --verbose Print raw output from internal Wrangler commands --prerender-all Pre-render discovered routes after building (future releases will auto-populate the remote cache) --prerender-concurrency @@ -35,7 +36,8 @@ export function formatDeployHelp(): string { Optional staged path-discovery retry limit (default: derived from the discovery deadline) --warm-cdn-probe-timeout - Total cacheability-probe deadline (default: 120000) + Abort when cacheability probing makes no progress for + this duration (default: 120000) --warm-cdn-probe-retries Cacheability-probe retries (default: 2) --warm-cdn-certify With --experimental-warm-cdn-cache, re-request warmed diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 2f4ea2c5f..8ca406fe2 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -54,6 +54,7 @@ import { parseWranglerConfig, runTPR } from "./tpr.js"; import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "./version-headers.js"; import { createCdnWarmTargets, + CdnOperationProgress, DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS, readPrerenderWarmPlan, waitForCdnWarmTargetReadiness, @@ -110,6 +111,8 @@ export type DeployOptions = { skipBuild?: boolean; /** Dry run — validate setup but don't build or deploy */ dryRun?: boolean; + /** Print raw output from internal Wrangler commands. */ + verbose?: boolean; /** Pre-render all discovered routes into the dist output after building */ prerenderAll?: boolean; /** Maximum number of routes to prerender in parallel */ @@ -126,7 +129,7 @@ export type DeployOptions = { warmCdnDiscoveryTimeout?: number; /** Number of transient staged Worker path discovery retries */ warmCdnDiscoveryRetries?: number; - /** Maximum duration of staged Worker cacheability probing */ + /** Abort after this duration without a completed cacheability probe */ warmCdnProbeTimeout?: number; /** Number of transient staged Worker cacheability probe retries */ warmCdnProbeRetries?: number; @@ -223,6 +226,7 @@ const deployArgOptions = { config: { type: "string" }, "skip-build": { type: "boolean", default: false }, "dry-run": { type: "boolean", default: false }, + verbose: { type: "boolean", default: false }, "prerender-all": { type: "boolean", default: false }, "prerender-concurrency": { type: "string" }, "experimental-warm-cdn-cache": { type: "boolean", default: false }, @@ -273,6 +277,7 @@ export function parseDeployArgs(args: string[]) { config: values.config?.trim() || undefined, skipBuild: values["skip-build"], dryRun: values["dry-run"], + verbose: values.verbose, prerenderAll: values["prerender-all"], prerenderConcurrency: values["prerender-concurrency"] === undefined @@ -714,6 +719,7 @@ type CdnWarmDeployOptions = Pick< | "env" | "name" | "config" + | "verbose" | "warmCdnConcurrency" | "warmCdnTimeout" | "warmCdnRetries" @@ -738,6 +744,7 @@ type CdnWarmDeployOptions = Pick< | "loadingShellPaths" | "pagesDataPaths" | "routeHandlerPaths" + | "routePatterns" | "rscPaths" > & { /** Probe a staged Worker and upload the resulting manifest as a second version. */ @@ -825,6 +832,7 @@ async function deployUploadedVersionWithCdnWarmup( pagesDataPaths: [...(options.pagesDataPaths ?? [])], paths: [...paths], routeHandlerPaths: [...(options.routeHandlerPaths ?? [])], + routePatterns: options.routePatterns ? { ...options.routePatterns } : undefined, rscPaths: [...(options.rscPaths ?? [])], }; let discoveredWarmRequests = @@ -872,6 +880,7 @@ async function deployUploadedVersionWithCdnWarmup( pagesDataPaths: [...(plan.pagesDataPaths ?? [])], paths: [...plan.paths], routeHandlerPaths: [...(plan.routeHandlerPaths ?? [])], + routePatterns: plan.routePatterns ? { ...plan.routePatterns } : undefined, rscPaths: [...plan.rscPaths], }; discoveredWarmRequests = @@ -897,6 +906,7 @@ async function deployUploadedVersionWithCdnWarmup( pagesDataPaths: remainingWarmPlan.pagesDataPaths, paths: remainingWarmPlan.paths, routeHandlerPaths: remainingWarmPlan.routeHandlerPaths, + routePatterns: remainingWarmPlan.routePatterns, rscPaths: remainingWarmPlan.rscPaths, }, requireCacheHit = false, @@ -912,6 +922,7 @@ async function deployUploadedVersionWithCdnWarmup( loadingShellPaths: plan.loadingShellPaths, pagesDataPaths: plan.pagesDataPaths, routeHandlerPaths: plan.routeHandlerPaths, + routePatterns: plan.routePatterns, rscPaths: plan.rscPaths, concurrency: options.warmCdnConcurrency, phaseTimeoutMs: hasPreparedWarmPlan ? DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS : undefined, @@ -1436,6 +1447,7 @@ async function deployWithCacheabilityProbe( pagesPaths: discovered.pagesPaths ? [...discovered.pagesPaths] : undefined, paths: [...discovered.paths], routeHandlerPaths: [...(discovered.routeHandlerPaths ?? [])], + routePatterns: discovered.routePatterns ? { ...discovered.routePatterns } : undefined, rscPaths: [...discovered.rscPaths], }; if (!plan.appPaths && !plan.pagesPaths) { @@ -1452,6 +1464,7 @@ async function deployWithCacheabilityProbe( pagesDataPaths: plan.pagesDataPaths, paths: plan.paths, routeHandlerPaths: plan.routeHandlerPaths, + routePatterns: plan.routePatterns, rscPaths: plan.rscPaths, }); if (targets.length > 0) { @@ -1476,29 +1489,48 @@ async function deployWithCacheabilityProbe( } console.log( - ` CDN warmup: probing ${targets.length} exact request identit${targets.length === 1 ? "y" : "ies"}...`, + ` CDN warmup: classifying ${targets.length} exact request identit${targets.length === 1 ? "y" : "ies"}; paired App HTML/RSC identities share a completed render when safe...`, ); } else { console.log( " CDN warmup: no page request identities were discovered; embedding an empty fail-closed cacheability manifest.", ); } - const probe = await probeStagedWorkerCacheability({ - buildId: discovered.buildId, - concurrency: options.warmCdnConcurrency, - expectedResponseBuildId: plan.buildIdentity, - phaseTimeoutMs: options.warmCdnProbeTimeout ?? DEFAULT_CACHEABILITY_PROBE_PHASE_TIMEOUT_MS, - retries: - options.warmCdnProbeRetries ?? options.warmCdnRetries ?? DEFAULT_CACHEABILITY_PROBE_RETRIES, - retryDelayMs: DEFAULT_CACHEABILITY_PROBE_RETRY_DELAY_MS, - root, - targets, - targetUrl, - timeoutMs: options.warmCdnTimeout, - }); + const probeProgress = new CdnOperationProgress(); + let probe: Awaited>; + try { + probe = await probeStagedWorkerCacheability({ + buildId: discovered.buildId, + concurrency: options.warmCdnConcurrency, + expectedResponseBuildId: plan.buildIdentity, + phaseTimeoutMs: options.warmCdnProbeTimeout ?? DEFAULT_CACHEABILITY_PROBE_PHASE_TIMEOUT_MS, + retries: + options.warmCdnProbeRetries ?? + options.warmCdnRetries ?? + DEFAULT_CACHEABILITY_PROBE_RETRIES, + retryDelayMs: DEFAULT_CACHEABILITY_PROBE_RETRY_DELAY_MS, + root, + targets, + targetUrl, + timeoutMs: options.warmCdnTimeout, + onProgress(progress) { + probeProgress.update( + progress.completed, + progress.total, + `${progress.static} static, ${progress.dynamic} dynamic, ${progress.skipped} skipped`, + "Probing route cacheability", + ); + }, + }); + } finally { + probeProgress.finish(); + } + console.log( + ` CDN warmup: classified ${probe.classified} exact identities with ${probe.probed} render probe${probe.probed === 1 ? "" : "s"}; ${probe.dynamic} dynamic and ${probe.skipped} skipped by pattern proof.`, + ); if (probe.failures.length > 0) { throw new Error( - `Two-stage CDN warming failed to classify ${probe.failures.length}/${probe.probed} request(s). First failure: ${probe.failures[0]}`, + `Two-stage CDN warming failed to classify ${probe.failures.length}/${targets.length} request identities after ${probe.probed} render probe(s). First failure: ${probe.failures[0]}`, ); } const finalPlan: PrerenderWarmPlan & CdnWarmRequestPlan = { @@ -1537,6 +1569,7 @@ async function deployWithCacheabilityProbe( env: options.env, name: options.name, preview: options.preview, + verbose: options.verbose, }), ); assertDeploymentStateUnchanged( @@ -1559,6 +1592,7 @@ async function deployWithCacheabilityProbe( loadingShellPaths: prepared.plan.loadingShellPaths, pagesDataPaths: prepared.plan.pagesDataPaths, routeHandlerPaths: prepared.plan.routeHandlerPaths, + routePatterns: prepared.plan.routePatterns, rscPaths: prepared.plan.rscPaths, uploadedVersion: prepared.upload, }); @@ -1857,6 +1891,7 @@ export async function deploy(options: DeployOptions): Promise { env: deployEnv === "production" && !options.env ? undefined : deployEnv, name: options.name, config: options.config, + verbose: options.verbose, }; let url: string; diff --git a/packages/cloudflare/src/version-deploy.ts b/packages/cloudflare/src/version-deploy.ts index d8d96308c..65d0919e1 100644 --- a/packages/cloudflare/src/version-deploy.ts +++ b/packages/cloudflare/src/version-deploy.ts @@ -110,7 +110,9 @@ export function parseWranglerVersionUploadOutput(output: string): WranglerVersio } export function buildWranglerVersionUploadArgs( - options: Pick & { previewAlias?: string }, + options: Pick & { + previewAlias?: string; + }, ): WranglerVersionArgs { const args = ["versions", "upload"]; const env = options.env || (options.preview ? "preview" : undefined); @@ -131,7 +133,7 @@ export function buildWranglerVersionUploadArgs( export function buildWranglerVersionDeployArgs( versionTraffic: readonly WranglerVersionTraffic[], - options: Pick, + options: Pick, ): WranglerVersionArgs { const args = [ "versions", @@ -153,7 +155,7 @@ export function buildWranglerVersionDeployArgs( } export function buildWranglerDeploymentsStatusArgs( - options: Pick, + options: Pick, ): WranglerVersionArgs { const args = ["deployments", "status", "--json"]; const env = options.env || (options.preview ? "preview" : undefined); @@ -170,7 +172,7 @@ export function buildWranglerDeploymentsStatusArgs( } export function buildWranglerTriggersDeployArgs( - options: Pick, + options: Pick, ): WranglerVersionArgs { const args = ["triggers", "deploy"]; const env = options.env || (options.preview ? "preview" : undefined); @@ -190,6 +192,7 @@ function runWranglerCommand( root: string, args: string[], execute: typeof execFileSync = execFileSync, + verbose = false, ): string { const wranglerBin = resolveWranglerBin(root); const invocation = buildNodeCliInvocation(wranglerBin, args); @@ -200,7 +203,7 @@ function runWranglerCommand( shell: false, }; const output = execute(invocation.file, invocation.args, execOpts) as string; - if (output.trim()) { + if (verbose && output.trim()) { for (const line of output.trim().split("\n")) { console.log(` ${line}`); } @@ -269,7 +272,9 @@ export function parseWranglerDeploymentStatusOutput(output: string): WranglerDep export function runWranglerVersionUpload( root: string, - options: Pick & { previewAlias?: string }, + options: Pick & { + previewAlias?: string; + }, execute: typeof execFileSync = execFileSync, ): WranglerVersionUploadResult { const { args, env } = buildWranglerVersionUploadArgs(options); @@ -279,7 +284,9 @@ export function runWranglerVersionUpload( console.log("\n Uploading Worker version for production..."); } try { - return parseWranglerVersionUploadOutput(runWranglerCommand(root, args, execute)); + return parseWranglerVersionUploadOutput( + runWranglerCommand(root, args, execute, options.verbose === true), + ); } catch (error) { if (isMissingWorkerVersionUploadError(error)) { throw withInitialDeployRequiredMessage(); @@ -291,7 +298,7 @@ export function runWranglerVersionUpload( export function runWranglerVersionDeploy( root: string, versionTraffic: readonly WranglerVersionTraffic[], - options: Pick, + options: Pick, phase: "stage" | "promote-warmed" | "promote-uploaded" = "promote-uploaded", execute: typeof execFileSync = execFileSync, ): WranglerVersionDeployResult { @@ -304,27 +311,31 @@ export function runWranglerVersionDeploy( } else { console.log(`\n Promoting uploaded Worker version to ${target}...`); } - const output = runWranglerCommand(root, args, execute); + const output = runWranglerCommand(root, args, execute, options.verbose === true); return { deployedUrl: parseWorkersDevUrl(output), output }; } export function runWranglerDeploymentStatus( root: string, - options: Pick, + options: Pick, execute: typeof execFileSync = execFileSync, ): WranglerDeploymentStatus { const { args, env } = buildWranglerDeploymentsStatusArgs(options); - if (env) { - console.log(`\n Reading current Worker deployment for env: ${env}...`); - } else { - console.log("\n Reading current Worker deployment..."); + if (options.verbose) { + if (env) { + console.log(`\n Reading current Worker deployment for env: ${env}...`); + } else { + console.log("\n Reading current Worker deployment..."); + } } - return parseWranglerDeploymentStatusOutput(runWranglerCommand(root, args, execute)); + return parseWranglerDeploymentStatusOutput( + runWranglerCommand(root, args, execute, options.verbose === true), + ); } export function runWranglerTriggersDeploy( root: string, - options: Pick, + options: Pick, execute: typeof execFileSync = execFileSync, ): WranglerVersionDeployResult { const { args, env } = buildWranglerTriggersDeployArgs(options); @@ -333,6 +344,6 @@ export function runWranglerTriggersDeploy( } else { console.log("\n Applying Worker triggers..."); } - const output = runWranglerCommand(root, args, execute); + const output = runWranglerCommand(root, args, execute, options.verbose === true); return { deployedUrl: parseCdnWarmupDeploymentUrl(output), output }; } diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index b4c88279a..6c568fa4a 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -61,6 +61,11 @@ export type PrerenderPathManifest = { pagesDataPaths?: string[]; /** Public paths omitted because configured routes can replace their page response. */ excludedWarmPaths?: string[]; + /** Resolved route ownership for grouping cacheability probes without re-matching paths. */ + routePatterns?: Record< + string, + { kind: "app-page" | "app-route" | "pages-page"; pattern: string } + >; trailingSlash?: boolean; paths: string[]; }; @@ -788,6 +793,7 @@ async function resolveAppWarmPaths(options: { loadingShellPaths: string[]; pagesPaths: string[]; rscPaths: string[]; + routePatterns: Record; }> { const appRoutes = await appRouter(options.appDir, options.pageExtensions); const routeHandlerClassifications = new Map( @@ -810,6 +816,10 @@ async function resolveAppWarmPaths(options: { const htmlPaths: string[] = []; const loadingShellPaths: string[] = []; const pagesPaths: string[] = []; + const routePatterns: Record< + string, + { kind: "app-page" | "app-route" | "pages-page"; pattern: string } + > = {}; for (const pathname of options.paths) { const appMatch = matchAppRoute(pathname, appRoutes); // Pages Router i18n prefixes are routing metadata rather than part of the @@ -831,6 +841,7 @@ async function resolveAppWarmPaths(options: { if (!isPagesApiRequest) { htmlPaths.push(pathname); pagesPaths.push(pathname); + routePatterns[pathname] = { kind: "pages-page", pattern: pagesMatch.route.pattern }; } continue; } @@ -844,6 +855,7 @@ async function resolveAppWarmPaths(options: { const classification = routeHandlerClassifications.get(matchedAppRoute.routePath); if (classification?.hasGet && classification.staticGenerationEnabled) { appRoutePaths.push(pathname); + routePatterns[pathname] = { kind: "app-route", pattern: matchedAppRoute.pattern }; } continue; } @@ -860,11 +872,20 @@ async function resolveAppWarmPaths(options: { appPaths.push(pathname); htmlPaths.push(pathname); rscPaths.push(pathname); + routePatterns[pathname] = { kind: "app-page", pattern: matchedAppRoute.pattern }; if (appRouteHasMainTreeLoadingBoundary(matchedAppRoute)) { loadingShellPaths.push(pathname); } } - return { appPaths, appRoutePaths, htmlPaths, loadingShellPaths, pagesPaths, rscPaths }; + return { + appPaths, + appRoutePaths, + htmlPaths, + loadingShellPaths, + pagesPaths, + routePatterns, + rscPaths, + }; } function configuredRouteAffectsWarmPath( @@ -1092,6 +1113,7 @@ export async function emitPrerenderPathManifest( htmlPaths: discoveredAppPaths, loadingShellPaths: discoveredLoadingShellPaths, pagesPaths: resolvedPagesWarmPaths, + routePatterns: {}, rscPaths: discoveredAppPaths, }; const warmPaths = appDir ? appOwnedWarmPaths.htmlPaths : resolvedPagesWarmPaths; @@ -1126,6 +1148,7 @@ export async function emitPrerenderPathManifest( ...(rscBuildId ? { rscBuildId } : {}), ...(options.responseVary ? { responseVary: options.responseVary } : {}), ...(options.responseVary ? { rscPaths: appOwnedWarmPaths.rscPaths } : {}), + routePatterns: appOwnedWarmPaths.routePatterns, ...(appOwnedWarmPaths.appRoutePaths.length > 0 ? { routeHandlerPaths: appOwnedWarmPaths.appRoutePaths } : {}), diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 804d4d0c5..d43701ad1 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -96,6 +96,7 @@ import { beginRouteCacheability, isRouteCacheabilityIdentityProbe, isRouteCacheabilityProbe, + markRouteCacheabilityPatternDynamic, } from "vinext/shims/cacheability-classification"; type AppPageParams = Record; @@ -650,6 +651,11 @@ async function dispatchAppPageInner( const isForceStatic = dynamicConfig === "force-static"; const isDynamicError = dynamicConfig === "error"; const isForceDynamic = dynamicConfig === "force-dynamic"; + if (isRouteCacheabilityProbe() && (isForceDynamic || currentRevalidateSeconds === 0)) { + markRouteCacheabilityPatternDynamic( + isForceDynamic ? 'dynamic = "force-dynamic"' : "revalidate = 0", + ); + } const isPrerender = process.env.VINEXT_PRERENDER === "1"; const serveStreamingMetadata = shouldServeStreamingMetadata( options.request.headers.get("user-agent") ?? "", diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index ba6c99235..5ad3c6c23 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -43,6 +43,7 @@ type CacheabilityProbeResult = { kind?: "app-page" | "app-route" | "pages-page"; pattern?: string; reason?: string; + scope?: "identity" | "pattern"; state: CacheabilityProbeRouteState; status: number; version: 1; @@ -148,6 +149,9 @@ function probeResponse( kind: state.route?.kind, pattern: state.route?.pattern, reason: outcome.reason, + ...(routeState === "dynamic" + ? { scope: state.patternDynamicReason ? ("pattern" as const) : ("identity" as const) } + : {}), state: routeState, status, version: 1, @@ -752,6 +756,21 @@ export async function finalizeWorkerCacheabilityResponse( ); } + if (state.patternDynamicReason && !state.explicitConfigCachePolicy) { + // Route configuration is pattern-wide, but Next.js lets a matching + // next.config public cache policy override force-dynamic/revalidate=0. + // Config headers are applied before this Worker finalizer, so only bypass + // the render body when no explicit policy still needs completed-response + // classification. A real route 5xx above must never be hidden by pruning. + await response.body?.cancel().catch(() => {}); + return probeResponse( + state, + "dynamic", + { cacheable: false, reason: state.patternDynamicReason }, + response.status, + ); + } + const drainFailure = await drainProbeBody(response, state.captureDeadlineAt); if (drainFailure) { return probeResponse( diff --git a/packages/vinext/src/shims/cacheability-classification.ts b/packages/vinext/src/shims/cacheability-classification.ts index c45c67d19..688012f79 100644 --- a/packages/vinext/src/shims/cacheability-classification.ts +++ b/packages/vinext/src/shims/cacheability-classification.ts @@ -36,6 +36,8 @@ export type RouteCacheabilityState = { explicitResponseCachePolicy?: boolean; finalResponseVetoReason?: string; forcedDynamicReason?: string; + /** A route-config decision that applies to every concrete identity for this pattern. */ + patternDynamicReason?: string; frameworkResponseCachePolicy?: Partial>; mode: "admit" | "identity" | "probe"; outcome?: RouteCacheabilityOutcome; @@ -89,6 +91,13 @@ export function markRouteCacheabilityDynamic(reason: string): void { state.forcedDynamicReason = reason; } +/** Mark an effective route configuration that makes the whole pattern dynamic. */ +export function markRouteCacheabilityPatternDynamic(reason: string): void { + const state = readRouteCacheabilityState(); + if (!state) return; + state.patternDynamicReason = reason; +} + /** Read a request-specific routing veto without making the route globally dynamic. */ export function getRouteCacheabilityDynamicReason(): string | null { return readRouteCacheabilityState()?.forcedDynamicReason ?? null; diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index e52a40e12..aeade5f5f 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -135,13 +135,13 @@ describe("staged Worker cacheability probes", () => { expect(result.cacheableTargets).toEqual([target]); }); - it("bounds all target retries by one cacheability-probe phase deadline", async () => { + it("aborts when cacheability probing makes no progress", async () => { const root = createProbeRoot(); const fetchImpl = vi .fn() .mockResolvedValueOnce(new Response("staged version unavailable", { status: 503 })) .mockResolvedValueOnce(new Response("staged version unavailable", { status: 503 })) - // The phase deadline remains authoritative even if fetch ignores abort. + // The no-progress watchdog remains authoritative even if fetch ignores abort. .mockImplementation(() => new Promise(() => {})); await expect( @@ -156,11 +156,282 @@ describe("staged Worker cacheability probes", () => { targetUrl: "https://example.com", targets: [target("/one"), target("/two")], }), - ).rejects.toThrow("cacheability probing exceeded its 25ms phase deadline"); + ).rejects.toThrow("cacheability probing made no progress for 25ms"); expect(fetchImpl).toHaveBeenCalled(); expect(fetchImpl.mock.calls.length).toBeLessThanOrEqual(3); }); + it("allows a large serial workload to exceed the watchdog while requests keep completing", async () => { + const root = createProbeRoot(); + const progress: number[] = []; + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 1, + fetchImpl: async (input) => { + await new Promise((resolve) => setTimeout(resolve, 15)); + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + return Response.json({ + kind: "app-page", + pattern: pathname, + state: "static-candidate", + status: 200, + version: 1, + }); + }, + onProgress(update) { + progress.push(update.completed); + }, + phaseTimeoutMs: 25, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [target("/one"), target("/two"), target("/three")], + }); + + expect(result).toMatchObject({ classified: 3, probed: 3, skipped: 0 }); + expect(progress).toEqual([0, 1, 2, 3]); + }); + + it("classifies paired App HTML and RSC identities from one completed HTML render", async () => { + const root = createProbeRoot(); + const route = { kind: "app-page" as const, pattern: "/posts/:slug" }; + const html = { ...target("/posts/one"), route }; + const rsc = { + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-full" as const, + label: "/posts/one (RSC full)", + pathname: "/posts/one?_rsc", + route, + sourcePathname: "/posts/one", + }; + const fetchImpl = vi.fn(async () => + Response.json({ + kind: "app-page", + pattern: route.pattern, + state: "static-candidate", + status: 200, + version: 1, + }), + ); + + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [rsc, html], + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ classified: 2, probed: 1, skipped: 0 }); + expect(result.cacheableTargets).toEqual([html, rsc]); + expect(Object.values(result.manifest.routes).map((entry) => entry.representation)).toEqual([ + "html", + "rsc-full", + ]); + }); + + it("probes terminal HTML and RSC identities separately because their statuses can differ", async () => { + const root = createProbeRoot(); + const route = { kind: "app-page" as const, pattern: "/missing" }; + const html = { ...target("/missing"), route }; + const rsc = { + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-full" as const, + label: "/missing (RSC full)", + pathname: "/missing?_rsc", + route, + sourcePathname: "/missing", + }; + const fetchImpl = vi.fn(async (_input, init) => { + const isRsc = new Headers(init?.headers).get("RSC") === "1"; + return Response.json({ + kind: "app-page", + pattern: route.pattern, + state: "static-candidate", + status: isRsc ? 200 : 404, + version: 1, + }); + }); + + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [rsc, html], + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ classified: 2, probed: 2, skipped: 0 }); + expect(Object.values(result.manifest.routes)).toEqual([ + expect.objectContaining({ representation: "html", status: 404 }), + expect.objectContaining({ representation: "rsc-full", status: 200 }), + ]); + }); + + it("requires matching discovered route ownership before sharing an HTML classification", async () => { + const root = createProbeRoot(); + const html = target("/posts/one"); + const rsc = { + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-full" as const, + label: "/posts/one (RSC full)", + pathname: "/posts/one?_rsc", + sourcePathname: "/posts/one", + }; + const fetchImpl = vi.fn(async () => + Response.json({ + kind: "app-page", + pattern: "/posts/:slug", + state: "static-candidate", + status: 200, + version: 1, + }), + ); + + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [rsc, html], + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ classified: 2, probed: 2, skipped: 0 }); + }); + + it("uses pattern-wide dynamic proof to skip sibling HTML and RSC renders", async () => { + const root = createProbeRoot(); + const route = { kind: "app-page" as const, pattern: "/posts/:slug" }; + const htmlOne = { ...target("/posts/one"), route }; + const htmlTwo = { ...target("/posts/two"), route }; + const rsc = (slug: string) => ({ + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-full" as const, + label: `/posts/${slug} (RSC full)`, + pathname: `/posts/${slug}?_rsc`, + route, + sourcePathname: `/posts/${slug}`, + }); + const fetchImpl = vi.fn(async () => + Response.json({ + kind: "app-page", + pattern: route.pattern, + scope: "pattern", + state: "dynamic", + status: 204, + version: 1, + }), + ); + + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 1, + fetchImpl, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [rsc("one"), rsc("two"), htmlOne, htmlTwo], + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ classified: 1, dynamic: 1, probed: 1, skipped: 3 }); + expect(result.cacheableTargets).toEqual([]); + expect(result.manifest.routes).toEqual({}); + }); + + it("does not prune siblings from an identity-scoped dynamic observation", async () => { + const root = createProbeRoot(); + const route = { kind: "app-page" as const, pattern: "/posts/:slug" }; + const html = (slug: string) => ({ ...target(`/posts/${slug}`), route }); + const rsc = (slug: string) => ({ + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-full" as const, + label: `/posts/${slug} (RSC full)`, + pathname: `/posts/${slug}?_rsc`, + route, + sourcePathname: `/posts/${slug}`, + }); + const fetchImpl = vi.fn(async () => + Response.json({ + kind: "app-page", + pattern: route.pattern, + scope: "identity", + state: "dynamic", + status: 200, + version: 1, + }), + ); + + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 1, + fetchImpl, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [rsc("one"), rsc("two"), html("one"), html("two")], + }); + + expect(fetchImpl).toHaveBeenCalledTimes(4); + expect(result).toMatchObject({ classified: 4, dynamic: 4, probed: 4, skipped: 0 }); + }); + + it("classifies a nodejs.org-sized paired workload with one render per App path", async () => { + const root = createProbeRoot(); + const pathCount = 2_272; + const htmlTargets = Array.from({ length: pathCount }, (_, index) => { + const pathname = `/docs/${index}`; + return { + ...target(pathname), + route: { kind: "app-page" as const, pattern: "/docs/:slug" }, + }; + }); + const rscTargets = htmlTargets.map((htmlTarget) => ({ + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-full" as const, + label: `${htmlTarget.sourcePathname} (RSC full)`, + pathname: `${htmlTarget.sourcePathname}?_rsc`, + route: htmlTarget.route, + sourcePathname: htmlTarget.sourcePathname, + })); + const progress: number[] = []; + const fetchImpl = vi.fn(async () => + Response.json({ + kind: "app-page", + pattern: "/docs/:slug", + state: "static-candidate", + status: 200, + version: 1, + }), + ); + + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl, + onProgress(update) { + progress.push(update.completed); + }, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [...rscTargets, ...htmlTargets], + }); + + expect(fetchImpl).toHaveBeenCalledTimes(pathCount); + expect(result).toMatchObject({ + classified: pathCount * 2, + probed: pathCount, + skipped: 0, + }); + expect(progress.at(-1)).toBe(pathCount * 2); + }); + it("rejects oversized probe envelopes without buffering the full response", async () => { const root = createProbeRoot(); let cancelled = false; diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index c4e4ec0a4..9970c9375 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -429,7 +429,8 @@ describe("Cloudflare CDN warmup deploy flow", () => { const headers = new Headers(init?.headers); if (headers.get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1") { const pathname = new URL(formatFetchUrl(input)).pathname; - events.push(`probe:${pathname}`); + const isRsc = headers.get("RSC") === "1"; + events.push(`probe:${pathname}${isRsc ? ":rsc" : ""}`); if ( pathname === "/pages-about" || pathname === "/_next/data/app-build-a/pages-about.json" @@ -465,15 +466,21 @@ describe("Cloudflare CDN warmup deploy flow", () => { if (isReadinessFetch(input)) events.push("readiness"); else { const pathname = new URL(formatFetchUrl(input)).pathname; - const count = (cacheRequestCounts.get(pathname) ?? 0) + 1; - cacheRequestCounts.set(pathname, count); - events.push(`${count === 1 ? "warm" : "unexpected-second-request"}:${pathname}`); + const isRsc = headers.get("RSC") === "1"; + const cacheKey = `${pathname}${isRsc ? "?_rsc" : ""}`; + const count = (cacheRequestCounts.get(cacheKey) ?? 0) + 1; + cacheRequestCounts.set(cacheKey, count); + events.push(`${count === 1 ? "warm" : "unexpected-second-request"}:${cacheKey}`); } const pathname = new URL(formatFetchUrl(input)).pathname; - const cacheStatus = (cacheRequestCounts.get(pathname) ?? 0) > 1 ? "HIT" : "MISS"; - return pathname.startsWith("/_next/data/") - ? cacheablePagesData(cacheStatus) - : cacheableHtml("ok", cacheStatus); + const isRsc = headers.get("RSC") === "1"; + const cacheKey = `${pathname}${isRsc ? "?_rsc" : ""}`; + const cacheStatus = (cacheRequestCounts.get(cacheKey) ?? 0) > 1 ? "HIT" : "MISS"; + return isRsc + ? cacheableRsc() + : pathname.startsWith("/_next/data/") + ? cacheablePagesData(cacheStatus) + : cacheableHtml("ok", cacheStatus); }); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -489,7 +496,13 @@ describe("Cloudflare CDN warmup deploy flow", () => { pagesPaths: ["/pages-about"], paths: ["/about", "/dynamic", "/pages-about"], routeHandlerPaths: ["/api/data"], - rscPaths: [], + routePatterns: { + "/about": { kind: "app-page", pattern: "/about" }, + "/api/data": { kind: "app-route", pattern: "/api/data" }, + "/dynamic": { kind: "app-page", pattern: "/dynamic" }, + "/pages-about": { kind: "pages-page", pattern: "/pages-about" }, + }, + rscPaths: ["/about", "/dynamic"], }), warmCdnConcurrency: 1, warmCdnPromotionDelay: 0, @@ -501,6 +514,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(uploadCount).toBe(2); expect(statusCount).toBe(7); expect(Array.from(cacheRequestCounts.entries())).toEqual([ + ["/about?_rsc", 1], ["/_next/data/app-build-a/pages-about.json", 1], ["/api/data", 1], ["/about", 1], @@ -514,6 +528,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "readiness", "probe:/about", "probe:/dynamic", + "probe:/dynamic:rsc", "probe:/pages-about", "probe:/_next/data/app-build-a/pages-about.json", "probe:/api/data", @@ -525,6 +540,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "status-6", "triggers", "readiness", + "warm:/about?_rsc", "warm:/_next/data/app-build-a/pages-about.json", "warm:/api/data", "warm:/about", @@ -546,6 +562,13 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect.objectContaining({ kind: "app-page", pattern: "/about", + representation: "html", + state: "static-candidate", + }), + expect.objectContaining({ + kind: "app-page", + pattern: "/about", + representation: "rsc-full", state: "static-candidate", }), expect.objectContaining({ diff --git a/tests/cloudflare-version-deploy.test.ts b/tests/cloudflare-version-deploy.test.ts index a37acd79b..223156f15 100644 --- a/tests/cloudflare-version-deploy.test.ts +++ b/tests/cloudflare-version-deploy.test.ts @@ -8,6 +8,7 @@ import { parseWorkersDevUrl, parseWranglerDeploymentStatusOutput, parseWranglerVersionUploadOutput, + runWranglerDeploymentStatus, runWranglerVersionDeploy, runWranglerVersionUpload, } from "../packages/cloudflare/src/version-deploy.js"; @@ -131,6 +132,37 @@ describe("Cloudflare Wrangler version deployment helpers", () => { expect(log).toHaveBeenCalledWith("\n Promoting uploaded Worker version to env: staging..."); }); + it("hides raw Wrangler upload output by default and shows it in verbose mode", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const output = JSON.stringify({ + version: { id: "095f00a7-23a7-43b7-a227-e4c97cab5f22" }, + }); + const execute = vi.fn(() => output); + + runWranglerVersionUpload("/tmp/app", {}, execute as never); + expect(log).not.toHaveBeenCalledWith(` ${output}`); + + log.mockClear(); + runWranglerVersionUpload("/tmp/app", { verbose: true }, execute as never); + expect(log).toHaveBeenCalledWith(` ${output}`); + }); + + it("keeps deployment-status internals quiet unless verbose output is requested", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const output = JSON.stringify({ + id: "deployment-1", + versions: [{ version_id: "11111111-1111-4111-8111-111111111111", percentage: 100 }], + }); + const execute = vi.fn(() => output); + + runWranglerDeploymentStatus("/tmp/app", {}, execute as never); + expect(log).not.toHaveBeenCalled(); + + runWranglerDeploymentStatus("/tmp/app", { verbose: true }, execute as never); + expect(log).toHaveBeenCalledWith("\n Reading current Worker deployment..."); + expect(log).toHaveBeenCalledWith(` ${output}`); + }); + it("asks for an initial deploy without CDN pre-warm when the Worker does not exist yet", () => { vi.spyOn(console, "log").mockImplementation(() => {}); const execute = vi.fn(() => { diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 236c55efe..a8df242af 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -70,6 +70,7 @@ import { parseCdnWarmupDeploymentUrl, parseWorkerDeploymentUrl, } from "../packages/cloudflare/src/worker-deployment-url.js"; +import { formatDeployHelp } from "../packages/cloudflare/src/deploy-help.js"; // ─── Test Helpers ──────────────────────────────────────────────────────────── @@ -726,6 +727,15 @@ describe("parseDeployArgs", () => { } }); + it("forwards verbose output control through the deploy CLI", () => { + const cliSource = fs.readFileSync( + path.join(process.cwd(), "packages/cloudflare/src/cli.ts"), + "utf-8", + ); + + expect(cliSource).toContain("verbose: parsed.verbose"); + }); + it("defaults to production deploy with no flags", () => { const parsed = parseDeployArgs([]); expect(parsed.preview).toBe(false); @@ -733,6 +743,7 @@ describe("parseDeployArgs", () => { expect(parsed.name).toBeUndefined(); expect(parsed.skipBuild).toBe(false); expect(parsed.dryRun).toBe(false); + expect(parsed.verbose).toBe(false); expect(parsed.warmCdnCache).toBe(false); expect(parsed.warmCdnCertify).toBe(false); expect(parsed.dangerouslyPromoteOnCdnWarmError).toBe(false); @@ -767,10 +778,17 @@ describe("parseDeployArgs", () => { }); it("parses boolean flags", () => { - const parsed = parseDeployArgs(["--preview", "--skip-build", "--dry-run"]); + const parsed = parseDeployArgs(["--preview", "--skip-build", "--dry-run", "--verbose"]); expect(parsed.preview).toBe(true); expect(parsed.skipBuild).toBe(true); expect(parsed.dryRun).toBe(true); + expect(parsed.verbose).toBe(true); + }); + + it("documents verbose Wrangler output and no-progress probe timeouts", () => { + const help = formatDeployHelp(); + expect(help).toContain("--verbose"); + expect(help).toContain("Abort when cacheability probing makes no progress"); }); it("parses numeric TPR flags from string values", () => { diff --git a/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts index 928ea28a9..2f2e0aa0a 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts @@ -75,6 +75,31 @@ test("classifies completed App Page renders inside workerd", async ({ request }) version: 1, }); + // Next.js treats both effective force-dynamic and revalidate=0 segment + // configuration as pattern-wide dynamic decisions: + // test/e2e/app-dir/app-prefetch/prefetching.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-prefetch/prefetching.test.ts + // packages/next/src/build/utils.ts + // packages/next/src/server/app-render/create-component-tree.tsx + // This includes inherited layout configuration. The coordinator can prune + // later concrete identities only after the staged Worker certifies this + // authoritative pattern scope. + for (const pathname of [ + "/cacheability/pattern-force-dynamic", + "/cacheability/pattern-revalidate-zero", + ]) { + const patternDynamicProbe = await request.get(pathname, { headers }); + expect(patternDynamicProbe.ok()).toBe(true); + await expect(patternDynamicProbe.json()).resolves.toMatchObject({ + kind: "app-page", + pattern: pathname, + scope: "pattern", + state: "dynamic", + status: 200, + version: 1, + }); + } + // Ported from Next.js: // test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts const configPublicDynamicProbe = await request.get("/cacheability/config-public-dynamic", { diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-force-dynamic/page.tsx b/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-force-dynamic/page.tsx new file mode 100644 index 000000000..2c661374d --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-force-dynamic/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = "force-dynamic"; + +export default function PatternForceDynamicPage() { + return

force-dynamic pattern

; +} diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-revalidate-zero/layout.tsx b/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-revalidate-zero/layout.tsx new file mode 100644 index 000000000..99c188a04 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-revalidate-zero/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +export const revalidate = 0; + +export default function PatternRevalidateZeroLayout({ children }: { children: ReactNode }) { + return children; +} diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-revalidate-zero/page.tsx b/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-revalidate-zero/page.tsx new file mode 100644 index 000000000..6241488d8 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-revalidate-zero/page.tsx @@ -0,0 +1,3 @@ +export default function PatternRevalidateZeroPage() { + return

revalidate-zero pattern

; +} diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 853647700..394a49189 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -120,6 +120,12 @@ describe("prerender path manifest", () => { loadingShellPaths: ["/cached/intro", "/cached/featured"], rscBuildId: "rsc-build-a", responseVary: "verbatim", + routePatterns: { + "/": { kind: "app-page", pattern: "/" }, + "/cached/featured": { kind: "app-page", pattern: "/cached/:slug" }, + "/cached/intro": { kind: "app-page", pattern: "/cached/:slug" }, + "/dynamic": { kind: "app-page", pattern: "/dynamic" }, + }, rscPaths: ["/", "/dynamic", "/cached/intro", "/cached/featured"], trailingSlash: false, paths: ["/", "/dynamic", "/cached/intro", "/cached/featured"], From 06489ce66918754fbe5499ba7d5a0b50efe5c937 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 10:44:29 +0100 Subject: [PATCH 02/24] fix(cloudflare): preserve cache policy probe identities --- packages/cloudflare/src/cacheability-probe.ts | 51 +++++-- packages/cloudflare/src/cdn-warm.ts | 25 ++-- packages/cloudflare/src/deploy.ts | 2 - packages/vinext/src/build/prerender-paths.ts | 95 +++++++++++-- packages/vinext/src/config/config-matchers.ts | 2 +- tests/cloudflare-cacheability-probe.test.ts | 125 +++++++++++++++++- tests/cloudflare-cdn-warm-deploy.test.ts | 22 ++- .../cacheability-probe.spec.ts | 47 +++++++ .../config-public-pattern/[slug]/page.tsx | 13 ++ .../config-public-representation/page.tsx | 5 + tests/fixtures/ppr-impact-demo/next.config.ts | 9 ++ tests/prerender-paths.test.ts | 84 +++++++++++- 12 files changed, 424 insertions(+), 56 deletions(-) create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/config-public-pattern/[slug]/page.tsx create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/config-public-representation/page.tsx diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index e2c7df39c..552f4c625 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -147,12 +147,12 @@ async function probeTarget(options: { version: 1, }); for (let attempt = 0; attempt <= options.retries; attempt++) { - const remainingMs = options.getDeadlineAt() - Date.now(); - if (remainingMs <= 0) return phaseTimeoutPayload(); + if (options.getDeadlineAt() - Date.now() <= 0) return phaseTimeoutPayload(); const controller = new AbortController(); - const attemptTimeoutMs = Math.min(options.timeoutMs, remainingMs); + const requestDeadlineAt = Date.now() + options.timeoutMs; let timeout: ReturnType | undefined; + let timedOutBy: "phase" | "request" | null = null; let retryable = true; try { const url = new URL(options.target.pathname, options.targetUrl); @@ -185,10 +185,22 @@ async function probeTarget(options: { return { kind: "complete" as const, payload: await readProbeEnvelope(response) }; })(); const timedOut = new Promise((_resolve, reject) => { - timeout = setTimeout(() => { + const checkDeadline = (): void => { + const phaseDeadlineAt = options.getDeadlineAt(); + const deadlineAt = Math.min(requestDeadlineAt, phaseDeadlineAt); + const remainingMs = deadlineAt - Date.now(); + if (remainingMs > 0) { + timeout = setTimeout(checkDeadline, remainingMs); + return; + } + // Another concurrent request can extend the no-progress deadline + // after this attempt starts. Re-read it whenever the timer fires; + // only the per-request deadline itself remains fixed. + timedOutBy = requestDeadlineAt <= phaseDeadlineAt ? "request" : "phase"; controller.abort(); - reject(new DOMException(`Timed out after ${attemptTimeoutMs}ms`, "AbortError")); - }, attemptTimeoutMs); + reject(new DOMException("Probe deadline exceeded", "AbortError")); + }; + checkDeadline(); }); const result = await Promise.race([request, timedOut]); if (Date.now() >= options.getDeadlineAt()) return phaseTimeoutPayload(); @@ -196,10 +208,12 @@ async function probeTarget(options: { reason = result.reason; retryable = result.retryable; } catch (error) { - if (Date.now() >= options.getDeadlineAt()) return phaseTimeoutPayload(); + if (timedOutBy === "phase" || Date.now() >= options.getDeadlineAt()) { + return phaseTimeoutPayload(); + } reason = error instanceof Error && error.name === "AbortError" - ? `probe timed out after ${attemptTimeoutMs}ms` + ? `probe timed out after ${options.timeoutMs}ms` : error instanceof Error ? error.message : String(error); @@ -278,12 +292,19 @@ export async function probeStagedWorkerCacheability(options: { const patternDynamic = new Set(); const rscBySourcePath = new Map( options.targets - .filter((target) => target.kind === "rsc-full") + .filter( + (target) => + target.kind === "rsc-full" && + target.route?.cacheabilityProbe?.canReuseHtmlForRsc === true, + ) .map((target) => [target.sourcePathname, target] as const), ); const htmlSources = new Set( options.targets - .filter((target) => target.kind === "html") + .filter( + (target) => + target.kind === "html" && target.route?.cacheabilityProbe?.canReuseHtmlForRsc === true, + ) .map((target) => target.sourcePathname), ); const targets = options.targets.filter( @@ -396,7 +417,11 @@ export async function probeStagedWorkerCacheability(options: { if (result.state !== "static-candidate") { dynamicCount += 1; - if (result.scope === "pattern" && knownPattern === `${result.kind}\0${result.pattern}`) { + if ( + result.scope === "pattern" && + target.route?.cacheabilityProbe?.canPrunePattern === true && + knownPattern === `${result.kind}\0${result.pattern}` + ) { patternDynamic.add(`${result.kind}\0${result.pattern}`); } reportProgress(); @@ -493,7 +518,9 @@ export async function probeStagedWorkerCacheability(options: { const resultPatternKey = `${result.kind}\0${result.pattern}`; if ( result.state === "dynamic" && - (result.scope !== "pattern" || routeKey(target) !== resultPatternKey) + (result.scope !== "pattern" || + target.route?.cacheabilityProbe?.canPrunePattern !== true || + routeKey(target) !== resultPatternKey) ) { await classifyTarget(pairedRsc); } else if (result.state === "dynamic") { diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index f5b21559c..eb1234572 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -5,6 +5,7 @@ import { setTimeout as delay } from "node:timers/promises"; import { PRERENDER_PATHS_MANIFEST, type PrerenderPathManifest, + type PrerenderRoutePattern, } from "vinext/internal/build/prerender-paths"; import { getPrerenderedConcretePaths, @@ -32,9 +33,7 @@ export type CdnWarmOptions = { pagesDataPaths?: readonly string[]; /** Statically eligible App Route Handler request identities. */ routeHandlerPaths?: readonly string[]; - routePatterns?: Readonly< - Record - >; + routePatterns?: Readonly>; /** App Router ISR paths whose definitive client-navigation payload is warmed. */ rscPaths?: readonly string[]; /** App Router paths whose deterministic loading-boundary payload is warmed. */ @@ -90,10 +89,7 @@ export type CdnWarmRequestPlan = { paths: string[]; rscPaths: string[]; routeHandlerPaths?: string[]; - routePatterns?: Record< - string, - { kind: "app-page" | "app-route" | "pages-page"; pattern: string } - >; + routePatterns?: Record; }; export type CdnWarmReadinessResult = { ready: true } | { error: string; ready: false }; @@ -108,10 +104,7 @@ export type PrerenderWarmPlan = { pagesPaths?: string[]; paths: string[]; routeHandlerPaths?: string[]; - routePatterns?: Record< - string, - { kind: "app-page" | "app-route" | "pages-page"; pattern: string } - >; + routePatterns?: Record; rscBuildId?: string; rscPaths: string[]; }; @@ -182,7 +175,13 @@ function readPrerenderPathManifest(manifestPath: string): PrerenderPathManifest route.kind === "app-route" || route.kind === "pages-page") && typeof route.pattern === "string" && - route.pattern.startsWith("/"), + route.pattern.startsWith("/") && + (route.cacheabilityProbe === undefined || + (route.cacheabilityProbe !== null && + typeof route.cacheabilityProbe === "object" && + !Array.isArray(route.cacheabilityProbe) && + typeof route.cacheabilityProbe.canPrunePattern === "boolean" && + typeof route.cacheabilityProbe.canReuseHtmlForRsc === "boolean")), ))) || (manifest.loadingShellPaths !== undefined && (!Array.isArray(manifest.loadingShellPaths) || @@ -414,7 +413,7 @@ export type CdnWarmTarget = { label: string; pathname: string; sourcePathname: string; - route?: { kind: "app-page" | "app-route" | "pages-page"; pattern: string }; + route?: PrerenderRoutePattern; }; export async function createCdnWarmTargets( diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 8ca406fe2..e5340632e 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -55,7 +55,6 @@ import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "./version-headers.js"; import { createCdnWarmTargets, CdnOperationProgress, - DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS, readPrerenderWarmPlan, waitForCdnWarmTargetReadiness, warmCdnCache, @@ -925,7 +924,6 @@ async function deployUploadedVersionWithCdnWarmup( routePatterns: plan.routePatterns, rscPaths: plan.rscPaths, concurrency: options.warmCdnConcurrency, - phaseTimeoutMs: hasPreparedWarmPlan ? DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS : undefined, timeoutMs: options.warmCdnTimeout, retries: options.warmCdnRetries, requireCacheHit, diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 6c568fa4a..95718d99b 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -37,6 +37,17 @@ import { pagesRouteHasPriorityOverAppRoute } from "../server/hybrid-route-priori import { extractLocaleFromUrl, normalizeDefaultLocalePathname } from "../server/pages-i18n.js"; import { normalizePathTrailingSlash } from "vinext/shims/url-utils"; import { buildPagesDataHref } from "vinext/shims/internal/pages-data-url"; +import { CACHEABILITY_POLICY_HEADERS } from "vinext/shims/cacheability-classification"; + +export type PrerenderRoutePattern = { + kind: "app-page" | "app-route" | "pages-page"; + pattern: string; + /** Closed-world safety facts for probe coordinator optimizations. */ + cacheabilityProbe?: { + canPrunePattern: boolean; + canReuseHtmlForRsc: boolean; + }; +}; export type PrerenderPathManifest = { /** App Page HTML paths after hybrid route ownership has been resolved. */ @@ -62,10 +73,7 @@ export type PrerenderPathManifest = { /** Public paths omitted because configured routes can replace their page response. */ excludedWarmPaths?: string[]; /** Resolved route ownership for grouping cacheability probes without re-matching paths. */ - routePatterns?: Record< - string, - { kind: "app-page" | "app-route" | "pages-page"; pattern: string } - >; + routePatterns?: Record; trailingSlash?: boolean; paths: string[]; }; @@ -793,7 +801,7 @@ async function resolveAppWarmPaths(options: { loadingShellPaths: string[]; pagesPaths: string[]; rscPaths: string[]; - routePatterns: Record; + routePatterns: Record; }> { const appRoutes = await appRouter(options.appDir, options.pageExtensions); const routeHandlerClassifications = new Map( @@ -816,10 +824,7 @@ async function resolveAppWarmPaths(options: { const htmlPaths: string[] = []; const loadingShellPaths: string[] = []; const pagesPaths: string[] = []; - const routePatterns: Record< - string, - { kind: "app-page" | "app-route" | "pages-page"; pattern: string } - > = {}; + const routePatterns: Record = {}; for (const pathname of options.paths) { const appMatch = matchAppRoute(pathname, appRoutes); // Pages Router i18n prefixes are routing metadata rather than part of the @@ -888,6 +893,76 @@ async function resolveAppWarmPaths(options: { }; } +const CACHEABILITY_POLICY_HEADER_NAMES = new Set(CACHEABILITY_POLICY_HEADERS); + +function cachePolicyRuleMatchesWarmPath( + pathname: string, + rule: ResolvedNextConfig["headers"][number], + config: Pick, +): boolean { + const canonicalPathname = normalizePathTrailingSlash(pathname, config.trailingSlash); + const hostnames = [undefined, ...(config.i18n?.domains?.map((domain) => domain.domain) ?? [])]; + return hostnames.some((hostname) => { + const matchPathname = normalizeDefaultLocalePathname(canonicalPathname, config.i18n, { + hostname, + }); + return matchesRewriteSource(matchPathname, rule, { + basePath: config.basePath, + hadBasePath: true, + }); + }); +} + +/** + * Certify only probe collapses that cannot hide a path- or request-specific + * next.config cache policy. The manifest contains a closed set of concrete + * identities, so path uniformity is evaluated across that discovered set. + */ +function annotateCacheabilityProbeSafety( + routePatterns: Record, + config: Pick, +): Record { + const cachePolicyRules = config.headers.filter((rule) => + rule.headers.some((header) => CACHEABILITY_POLICY_HEADER_NAMES.has(header.key.toLowerCase())), + ); + const matchingRules = new Map( + Object.keys(routePatterns).map((pathname) => [ + pathname, + cachePolicyRules.filter((rule) => cachePolicyRuleMatchesWarmPath(pathname, rule, config)), + ]), + ); + const pathsByPattern = new Map(); + for (const [pathname, route] of Object.entries(routePatterns)) { + const key = `${route.kind}\0${route.pattern}`; + const paths = pathsByPattern.get(key) ?? []; + paths.push(pathname); + pathsByPattern.set(key, paths); + } + + return Object.fromEntries( + Object.entries(routePatterns).map(([pathname, route]) => { + const patternPaths = pathsByPattern.get(`${route.kind}\0${route.pattern}`) ?? [pathname]; + const relevantRules = new Set(patternPaths.flatMap((path) => matchingRules.get(path) ?? [])); + const canPrunePattern = Array.from(relevantRules).every( + (rule) => + !rule.has?.length && + !rule.missing?.length && + patternPaths.every((path) => matchingRules.get(path)?.includes(rule) === true), + ); + const canReuseHtmlForRsc = (matchingRules.get(pathname) ?? []).every( + (rule) => !rule.has?.length && !rule.missing?.length, + ); + return [ + pathname, + { + ...route, + cacheabilityProbe: { canPrunePattern, canReuseHtmlForRsc }, + }, + ]; + }), + ); +} + function configuredRouteAffectsWarmPath( pathname: string, config: Pick< @@ -1148,7 +1223,7 @@ export async function emitPrerenderPathManifest( ...(rscBuildId ? { rscBuildId } : {}), ...(options.responseVary ? { responseVary: options.responseVary } : {}), ...(options.responseVary ? { rscPaths: appOwnedWarmPaths.rscPaths } : {}), - routePatterns: appOwnedWarmPaths.routePatterns, + routePatterns: annotateCacheabilityProbeSafety(appOwnedWarmPaths.routePatterns, config), ...(appOwnedWarmPaths.appRoutePaths.length > 0 ? { routeHandlerPaths: appOwnedWarmPaths.appRoutePaths } : {}), diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index 6347d4fcc..969467309 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -1013,7 +1013,7 @@ export function matchRewrite( */ export function matchesRewriteSource( pathname: string, - rewrite: NextRewrite, + rewrite: Pick, basePathState: BasePathMatchState = _BASEPATH_DEFAULT, ): boolean { return ( diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index aeade5f5f..d2727462e 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -38,6 +38,12 @@ describe("staged Worker cacheability probes", () => { sourcePathname: pathname, }); + const optimizableRoute = (pattern: string) => ({ + cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: true }, + kind: "app-page" as const, + pattern, + }); + const createStaticProbeFetch = () => vi.fn(async (input) => { const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; @@ -168,7 +174,7 @@ describe("staged Worker cacheability probes", () => { buildId: "application-build", concurrency: 1, fetchImpl: async (input) => { - await new Promise((resolve) => setTimeout(resolve, 15)); + await new Promise((resolve) => setTimeout(resolve, 40)); const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; return Response.json({ kind: "app-page", @@ -181,20 +187,47 @@ describe("staged Worker cacheability probes", () => { onProgress(update) { progress.push(update.completed); }, - phaseTimeoutMs: 25, + phaseTimeoutMs: 250, retries: 0, root, targetUrl: "https://example.com", - targets: [target("/one"), target("/two"), target("/three")], + targets: Array.from({ length: 7 }, (_, index) => target(`/serial-${index}`)), + }); + + expect(result).toMatchObject({ classified: 7, probed: 7, skipped: 0 }); + expect(progress).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); + }); + + it("extends an in-flight probe watchdog when another request makes progress", async () => { + const root = createProbeRoot(); + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 2, + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + await new Promise((resolve) => setTimeout(resolve, pathname === "/slow" ? 220 : 80)); + return Response.json({ + kind: "app-page", + pattern: pathname, + state: "static-candidate", + status: 200, + version: 1, + }); + }, + phaseTimeoutMs: 150, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [target("/slow"), target("/fast-one"), target("/fast-two")], + timeoutMs: 1_000, }); expect(result).toMatchObject({ classified: 3, probed: 3, skipped: 0 }); - expect(progress).toEqual([0, 1, 2, 3]); }); it("classifies paired App HTML and RSC identities from one completed HTML render", async () => { const root = createProbeRoot(); - const route = { kind: "app-page" as const, pattern: "/posts/:slug" }; + const route = optimizableRoute("/posts/:slug"); const html = { ...target("/posts/one"), route }; const rsc = { headers: { Accept: "text/x-component", RSC: "1" }, @@ -307,7 +340,7 @@ describe("staged Worker cacheability probes", () => { it("uses pattern-wide dynamic proof to skip sibling HTML and RSC renders", async () => { const root = createProbeRoot(); - const route = { kind: "app-page" as const, pattern: "/posts/:slug" }; + const route = optimizableRoute("/posts/:slug"); const htmlOne = { ...target("/posts/one"), route }; const htmlTwo = { ...target("/posts/two"), route }; const rsc = (slug: string) => ({ @@ -345,6 +378,84 @@ describe("staged Worker cacheability probes", () => { expect(result.manifest.routes).toEqual({}); }); + it("does not prune siblings when a config cache policy varies within the route pattern", async () => { + const root = createProbeRoot(); + const route = { + cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: true }, + kind: "app-page" as const, + pattern: "/posts/:slug", + }; + const fetchImpl = vi.fn(async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + return Response.json({ + kind: "app-page", + pattern: route.pattern, + scope: pathname === "/posts/ordinary" ? "pattern" : undefined, + state: pathname === "/posts/ordinary" ? "dynamic" : "static-candidate", + status: 200, + version: 1, + }); + }); + + const special = { ...target("/posts/special"), route }; + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 1, + fetchImpl, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [{ ...target("/posts/ordinary"), route }, special], + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ classified: 2, dynamic: 1, probed: 2, skipped: 0 }); + expect(result.cacheableTargets).toEqual([special]); + }); + + it("probes RSC independently when config cache policy conditions distinguish identities", async () => { + const root = createProbeRoot(); + const route = { + cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: false }, + kind: "app-page" as const, + pattern: "/conditional", + }; + const html = { ...target("/conditional"), route }; + const rsc = { + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-full" as const, + label: "/conditional (RSC full)", + pathname: "/conditional?_rsc", + route, + sourcePathname: "/conditional", + }; + const fetchImpl = vi.fn(async (_input, init) => { + const isRsc = new Headers(init?.headers).get("RSC") === "1"; + return Response.json({ + kind: "app-page", + pattern: route.pattern, + scope: isRsc ? "pattern" : undefined, + state: isRsc ? "dynamic" : "static-candidate", + status: 200, + version: 1, + }); + }); + + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 1, + fetchImpl, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [rsc, html], + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ classified: 2, dynamic: 1, probed: 2, skipped: 0 }); + expect(result.cacheableTargets).toEqual([html]); + }); + it("does not prune siblings from an identity-scoped dynamic observation", async () => { const root = createProbeRoot(); const route = { kind: "app-page" as const, pattern: "/posts/:slug" }; @@ -389,7 +500,7 @@ describe("staged Worker cacheability probes", () => { const pathname = `/docs/${index}`; return { ...target(pathname), - route: { kind: "app-page" as const, pattern: "/docs/:slug" }, + route: optimizableRoute("/docs/:slug"), }; }); const rscTargets = htmlTargets.map((htmlTarget) => ({ diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 9970c9375..d48495606 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -497,9 +497,17 @@ describe("Cloudflare CDN warmup deploy flow", () => { paths: ["/about", "/dynamic", "/pages-about"], routeHandlerPaths: ["/api/data"], routePatterns: { - "/about": { kind: "app-page", pattern: "/about" }, + "/about": { + cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: true }, + kind: "app-page", + pattern: "/about", + }, "/api/data": { kind: "app-route", pattern: "/api/data" }, - "/dynamic": { kind: "app-page", pattern: "/dynamic" }, + "/dynamic": { + cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: true }, + kind: "app-page", + pattern: "/dynamic", + }, "/pages-about": { kind: "pages-page", pattern: "/pages-about" }, }, rscPaths: ["/about", "/dynamic"], @@ -1020,7 +1028,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { }, ); - it("bounds queued prepared cache fills by one hard phase deadline", async () => { + it("lets prepared cache fills exceed the readiness window while requests keep completing", async () => { writeTwoStageWorkerArtifact(); let now = 0; vi.spyOn(Date, "now").mockImplementation(() => now); @@ -1032,7 +1040,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { } if (isReadinessFetch(input)) return cacheableHtml(); fillCalls++; - now = 120_001; + now += 120_001; return cacheableHtml(); }); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -1054,9 +1062,9 @@ describe("Cloudflare CDN warmup deploy flow", () => { warmCdnReadinessProbes: 1, warmCdnRetries: 0, }), - ).rejects.toThrow("CDN warmup exceeded its 120000ms phase deadline"); - expect(fillCalls).toBe(1); - expect(wrangler.promoted).toBe(false); + ).resolves.toBe("https://my-worker.example.workers.dev"); + expect(fillCalls).toBe(2); + expect(wrangler.promoted).toBe(true); }); it("uses the dedicated cacheability-probe retry budget before the legacy warm fallback", async () => { diff --git a/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts index 2f2e0aa0a..e1a15daad 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts @@ -114,6 +114,53 @@ test("classifies completed App Page renders inside workerd", async ({ request }) version: 1, }); + const ordinaryPatternProbe = await request.get("/cacheability/config-public-pattern/ordinary", { + headers, + }); + await expect(ordinaryPatternProbe.json()).resolves.toMatchObject({ + kind: "app-page", + pattern: "/cacheability/config-public-pattern/:slug", + scope: "pattern", + state: "dynamic", + status: 200, + version: 1, + }); + const specialPatternProbe = await request.get("/cacheability/config-public-pattern/special", { + headers, + }); + await expect(specialPatternProbe.json()).resolves.toMatchObject({ + cacheControl: "s-maxage=33", + kind: "app-page", + pattern: "/cacheability/config-public-pattern/:slug", + state: "static-candidate", + status: 200, + version: 1, + }); + + const representationHtmlProbe = await request.get("/cacheability/config-public-representation", { + headers, + }); + await expect(representationHtmlProbe.json()).resolves.toMatchObject({ + cacheControl: "s-maxage=34", + kind: "app-page", + pattern: "/cacheability/config-public-representation", + state: "static-candidate", + status: 200, + version: 1, + }); + const representationRscProbe = await request.get( + "/cacheability/config-public-representation?_rsc", + { headers: { ...headers, Accept: "text/x-component", RSC: "1" } }, + ); + await expect(representationRscProbe.json()).resolves.toMatchObject({ + kind: "app-page", + pattern: "/cacheability/config-public-representation", + scope: "pattern", + state: "dynamic", + status: 200, + version: 1, + }); + const staticRouteHandlerProbe = await request.get("/cacheability/route-handler-static", { headers: { ...headers, Accept: "*/*" }, }); diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/config-public-pattern/[slug]/page.tsx b/tests/fixtures/ppr-impact-demo/app/cacheability/config-public-pattern/[slug]/page.tsx new file mode 100644 index 000000000..b3011ae75 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/config-public-pattern/[slug]/page.tsx @@ -0,0 +1,13 @@ +export const dynamic = "force-dynamic"; + +export function generateStaticParams() { + return [{ slug: "ordinary" }, { slug: "special" }]; +} + +export default async function ConfigPublicPatternPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + return

config policy slug: {(await params).slug}

; +} diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/config-public-representation/page.tsx b/tests/fixtures/ppr-impact-demo/app/cacheability/config-public-representation/page.tsx new file mode 100644 index 000000000..756de6c16 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/config-public-representation/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = "force-dynamic"; + +export default function ConfigPublicRepresentationPage() { + return

HTML-only config cache policy

; +} diff --git a/tests/fixtures/ppr-impact-demo/next.config.ts b/tests/fixtures/ppr-impact-demo/next.config.ts index 65add0b37..ab1b189ea 100644 --- a/tests/fixtures/ppr-impact-demo/next.config.ts +++ b/tests/fixtures/ppr-impact-demo/next.config.ts @@ -26,6 +26,15 @@ export default { source: "/cacheability/config-public-dynamic", headers: [{ key: "Cache-Control", value: "s-maxage=32" }], }, + { + source: "/cacheability/config-public-pattern/special", + headers: [{ key: "Cache-Control", value: "s-maxage=33" }], + }, + { + source: "/cacheability/config-public-representation", + missing: [{ type: "query", key: "_rsc", value: ".*" }], + headers: [{ key: "Cache-Control", value: "s-maxage=34" }], + }, { source: "/cacheability/route-handler-config-public-late-error", headers: [{ key: "Cache-Control", value: "public, s-maxage=60" }], diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 394a49189..4825b9d0b 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -38,6 +38,12 @@ describe("prerender path manifest", () => { ) { return Response.json([{ slug: "intro" }, { slug: "featured" }]); } + if ( + url.pathname === "/__vinext/prerender/static-params" && + url.searchParams.get("pattern") === "/policy/:slug" + ) { + return Response.json([{ slug: "ordinary" }, { slug: "special" }]); + } if ( url.pathname === "/__vinext/prerender/static-params" && url.searchParams.get("pattern") === "/:path+" @@ -121,10 +127,26 @@ describe("prerender path manifest", () => { rscBuildId: "rsc-build-a", responseVary: "verbatim", routePatterns: { - "/": { kind: "app-page", pattern: "/" }, - "/cached/featured": { kind: "app-page", pattern: "/cached/:slug" }, - "/cached/intro": { kind: "app-page", pattern: "/cached/:slug" }, - "/dynamic": { kind: "app-page", pattern: "/dynamic" }, + "/": { + cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: true }, + kind: "app-page", + pattern: "/", + }, + "/cached/featured": { + cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: true }, + kind: "app-page", + pattern: "/cached/:slug", + }, + "/cached/intro": { + cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: true }, + kind: "app-page", + pattern: "/cached/:slug", + }, + "/dynamic": { + cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: true }, + kind: "app-page", + pattern: "/dynamic", + }, }, rscPaths: ["/", "/dynamic", "/cached/intro", "/cached/featured"], trailingSlash: false, @@ -149,6 +171,60 @@ describe("prerender path manifest", () => { expect(closeMock).toHaveBeenCalledOnce(); }); + it("marks probe collapses unsafe when config cache policy varies by path or request", async () => { + // Next.js applies pathname-specific custom Cache-Control to dynamic App + // routes and evaluates has/missing conditions against each request: + // test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts + // test/e2e/custom-routes/custom-routes.test.ts + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/policy/[slug]/page.tsx", + [ + "export const dynamic = 'force-dynamic';", + "export function generateStaticParams() { return [{ slug: 'ordinary' }, { slug: 'special' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile( + "app/conditional/page.tsx", + "export const dynamic = 'force-dynamic'; export default function Page() { return null; }\n", + ); + writeFile( + "next.config.mjs", + [ + "export default {", + " headers: async () => [", + " { source: '/policy/special', headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },", + " { source: '/conditional', missing: [{ type: 'query', key: '_rsc' }], headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },", + " ],", + "};", + ].join("\n"), + ); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + buildIdentity: "response-header", + responseVary: "verbatim", + }); + + expect(manifest?.routePatterns).toMatchObject({ + "/conditional": { + cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: false }, + }, + "/policy/ordinary": { + cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: true }, + }, + "/policy/special": { + cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: true }, + }, + }); + }); + it("discovers only Next.js-static Route Handler GET identities", async () => { // Ported from Next.js static eligibility and dynamic Route Handler params: // packages/next/src/server/route-modules/app-route/helpers/is-static-gen-enabled.ts From c0e329f18e1e28c642e4e05c9473ec65f1649d70 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 10:46:15 +0100 Subject: [PATCH 03/24] test(cache): cover conditional response vetoes --- packages/vinext/src/build/prerender-paths.ts | 24 ++++++++++++++++---- tests/prerender-paths.test.ts | 5 ++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 95718d99b..dca5cbe72 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -894,6 +894,11 @@ async function resolveAppWarmPaths(options: { } const CACHEABILITY_POLICY_HEADER_NAMES = new Set(CACHEABILITY_POLICY_HEADERS); +const CACHEABILITY_IDENTITY_HEADER_NAMES = new Set([ + ...CACHEABILITY_POLICY_HEADERS, + "set-cookie", + "vary", +]); function cachePolicyRuleMatchesWarmPath( pathname: string, @@ -925,12 +930,21 @@ function annotateCacheabilityProbeSafety( const cachePolicyRules = config.headers.filter((rule) => rule.headers.some((header) => CACHEABILITY_POLICY_HEADER_NAMES.has(header.key.toLowerCase())), ); - const matchingRules = new Map( + const identityRules = config.headers.filter((rule) => + rule.headers.some((header) => CACHEABILITY_IDENTITY_HEADER_NAMES.has(header.key.toLowerCase())), + ); + const matchingPolicyRules = new Map( Object.keys(routePatterns).map((pathname) => [ pathname, cachePolicyRules.filter((rule) => cachePolicyRuleMatchesWarmPath(pathname, rule, config)), ]), ); + const matchingIdentityRules = new Map( + Object.keys(routePatterns).map((pathname) => [ + pathname, + identityRules.filter((rule) => cachePolicyRuleMatchesWarmPath(pathname, rule, config)), + ]), + ); const pathsByPattern = new Map(); for (const [pathname, route] of Object.entries(routePatterns)) { const key = `${route.kind}\0${route.pattern}`; @@ -942,14 +956,16 @@ function annotateCacheabilityProbeSafety( return Object.fromEntries( Object.entries(routePatterns).map(([pathname, route]) => { const patternPaths = pathsByPattern.get(`${route.kind}\0${route.pattern}`) ?? [pathname]; - const relevantRules = new Set(patternPaths.flatMap((path) => matchingRules.get(path) ?? [])); + const relevantRules = new Set( + patternPaths.flatMap((path) => matchingPolicyRules.get(path) ?? []), + ); const canPrunePattern = Array.from(relevantRules).every( (rule) => !rule.has?.length && !rule.missing?.length && - patternPaths.every((path) => matchingRules.get(path)?.includes(rule) === true), + patternPaths.every((path) => matchingPolicyRules.get(path)?.includes(rule) === true), ); - const canReuseHtmlForRsc = (matchingRules.get(pathname) ?? []).every( + const canReuseHtmlForRsc = (matchingIdentityRules.get(pathname) ?? []).every( (rule) => !rule.has?.length && !rule.missing?.length, ); return [ diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 4825b9d0b..c93670343 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -192,6 +192,7 @@ describe("prerender path manifest", () => { "app/conditional/page.tsx", "export const dynamic = 'force-dynamic'; export default function Page() { return null; }\n", ); + writeFile("app/cookie/page.tsx", "export default function Page() { return null; }\n"); writeFile( "next.config.mjs", [ @@ -199,6 +200,7 @@ describe("prerender path manifest", () => { " headers: async () => [", " { source: '/policy/special', headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },", " { source: '/conditional', missing: [{ type: 'query', key: '_rsc' }], headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },", + " { source: '/cookie', has: [{ type: 'query', key: '_rsc', value: '.*' }], headers: [{ key: 'Set-Cookie', value: 'rsc=1' }] },", " ],", "};", ].join("\n"), @@ -216,6 +218,9 @@ describe("prerender path manifest", () => { "/conditional": { cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: false }, }, + "/cookie": { + cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: false }, + }, "/policy/ordinary": { cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: true }, }, From 28890067f155b6fe4e8b8195d06d905602ebe00f Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 10:47:58 +0100 Subject: [PATCH 04/24] fix(cache): match header policy sources exactly --- packages/vinext/src/build/prerender-paths.ts | 22 ++++++++++++++----- packages/vinext/src/config/config-matchers.ts | 2 +- tests/prerender-paths.test.ts | 5 +++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index dca5cbe72..f6b34023f 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -32,7 +32,7 @@ import { VINEXT_PRERENDER_SECRET_HEADER } from "../server/headers.js"; import type { VinextRouteRootConfig } from "../config/prerender.js"; import { enterPrerenderPhase } from "./prerender-phase.js"; import type { CdnCacheAdapterCapabilities } from "../cache/cache-adapters-virtual.js"; -import { matchesRewriteSource } from "../config/config-matchers.js"; +import { matchHeaders, matchesRewriteSource } from "../config/config-matchers.js"; import { pagesRouteHasPriorityOverAppRoute } from "../server/hybrid-route-priority.js"; import { extractLocaleFromUrl, normalizeDefaultLocalePathname } from "../server/pages-i18n.js"; import { normalizePathTrailingSlash } from "vinext/shims/url-utils"; @@ -911,10 +911,22 @@ function cachePolicyRuleMatchesWarmPath( const matchPathname = normalizeDefaultLocalePathname(canonicalPathname, config.i18n, { hostname, }); - return matchesRewriteSource(matchPathname, rule, { - basePath: config.basePath, - hadBasePath: true, - }); + let sourceMatched = false; + matchHeaders( + matchPathname, + [rule], + { + cookies: {}, + headers: new Headers(), + host: hostname ?? "", + query: new URLSearchParams(), + }, + { basePath: config.basePath, hadBasePath: true }, + () => { + sourceMatched = true; + }, + ); + return sourceMatched; }); } diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index 969467309..6347d4fcc 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -1013,7 +1013,7 @@ export function matchRewrite( */ export function matchesRewriteSource( pathname: string, - rewrite: Pick, + rewrite: NextRewrite, basePathState: BasePathMatchState = _BASEPATH_DEFAULT, ): boolean { return ( diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index c93670343..a9c904992 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -193,6 +193,7 @@ describe("prerender path manifest", () => { "export const dynamic = 'force-dynamic'; export default function Page() { return null; }\n", ); writeFile("app/cookie/page.tsx", "export default function Page() { return null; }\n"); + writeFile("app/wildcard/path/page.tsx", "export default function Page() { return null; }\n"); writeFile( "next.config.mjs", [ @@ -201,6 +202,7 @@ describe("prerender path manifest", () => { " { source: '/policy/special', headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },", " { source: '/conditional', missing: [{ type: 'query', key: '_rsc' }], headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },", " { source: '/cookie', has: [{ type: 'query', key: '_rsc', value: '.*' }], headers: [{ key: 'Set-Cookie', value: 'rsc=1' }] },", + " { source: '/wildcard/*', missing: [{ type: 'query', key: '_rsc', value: '.*' }], headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },", " ],", "};", ].join("\n"), @@ -227,6 +229,9 @@ describe("prerender path manifest", () => { "/policy/special": { cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: true }, }, + "/wildcard/path": { + cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: false }, + }, }); }); From 8bb244bb73c0417b2528a70333e91a5f92b14dc4 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 10:49:42 +0100 Subject: [PATCH 05/24] perf(cache): compute probe safety per pattern --- packages/vinext/src/build/prerender-paths.ts | 38 +++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index f6b34023f..1add734c9 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -948,13 +948,17 @@ function annotateCacheabilityProbeSafety( const matchingPolicyRules = new Map( Object.keys(routePatterns).map((pathname) => [ pathname, - cachePolicyRules.filter((rule) => cachePolicyRuleMatchesWarmPath(pathname, rule, config)), + new Set( + cachePolicyRules.filter((rule) => cachePolicyRuleMatchesWarmPath(pathname, rule, config)), + ), ]), ); const matchingIdentityRules = new Map( Object.keys(routePatterns).map((pathname) => [ pathname, - identityRules.filter((rule) => cachePolicyRuleMatchesWarmPath(pathname, rule, config)), + new Set( + identityRules.filter((rule) => cachePolicyRuleMatchesWarmPath(pathname, rule, config)), + ), ]), ); const pathsByPattern = new Map(); @@ -964,20 +968,28 @@ function annotateCacheabilityProbeSafety( paths.push(pathname); pathsByPattern.set(key, paths); } - - return Object.fromEntries( - Object.entries(routePatterns).map(([pathname, route]) => { - const patternPaths = pathsByPattern.get(`${route.kind}\0${route.pattern}`) ?? [pathname]; - const relevantRules = new Set( - patternPaths.flatMap((path) => matchingPolicyRules.get(path) ?? []), - ); - const canPrunePattern = Array.from(relevantRules).every( + const canPrunePatterns = new Map(); + for (const [patternKey, patternPaths] of pathsByPattern) { + const relevantRules = new Set(); + for (const path of patternPaths) { + for (const rule of matchingPolicyRules.get(path) ?? []) relevantRules.add(rule); + } + canPrunePatterns.set( + patternKey, + Array.from(relevantRules).every( (rule) => !rule.has?.length && !rule.missing?.length && - patternPaths.every((path) => matchingPolicyRules.get(path)?.includes(rule) === true), - ); - const canReuseHtmlForRsc = (matchingIdentityRules.get(pathname) ?? []).every( + patternPaths.every((path) => matchingPolicyRules.get(path)?.has(rule) === true), + ), + ); + } + + return Object.fromEntries( + Object.entries(routePatterns).map(([pathname, route]) => { + const patternKey = `${route.kind}\0${route.pattern}`; + const canPrunePattern = canPrunePatterns.get(patternKey) ?? false; + const canReuseHtmlForRsc = Array.from(matchingIdentityRules.get(pathname) ?? []).every( (rule) => !rule.has?.length && !rule.missing?.length, ); return [ From 957e20b13e8f960e596f18ba451a30386a1ec676 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 10:53:47 +0100 Subject: [PATCH 06/24] fix(build): omit empty route ownership metadata --- packages/vinext/src/build/prerender-paths.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 1add734c9..3d5433a73 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -1244,6 +1244,7 @@ export async function emitPrerenderPathManifest( "", ), ); + const routePatterns = annotateCacheabilityProbeSafety(appOwnedWarmPaths.routePatterns, config); const manifest: PrerenderPathManifest = { ...(appDir ? { appPaths: appOwnedWarmPaths.appPaths } : {}), @@ -1263,7 +1264,7 @@ export async function emitPrerenderPathManifest( ...(rscBuildId ? { rscBuildId } : {}), ...(options.responseVary ? { responseVary: options.responseVary } : {}), ...(options.responseVary ? { rscPaths: appOwnedWarmPaths.rscPaths } : {}), - routePatterns: annotateCacheabilityProbeSafety(appOwnedWarmPaths.routePatterns, config), + ...(Object.keys(routePatterns).length > 0 ? { routePatterns } : {}), ...(appOwnedWarmPaths.appRoutePaths.length > 0 ? { routeHandlerPaths: appOwnedWarmPaths.appRoutePaths } : {}), From 83394fd61276e967b902f184c9e1ba451691bccb Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 11:36:05 +0100 Subject: [PATCH 07/24] fix(cloudflare): classify cacheability per concrete path --- .../src/cacheability-manifest-limits.ts | 4 +- packages/cloudflare/src/cacheability-probe.ts | 505 ++++++++++++------ packages/cloudflare/src/cdn-warm.ts | 28 +- packages/cloudflare/src/deploy.ts | 66 ++- packages/vinext/src/build/prerender-paths.ts | 179 +++++-- .../vinext/src/server/app-page-dispatch.ts | 7 +- .../vinext/src/server/app-segment-config.ts | 47 +- .../src/server/cacheability-manifest.ts | 308 ++++++++--- .../vinext/src/server/cacheability-request.ts | 94 ++-- .../src/shims/cacheability-classification.ts | 1 + tests/app-page-dispatch.test.ts | 21 + tests/app-segment-config.test.ts | 19 + tests/cacheability-admission.test.ts | 282 ++++++++-- tests/cacheability-manifest.test.ts | 227 ++++++-- tests/cloudflare-cacheability-probe.test.ts | 489 ++++++++++++++--- tests/cloudflare-cdn-warm-deploy.test.ts | 240 +++++++-- tests/cloudflare-cdn-warm.test.ts | 23 +- .../cacheability-admission.spec.ts | 104 +++- .../pages-cacheability.spec.ts | 13 +- .../pattern-runtime-dynamic/[slug]/page.tsx | 17 + .../pattern-runtime-static/[slug]/page.tsx | 14 + .../cacheability/static-empty/[slug]/page.tsx | 8 + .../cacheability-manifest.json | 210 ++------ tests/fixtures/ppr-impact-demo/next.config.ts | 1 + tests/prerender-paths.test.ts | 209 +++++++- 25 files changed, 2414 insertions(+), 702 deletions(-) create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/pattern-runtime-dynamic/[slug]/page.tsx create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/pattern-runtime-static/[slug]/page.tsx create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/static-empty/[slug]/page.tsx diff --git a/packages/cloudflare/src/cacheability-manifest-limits.ts b/packages/cloudflare/src/cacheability-manifest-limits.ts index b793bcfde..89d8abc78 100644 --- a/packages/cloudflare/src/cacheability-manifest-limits.ts +++ b/packages/cloudflare/src/cacheability-manifest-limits.ts @@ -6,7 +6,7 @@ export function cacheabilityManifestRouteLimitError( limit = MAX_CACHEABILITY_MANIFEST_ROUTES, ): Error { return new Error( - `Two-stage CDN warming produced ${routeCount} cacheable identities; the limit is ${limit}. Narrow prerender discovery or split the deployment before retrying.`, + `Two-stage CDN warming produced ${routeCount} cacheable route patterns; the limit is ${limit}. Split the deployment before retrying.`, ); } @@ -15,6 +15,6 @@ export function cacheabilityManifestByteLimitError( limit = MAX_CACHEABILITY_MANIFEST_BYTES, ): Error { return new Error( - `Two-stage CDN warming produced a ${manifestBytes}-byte cacheability manifest; the limit is ${limit} bytes. Narrow prerender discovery or split the deployment before retrying.`, + `Two-stage CDN warming produced a ${manifestBytes}-byte route-pattern cacheability manifest; the limit is ${limit} bytes. Reduce the number or length of route patterns, or split the deployment before retrying.`, ); } diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index 552f4c625..f9a0ca906 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -6,9 +6,12 @@ import { setTimeout as delay } from "node:timers/promises"; import { cacheabilityManifestRouteKey, cacheabilityRequestIdentity, + cacheabilityRoutePathname, + normalizeCacheabilityRoutePathname, type CacheabilityManifest, type CacheabilityManifestRoute, } from "vinext/internal/server/cacheability-manifest"; +import type { PrerenderRoutePattern } from "vinext/internal/build/prerender-paths"; import { VINEXT_CACHEABILITY_PROBE_HEADER, VINEXT_CACHEABILITY_PROBE_QUERY_PARAM, @@ -33,6 +36,7 @@ type ProbePayload = { kind?: string; pattern?: string; reason?: string; + rendererStatic?: boolean; state?: string; status?: number; version?: number; @@ -58,9 +62,68 @@ export type CacheabilityProbeResult = { manifest: CacheabilityManifest; probed: number; skipped: number; + /** Paired representations admitted only if their own final warm render remains cacheable. */ + speculativeTargets: CdnWarmTarget[]; }; -function isProbeRouteState(value: unknown): value is CacheabilityManifestRoute["state"] { +type ProbeRouteState = "static-candidate" | "dynamic" | "probe-failed"; + +function sharedPathPrefix(pathnames: readonly string[]): string | null { + if (pathnames.length === 0) return null; + let prefix = pathnames[0]; + for (const pathname of pathnames.slice(1)) { + const limit = Math.min(prefix.length, pathname.length); + let length = 0; + while (length < limit && prefix.charCodeAt(length) === pathname.charCodeAt(length)) length++; + prefix = prefix.slice(0, length); + } + + while ( + prefix.length > 1 && + pathnames.some( + (pathname) => + pathname !== prefix && + (prefix.endsWith("/") ? !pathname.startsWith(prefix) : !pathname.startsWith(`${prefix}/`)), + ) + ) { + const slash = prefix.lastIndexOf("/"); + prefix = slash <= 0 ? "/" : prefix.slice(0, slash + 1); + } + return prefix || null; +} + +/** Remove a shared pathname prefix only when it makes the serialized route smaller. */ +function compactManifestRoutePaths(route: CacheabilityManifestRoute): CacheabilityManifestRoute { + const pathnames = [ + ...(route.runtimePaths ?? []), + ...Object.values(route.staticPaths ?? {}).flatMap((paths) => paths ?? []), + ]; + const pathPrefix = sharedPathPrefix(pathnames); + if (!pathPrefix) return route; + + 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)), + ]), + ), + } + : {}), + }; + return Buffer.byteLength(JSON.stringify(compacted)) < Buffer.byteLength(JSON.stringify(route)) + ? compacted + : route; +} + +function isProbeRouteState(value: unknown): value is ProbeRouteState { return value === "static-candidate" || value === "dynamic" || value === "probe-failed"; } @@ -237,6 +300,7 @@ export async function probeStagedWorkerCacheability(options: { buildId: string; concurrency?: number; expectedResponseBuildId?: string; + fallbackRoutePatterns?: readonly PrerenderRoutePattern[]; fetchImpl?: typeof fetch; headers?: HeadersInit; retries?: number; @@ -266,6 +330,7 @@ export async function probeStagedWorkerCacheability(options: { const getDeadlineAt = () => lastProgressAt + phaseTimeoutMs; const routes: Record = {}; const cacheableTargets: CdnWarmTarget[] = []; + const speculativeTargets: CdnWarmTarget[] = []; const failures: string[] = []; const maxManifestBytes = Math.min( options.manifestLimits?.maxBytes ?? MAX_CACHEABILITY_MANIFEST_BYTES, @@ -284,45 +349,99 @@ export async function probeStagedWorkerCacheability(options: { const routeEntryBytes = new Map(); let limitFailure: Error | null = null; let phaseTimedOut = false; - let nextIndex = 0; let probed = 0; - let skipped = 0; - let staticCount = 0; - let dynamicCount = 0; - const patternDynamic = new Set(); - const rscBySourcePath = new Map( - options.targets - .filter( - (target) => - target.kind === "rsc-full" && - target.route?.cacheabilityProbe?.canReuseHtmlForRsc === true, - ) - .map((target) => [target.sourcePathname, target] as const), - ); - const htmlSources = new Set( - options.targets - .filter( - (target) => - target.kind === "html" && target.route?.cacheabilityProbe?.canReuseHtmlForRsc === true, - ) - .map((target) => target.sourcePathname), - ); - const targets = options.targets.filter( - (target) => target.kind !== "rsc-full" || !htmlSources.has(target.sourcePathname), - ); + let completedPathCount = 0; + let skippedPathCount = 0; + let staticPathCount = 0; + let dynamicPathCount = 0; + + type PatternClassification = { + canPrune: boolean; + groups: ConcretePathGroup[]; + key: string; + pathnames: Set; + pruned: boolean; + results: Map< + string, + { + rendererStatic: boolean; + representation: CdnWarmTarget["kind"]; + state: Exclude; + } + >; + route: NonNullable; + }; + type ConcretePathGroup = { + pattern: PatternClassification; + primary: CdnWarmTarget; + routePathname: string; + targets: CdnWarmTarget[]; + }; + const targetPreference = (target: CdnWarmTarget): number => { + if (target.route?.kind === "app-route") return target.kind === "app-route" ? 0 : 1; + return target.kind === "html" ? 0 : target.kind === "rsc-full" ? 1 : 2; + }; + const patterns = new Map(); + const targetsByConcretePath = new Map(); + let missingRouteMetadata = 0; + for (const target of options.targets) { + if (!target.route) { + missingRouteMetadata++; + continue; + } + const key = cacheabilityManifestRouteKey(target.route.kind, target.route.pattern); + const pattern = patterns.get(key) ?? { + canPrune: true, + groups: [], + key, + pathnames: new Set(), + pruned: false, + results: new Map(), + route: target.route, + }; + pattern.canPrune &&= target.route.cacheabilityProbe?.canPrunePattern === true; + patterns.set(key, pattern); - const routeKey = (target: CdnWarmTarget): string | null => - target.route ? `${target.route.kind}\0${target.route.pattern}` : null; + const routePathname = + target.route.cacheabilityProbe?.concretePathname ?? + cacheabilityRoutePathname(target.pathname, target.kind); + pattern.pathnames.add(routePathname); + const concreteKey = `${key}\0${routePathname}`; + const groupTargets = targetsByConcretePath.get(concreteKey) ?? []; + groupTargets.push(target); + targetsByConcretePath.set(concreteKey, groupTargets); + } + if (missingRouteMetadata > 0) { + failures.push( + `${missingRouteMetadata} warm target${missingRouteMetadata === 1 ? " is" : "s are"} missing route-pattern metadata`, + ); + } + const groups: ConcretePathGroup[] = Array.from( + targetsByConcretePath, + ([concreteKey, groupTargets]) => { + const route = groupTargets[0].route!; + const key = cacheabilityManifestRouteKey(route.kind, route.pattern); + const pattern = patterns.get(key)!; + const routePathname = concreteKey.slice(key.length + 1); + groupTargets.sort((first, second) => { + const preference = targetPreference(first) - targetPreference(second); + return preference || first.sourcePathname.localeCompare(second.sourcePathname); + }); + const group = { pattern, primary: groupTargets[0], routePathname, targets: groupTargets }; + pattern.groups.push(group); + return group; + }, + ); const reportProgress = (): void => { options.onProgress?.({ - completed: staticCount + dynamicCount + failures.length + skipped, - dynamic: dynamicCount, + completed: completedPathCount + (missingRouteMetadata > 0 ? 1 : 0), + dynamic: dynamicPathCount, failed: failures.length, probed, - skipped, - static: staticCount, - total: options.targets.length, + skipped: skippedPathCount, + static: staticPathCount, + total: groups.length + (missingRouteMetadata > 0 ? 1 : 0), }); }; @@ -355,22 +474,23 @@ export async function probeStagedWorkerCacheability(options: { return true; }; - const classifyTarget = async (target: CdnWarmTarget): Promise => { - const knownPattern = routeKey(target); - if (knownPattern && patternDynamic.has(knownPattern)) { - skipped += 1; + const classifyConcretePath = async (group: ConcretePathGroup): Promise => { + if (group.pattern.pruned) { + skippedPathCount += 1; + completedPathCount += 1; reportProgress(); - return null; + return; } - + const target = group.primary; const request = new Request(new URL(target.pathname, options.targetUrl), { headers: target.headers, }); const identity = cacheabilityRequestIdentity(request); if (!identity || identity.representation !== target.kind) { failures.push(`${target.label}: warm request does not have a cacheable request identity`); + completedPathCount += 1; reportProgress(); - return null; + return; } const result = await probeTarget({ @@ -388,10 +508,10 @@ export async function probeStagedWorkerCacheability(options: { }); probed += 1; lastProgressAt = Date.now(); - if (limitFailure) return null; + if (limitFailure) return; if (result.phaseTimedOut) { phaseTimedOut = true; - return null; + return; } if ( result.version !== 1 || @@ -401,141 +521,216 @@ export async function probeStagedWorkerCacheability(options: { !isProbeRouteState(result.state) || (result.scope !== undefined && result.scope !== "identity" && result.scope !== "pattern") || (result.scope === "pattern" && result.state !== "dynamic") || + (result.rendererStatic !== undefined && typeof result.rendererStatic !== "boolean") || !Number.isInteger(result.status) || result.status! < 100 || result.status! > 599 ) { failures.push(`${target.label}: ${result.reason ?? "probe returned an invalid envelope"}`); + completedPathCount += 1; reportProgress(); - return null; + return; } if (result.state === "probe-failed") { failures.push(`${target.label}: ${result.reason ?? "probe failed"}`); + completedPathCount += 1; + reportProgress(); + return; + } + if ( + !target.route || + result.kind !== target.route.kind || + result.pattern !== target.route.pattern + ) { + failures.push(`${target.label}: probe resolved to unexpected route ${result.pattern ?? ""}`); + completedPathCount += 1; reportProgress(); - return null; + return; } - if (result.state !== "static-candidate") { - dynamicCount += 1; - if ( - result.scope === "pattern" && - target.route?.cacheabilityProbe?.canPrunePattern === true && - knownPattern === `${result.kind}\0${result.pattern}` - ) { - patternDynamic.add(`${result.kind}\0${result.pattern}`); - } + const patternIsDefinitelyDynamic = + result.state === "dynamic" && result.scope === "pattern" && group.pattern.canPrune; + if (patternIsDefinitelyDynamic) { + group.pattern.pruned = true; + dynamicPathCount += 1; + completedPathCount += 1; reportProgress(); - return result; + return; } - const route: CacheabilityManifestRoute = { - kind: result.kind, - pattern: result.pattern, - representation: identity.representation, - requestKey: identity.requestKey, + group.pattern.results.set(group.routePathname, { + rendererStatic: result.rendererStatic === true, + representation: target.kind, state: result.state, - status: result.status!, - }; - const key = cacheabilityManifestRouteKey( - route.kind, - route.pattern, - route.representation, - route.requestKey, - ); - if (!addRouteWithinManifestLimits(key, route)) return null; - cacheableTargets.push(target); - staticCount += 1; + }); + if (result.state === "static-candidate") { + staticPathCount += 1; + } else { + dynamicPathCount += 1; + } + completedPathCount += 1; reportProgress(); - return result; }; - const worker = async (): Promise => { - while (!limitFailure && !phaseTimedOut && nextIndex < targets.length) { - const target = targets[nextIndex++]; - const pairedRsc = target.kind === "html" ? rscBySourcePath.get(target.sourcePathname) : null; - const knownPattern = routeKey(target); - if (knownPattern && patternDynamic.has(knownPattern)) { - skipped += pairedRsc ? 2 : 1; - reportProgress(); - continue; + const runGroups = async (scheduledGroups: ConcretePathGroup[]): Promise => { + let nextIndex = 0; + const worker = async (): Promise => { + while (!limitFailure && !phaseTimedOut && nextIndex < scheduledGroups.length) { + await classifyConcretePath(scheduledGroups[nextIndex++]); } - const result = await classifyTarget(target); - if (!result || limitFailure || phaseTimedOut) continue; - - if (!pairedRsc) continue; - // An HTML App Page render produces the RSC payload consumed by SSR, so a - // completed successful HTML render is a strict superset of the work done - // by the paired full-RSC request. Keep both exact CDN identities in the - // manifest, while avoiding a second user-code render. Runtime admission - // still rechecks the exact RSC identity, status, completed body, dynamic - // observations, and final response vetoes; a representation-specific - // dynamic observation therefore fails closed as static-to-dynamic. - // Terminal HTML and RSC requests can intentionally use different HTTP - // statuses, so classify those representations independently. - if ( - result.state === "static-candidate" && - result.kind === "app-page" && - result.status! >= 200 && - result.status! < 300 && - pairedRsc.route?.kind === "app-page" && - pairedRsc.route.pattern === result.pattern - ) { - const pairedRequest = new Request(new URL(pairedRsc.pathname, options.targetUrl), { - headers: pairedRsc.headers, - }); - const pairedIdentity = cacheabilityRequestIdentity(pairedRequest); - if (!pairedIdentity || pairedIdentity.representation !== "rsc-full") { - failures.push( - `${pairedRsc.label}: warm request does not have a cacheable request identity`, - ); - reportProgress(); - continue; - } - const pairedRoute: CacheabilityManifestRoute = { - kind: "app-page", - pattern: result.pattern!, - representation: pairedIdentity.representation, - requestKey: pairedIdentity.requestKey, - state: "static-candidate", - status: result.status!, - }; - const pairedKey = cacheabilityManifestRouteKey( - pairedRoute.kind, - pairedRoute.pattern, - pairedRoute.representation, - pairedRoute.requestKey, - ); - if (!addRouteWithinManifestLimits(pairedKey, pairedRoute)) continue; - cacheableTargets.push(pairedRsc); - staticCount += 1; - reportProgress(); - continue; - } - if (result.state === "static-candidate") { - await classifyTarget(pairedRsc); - continue; - } - const resultPatternKey = `${result.kind}\0${result.pattern}`; - if ( - result.state === "dynamic" && - (result.scope !== "pattern" || - target.route?.cacheabilityProbe?.canPrunePattern !== true || - routeKey(target) !== resultPatternKey) - ) { - await classifyTarget(pairedRsc); - } else if (result.state === "dynamic") { - skipped += 1; - reportProgress(); - } - } + }; + await Promise.all( + Array.from({ length: Math.min(concurrency, scheduledGroups.length) }, () => worker()), + ); }; reportProgress(); - await Promise.all(Array.from({ length: Math.min(concurrency, targets.length) }, () => worker())); + const representativeGroups: ConcretePathGroup[] = []; + const siblingGroups: ConcretePathGroup[] = []; + const scheduledPatterns = new Set(); + for (const group of groups) { + if (scheduledPatterns.has(group.pattern.key)) siblingGroups.push(group); + else { + scheduledPatterns.add(group.pattern.key); + representativeGroups.push(group); + } + } + await runGroups(representativeGroups); + if (!limitFailure && !phaseTimedOut) await runGroups(siblingGroups); if (limitFailure) throw limitFailure; if (phaseTimedOut || Date.now() >= getDeadlineAt()) { throw new Error(`cacheability probing made no progress for ${phaseTimeoutMs}ms`); } + let classified = 0; + let dynamic = 0; + let skipped = 0; + for (const fallbackRoute of options.fallbackRoutePatterns ?? []) { + const key = cacheabilityManifestRouteKey(fallbackRoute.kind, fallbackRoute.pattern); + if (routes[key]) continue; + if ( + !addRouteWithinManifestLimits(key, { + kind: fallbackRoute.kind, + pattern: fallbackRoute.pattern, + state: "static-candidate", + }) + ) { + break; + } + 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. + for (const pattern of patterns.values()) { + if (pattern.pruned) { + classified += 1; + dynamic += 1; + skipped += 1; + const loadingShellTargets = pattern.groups.flatMap((group) => + group.targets.filter((target) => target.kind === "rsc-loading-shell"), + ); + if (loadingShellTargets.length > 0) { + cacheableTargets.push(...loadingShellTargets); + speculativeTargets.push(...loadingShellTargets); + const route: CacheabilityManifestRoute = { + kind: pattern.route.kind, + pattern: pattern.route.pattern, + runtimeRepresentation: "rsc-loading-shell", + state: "runtime-check", + }; + if (!addRouteWithinManifestLimits(pattern.key, route)) break; + } + continue; + } + if (pattern.results.size === 0) continue; + classified += 1; + if (Array.from(pattern.results.values()).some((result) => result.state === "dynamic")) { + dynamic += 1; + } + + const staticPaths: CacheabilityManifestRoute["staticPaths"] = {}; + const runtimePathSet = new Set(); + for (const group of pattern.groups) { + const result = pattern.results.get(group.routePathname); + if (result?.state === "static-candidate") { + if (result.rendererStatic) { + const paths = staticPaths[result.representation] ?? []; + paths.push(group.routePathname); + staticPaths[result.representation] = paths; + } else { + runtimePathSet.add(group.routePathname); + } + cacheableTargets.push(...group.targets); + speculativeTargets.push(...group.targets.filter((target) => target !== group.primary)); + continue; + } + + runtimePathSet.add(group.routePathname); + // A representation-specific response policy can make an RSC/data + // sibling reusable even when the representative HTML render is private. + // The final completed render decides admission without another probe. + if (!pattern.pruned) { + const pairedTargets = group.targets.filter((target) => target !== group.primary); + cacheableTargets.push(...pairedTargets); + speculativeTargets.push(...pairedTargets); + } + } + for (const paths of Object.values(staticPaths)) paths?.sort(); + const allObservedPathsStatic = + pattern.results.size === pattern.pathnames.size && runtimePathSet.size === 0; + const allObservedPathsStaticallyGenerated = + allObservedPathsStatic && + Array.from(pattern.results.values()).every((result) => result.rendererStatic); + 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) { + const result = pattern.results.get(soleGroup.routePathname); + route = + result?.state === "static-candidate" + ? pattern.route.kind === "app-route" + ? { + kind: pattern.route.kind, + pattern: pattern.route.pattern, + state: "static-candidate", + } + : 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 { + route = compactManifestRoutePaths({ + kind: pattern.route.kind, + pattern: pattern.route.pattern, + state: "runtime-check", + ...(allObservedPathsStaticallyGenerated + ? { allowUnknown: true, unknownState: "static-candidate" as const } + : {}), + ...(runtimePathSet.size > 0 ? { runtimePaths: Array.from(runtimePathSet).sort() } : {}), + ...(Object.keys(staticPaths).length > 0 ? { staticPaths } : {}), + }); + } + if (!addRouteWithinManifestLimits(pattern.key, route)) break; + } + if (limitFailure) throw limitFailure; const sortedRoutes = Object.fromEntries( Object.entries(routes).sort(([first], [second]) => first.localeCompare(second)), ); @@ -544,13 +739,19 @@ export async function probeStagedWorkerCacheability(options: { `${second.kind}\0${second.sourcePathname}`, ), ); + speculativeTargets.sort((first, second) => + `${first.kind}\0${first.sourcePathname}`.localeCompare( + `${second.kind}\0${second.sourcePathname}`, + ), + ); return { cacheableTargets, - classified: staticCount + dynamicCount, - dynamic: dynamicCount, + classified, + dynamic, failures, manifest: { buildId: options.buildId, routes: sortedRoutes, version: 1 }, probed, skipped, + speculativeTargets, }; } diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index eb1234572..9235221b8 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -79,6 +79,7 @@ export type CdnWarmResult = { skipped: number; failed: number; failures: Array<{ path: string; error: string }>; + skippedTargets: CdnWarmTarget[]; warmedPlan: CdnWarmRequestPlan; retryPlan: CdnWarmRequestPlan; }; @@ -99,6 +100,7 @@ export type PrerenderWarmPlan = { buildId?: string; buildIdentity?: string; deploymentId?: string; + fallbackRoutePatterns?: PrerenderRoutePattern[]; loadingShellPaths: string[]; pagesDataPaths?: string[]; pagesPaths?: string[]; @@ -155,6 +157,17 @@ function readPrerenderPathManifest(manifestPath: string): PrerenderPathManifest (manifest.excludedWarmPaths !== undefined && (!Array.isArray(manifest.excludedWarmPaths) || !manifest.excludedWarmPaths.every((pathname) => typeof pathname === "string"))) || + (manifest.fallbackRoutePatterns !== undefined && + (!Array.isArray(manifest.fallbackRoutePatterns) || + !manifest.fallbackRoutePatterns.every( + (route) => + route !== null && + typeof route === "object" && + !Array.isArray(route) && + route.kind === "app-page" && + typeof route.pattern === "string" && + route.pattern.startsWith("/"), + ))) || (manifest.rscPaths !== undefined && (!Array.isArray(manifest.rscPaths) || !manifest.rscPaths.every((pathname) => typeof pathname === "string"))) || @@ -181,7 +194,9 @@ function readPrerenderPathManifest(manifestPath: string): PrerenderPathManifest typeof route.cacheabilityProbe === "object" && !Array.isArray(route.cacheabilityProbe) && typeof route.cacheabilityProbe.canPrunePattern === "boolean" && - typeof route.cacheabilityProbe.canReuseHtmlForRsc === "boolean")), + (route.cacheabilityProbe.concretePathname === undefined || + (typeof route.cacheabilityProbe.concretePathname === "string" && + route.cacheabilityProbe.concretePathname.startsWith("/"))))), ))) || (manifest.loadingShellPaths !== undefined && (!Array.isArray(manifest.loadingShellPaths) || @@ -253,7 +268,7 @@ export function readPrerenderWarmPlan( const routePatterns = manifest.routePatterns ? Object.fromEntries( Object.entries(manifest.routePatterns).map(([pathname, route]) => [ - applyConfig(pathname), + pathname.includes("/_next/data/") ? pathname : applyConfig(pathname), route, ]), ) @@ -287,6 +302,9 @@ export function readPrerenderWarmPlan( buildId: manifest.buildId, ...(manifest.buildIdentity ? { buildIdentity: manifest.buildIdentity } : {}), ...(manifest.deploymentId ? { deploymentId: manifest.deploymentId } : {}), + ...(manifest.fallbackRoutePatterns + ? { fallbackRoutePatterns: manifest.fallbackRoutePatterns } + : {}), loadingShellPaths: supportsCanonicalRsc ? (manifest.loadingShellPaths ?? []).map(applyConfig) : [], @@ -1193,6 +1211,7 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise ({ path, error })); const skippedResults = results.filter((result) => result.ok && result.skipped); + const skippedTargets = requests.filter((_target, index) => { + const result = results[index]; + return result.ok && result.skipped; + }); const warmedRequests = requests.filter((_target, index) => { const result = results[index]; return result.ok && !result.skipped; @@ -1342,6 +1365,7 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise target.kind === "rsc-loading-shell") diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index e5340632e..e35c3cc6c 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -60,6 +60,7 @@ import { warmCdnCache, type CdnWarmOptions, type CdnWarmRequestPlan, + type CdnWarmTarget, type PrerenderWarmPlan, } from "./cdn-warm.js"; import { @@ -82,6 +83,7 @@ import { } from "./version-deploy.js"; import { parseWorkerDeploymentUrl } from "./worker-deployment-url.js"; import { PHASE_PRODUCTION_BUILD } from "vinext/shims/constants"; +import { cacheabilityRoutePathname } from "vinext/internal/server/cacheability-manifest"; import { buildPrerenderKVPairs, type KVBulkPair } from "./prerender-kv-populate.js"; import { withCacheabilityManifestArtifact } from "./cacheability-artifact.js"; import { @@ -756,11 +758,16 @@ type CdnWarmDeployOptions = Pick< type PreparedCdnWarmDeployOptions = CdnWarmDeployOptions & { expectedDeploymentState?: WranglerDeploymentStatus; + optionalWarmTargetKeys?: ReadonlySet; triggersAlreadyApplied?: boolean; triggersDeployedUrl?: string | null; uploadedVersion?: WranglerVersionUploadResult; }; +function cdnWarmTargetKey(target: Pick): string { + return `${target.kind}\0${target.sourcePathname}`; +} + export async function deployWithCdnWarmup( root: string, paths: readonly string[], @@ -1052,22 +1059,33 @@ async function deployUploadedVersionWithCdnWarmup( } else { console.log(" CDN warmup: staged Worker version is stable."); const warmResult = await warmUploadedVersion(targetUrl, headers, true, stagedWarmPlan); + const optionalSkipped = options.optionalWarmTargetKeys + ? warmResult.skippedTargets.filter((target) => + options.optionalWarmTargetKeys!.has(cdnWarmTargetKey(target)), + ).length + : 0; if (hasPreparedWarmPlan && options.warmCdnCertify) { - if (warmResult.warmed !== stagedWarmRequests) { + if (warmResult.warmed + optionalSkipped !== stagedWarmRequests) { throw new Error( - `CDN warmup cannot certify the staged cache because only ${warmResult.warmed}/${stagedWarmRequests} planned cache entries completed their initial fill.`, + `CDN warmup cannot certify the staged cache because only ${warmResult.warmed}/${stagedWarmRequests - optionalSkipped} cacheable entries completed their initial fill.`, ); } } - if (hasPreparedWarmPlan && warmResult.skipped > 0) { + const requiredSkipped = warmResult.skipped - optionalSkipped; + if (hasPreparedWarmPlan && requiredSkipped > 0) { const message = - `Two-stage CDN warming could not fill ${warmResult.skipped}/${warmResult.total} ` + + `Two-stage CDN warming could not fill ${requiredSkipped}/${warmResult.total} ` + "planned cache entries because Cloudflare refused cache admission."; if (!allowUnverifiedPromotion) { throw new Error(message); } console.warn(` ${message} Promoting because the dangerous override is enabled.`); } + if (optionalSkipped > 0) { + console.log( + ` CDN warmup: ${optionalSkipped} paired representation${optionalSkipped === 1 ? " remained" : "s remained"} private after ${optionalSkipped === 1 ? "its" : "their"} final render and will not be cached.`, + ); + } remainingWarmPlan = { loadingShellPaths: warmResult.retryPlan.loadingShellPaths, pagesDataPaths: warmResult.retryPlan.pagesDataPaths, @@ -1401,6 +1419,7 @@ async function deployWithCacheabilityProbe( } let prepared: | { + optionalWarmTargetKeys: ReadonlySet; plan: PrerenderWarmPlan; upload: WranglerVersionUploadResult; } @@ -1440,6 +1459,9 @@ async function deployWithCacheabilityProbe( const plan: PrerenderWarmPlan & CdnWarmRequestPlan = { ...discovered, appPaths: discovered.appPaths ? [...discovered.appPaths] : undefined, + fallbackRoutePatterns: discovered.fallbackRoutePatterns + ? [...discovered.fallbackRoutePatterns] + : undefined, loadingShellPaths: [...discovered.loadingShellPaths], pagesDataPaths: [...(discovered.pagesDataPaths ?? [])], pagesPaths: discovered.pagesPaths ? [...discovered.pagesPaths] : undefined, @@ -1465,6 +1487,23 @@ async function deployWithCacheabilityProbe( routePatterns: plan.routePatterns, rscPaths: plan.rscPaths, }); + const routePatternCount = new Set( + targets.flatMap((target) => + target.route ? [`${target.route.kind}\0${target.route.pattern}`] : [], + ), + ).size; + const concreteRoutePathCount = new Set( + targets.flatMap((target) => + target.route + ? [ + `${target.route.kind}\0${target.route.pattern}\0${ + target.route.cacheabilityProbe?.concretePathname ?? + cacheabilityRoutePathname(target.pathname, target.kind) + }`, + ] + : [], + ), + ).size; if (targets.length > 0) { console.log(" CDN warmup: waiting for the staged probe Worker to become stable..."); const readiness = await waitForCdnWarmTargetReadiness({ @@ -1487,11 +1526,14 @@ async function deployWithCacheabilityProbe( } console.log( - ` CDN warmup: classifying ${targets.length} exact request identit${targets.length === 1 ? "y" : "ies"}; paired App HTML/RSC identities share a completed render when safe...`, + ` CDN warmup: probing ${concreteRoutePathCount} concrete route path${concreteRoutePathCount === 1 ? "" : "s"} once across ${routePatternCount} pattern${routePatternCount === 1 ? "" : "s"}; filtering ${targets.length} candidate warm request identit${targets.length === 1 ? "y" : "ies"}...`, ); } else { + const fallbackPatternCount = plan.fallbackRoutePatterns?.length ?? 0; console.log( - " CDN warmup: no page request identities were discovered; embedding an empty fail-closed cacheability manifest.", + fallbackPatternCount > 0 + ? ` CDN warmup: embedding ${fallbackPatternCount} on-demand static route pattern${fallbackPatternCount === 1 ? "" : "s"} without a speculative render.` + : " CDN warmup: no page request identities were discovered; embedding an empty fail-closed cacheability manifest.", ); } const probeProgress = new CdnOperationProgress(); @@ -1501,6 +1543,7 @@ async function deployWithCacheabilityProbe( buildId: discovered.buildId, concurrency: options.warmCdnConcurrency, expectedResponseBuildId: plan.buildIdentity, + fallbackRoutePatterns: plan.fallbackRoutePatterns, phaseTimeoutMs: options.warmCdnProbeTimeout ?? DEFAULT_CACHEABILITY_PROBE_PHASE_TIMEOUT_MS, retries: options.warmCdnProbeRetries ?? @@ -1524,11 +1567,11 @@ async function deployWithCacheabilityProbe( probeProgress.finish(); } console.log( - ` CDN warmup: classified ${probe.classified} exact identities with ${probe.probed} render probe${probe.probed === 1 ? "" : "s"}; ${probe.dynamic} dynamic and ${probe.skipped} skipped by pattern proof.`, + ` CDN warmup: classified ${probe.classified} route pattern${probe.classified === 1 ? "" : "s"} with ${probe.probed} render probe${probe.probed === 1 ? "" : "s"}; ${probe.dynamic} observed dynamic and ${probe.skipped} excluded by pattern-wide proof.`, ); if (probe.failures.length > 0) { throw new Error( - `Two-stage CDN warming failed to classify ${probe.failures.length}/${targets.length} request identities after ${probe.probed} render probe(s). First failure: ${probe.failures[0]}`, + `Two-stage CDN warming failed to classify ${probe.failures.length}/${concreteRoutePathCount} concrete route paths after ${probe.probed} render probe(s). First failure: ${probe.failures[0]}`, ); } const finalPlan: PrerenderWarmPlan & CdnWarmRequestPlan = { @@ -1576,7 +1619,11 @@ async function deployWithCacheabilityProbe( stagedProbeDeployment, "Two-stage CDN warming stopped because Worker deployment traffic or deployment identity changed before the final version could be staged. No final version was promoted.", ); - prepared = { plan: finalPlan, upload: finalUpload }; + prepared = { + optionalWarmTargetKeys: new Set(probe.speculativeTargets.map(cdnWarmTargetKey)), + plan: finalPlan, + upload: finalUpload, + }; } catch (error) { throw withStagedProbeVersionCleanupNote(error); } @@ -1588,6 +1635,7 @@ async function deployWithCacheabilityProbe( expectedRscBuildId: prepared.plan.rscBuildId, expectedDeploymentState: stagedProbeDeployment, loadingShellPaths: prepared.plan.loadingShellPaths, + optionalWarmTargetKeys: prepared.optionalWarmTargetKeys, pagesDataPaths: prepared.plan.pagesDataPaths, routeHandlerPaths: prepared.plan.routeHandlerPaths, routePatterns: prepared.plan.routePatterns, diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 3d5433a73..349f9b7d0 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -22,6 +22,7 @@ import { classifyAppRoute, classifyAppRouteHandler, classifyPagesRoute, + extractExportConstString, } from "./report.js"; import { buildUrlFromParams, resolveParentParams, type StaticParamsMap } from "./prerender.js"; import { readPrerenderSecret } from "./server-manifest.js"; @@ -34,6 +35,7 @@ import { enterPrerenderPhase } from "./prerender-phase.js"; import type { CdnCacheAdapterCapabilities } from "../cache/cache-adapters-virtual.js"; import { matchHeaders, matchesRewriteSource } from "../config/config-matchers.js"; import { pagesRouteHasPriorityOverAppRoute } from "../server/hybrid-route-priority.js"; +import { resolveAppPageDynamicConfig } from "../server/app-segment-config.js"; import { extractLocaleFromUrl, normalizeDefaultLocalePathname } from "../server/pages-i18n.js"; import { normalizePathTrailingSlash } from "vinext/shims/url-utils"; import { buildPagesDataHref } from "vinext/shims/internal/pages-data-url"; @@ -45,7 +47,8 @@ export type PrerenderRoutePattern = { /** Closed-world safety facts for probe coordinator optimizations. */ cacheabilityProbe?: { canPrunePattern: boolean; - canReuseHtmlForRsc: boolean; + /** HTML pathname shared by alternate representations of this route. */ + concretePathname?: string; }; }; @@ -72,6 +75,8 @@ export type PrerenderPathManifest = { pagesDataPaths?: string[]; /** Public paths omitted because configured routes can replace their page response. */ excludedWarmPaths?: string[]; + /** Static dynamic-route patterns with no build-discovered concrete path. */ + fallbackRoutePatterns?: PrerenderRoutePattern[]; /** Resolved route ownership for grouping cacheability probes without re-matching paths. */ routePatterns?: Record; trailingSlash?: boolean; @@ -615,6 +620,26 @@ async function excludePagesApiWarmPaths(options: { }); } +async function resolvePagesWarmRoutePatterns(options: { + i18n: ResolvedNextConfig["i18n"]; + pagesDir: string; + pageExtensions: readonly string[]; + paths: readonly string[]; +}): Promise> { + const pageRoutes = await pagesRouter(options.pagesDir, options.pageExtensions); + return Object.fromEntries( + options.paths.flatMap((pathname) => { + const pagesPathname = options.i18n + ? extractLocaleFromUrl(pathname, options.i18n).url + : pathname; + const match = matchRoute(pagesPathname, pageRoutes); + return match + ? [[pathname, { kind: "pages-page" as const, pattern: match.route.pattern }] as const] + : []; + }), + ); +} + function localizePagesPath( pathname: string, locale: string | undefined, @@ -660,7 +685,12 @@ async function collectAppPaths(options: { pageExtensions: readonly string[]; retryOptions?: PathDiscoveryRetryOptions; secretHeaders: Record; -}): Promise<{ loadingShellPaths: string[]; paths: string[]; routeHandlerPaths: string[] }> { +}): Promise<{ + fallbackRoutePatterns: PrerenderRoutePattern[]; + loadingShellPaths: string[]; + paths: string[]; + routeHandlerPaths: string[]; +}> { const routes = await appRouter(options.appDir, options.pageExtensions); const paths: string[] = []; const seen = new Set(); @@ -668,6 +698,7 @@ async function collectAppPaths(options: { const seenLoadingShellPaths = new Set(); const routeHandlerPaths: string[] = []; const seenRouteHandlerPaths = new Set(); + const fallbackRoutePatterns: PrerenderRoutePattern[] = []; const staticParamsCache = new Map[] | null>>(); const staticParamsMap = new Proxy({} as StaticParamsMap, { get(_target, pattern: string) { @@ -774,7 +805,36 @@ async function collectAppPaths(options: { } } - if (!paramSets?.length) continue; + if (!paramSets?.length) { + const parallelSegments = route.parallelSlots.flatMap((slot) => + [ + slot.layoutPath, + ...(slot.configLayoutPaths ?? []), + slot.pagePath ?? slot.defaultPath, + ].filter((filePath): filePath is string => typeof filePath === "string"), + ); + const segmentClassifications = [...route.layouts, renderEntryPath, ...parallelSegments].map( + (filePath) => classifyAppRoute(filePath, null, false), + ); + const hasDynamicSegment = segmentClassifications.some( + (classification) => classification.type === "ssr", + ); + const readDynamicConfig = (filePath: string): { dynamic?: string } => { + const dynamic = extractExportConstString(fs.readFileSync(filePath, "utf8"), "dynamic"); + return dynamic === null ? {} : { dynamic }; + }; + const dynamicConfig = resolveAppPageDynamicConfig({ + layouts: route.layouts.map(readDynamicConfig), + page: readDynamicConfig(renderEntryPath), + parallelSegments: parallelSegments.map(readDynamicConfig), + }); + const hasStaticFallback = + paramSets !== null || dynamicConfig === "force-static" || dynamicConfig === "error"; + if (hasStaticFallback && !hasDynamicSegment) { + fallbackRoutePatterns.push({ kind: "app-page", pattern: route.pattern }); + } + continue; + } for (const params of paramSets) { if (params === null || params === undefined) continue; @@ -785,7 +845,7 @@ async function collectAppPaths(options: { } } - return { loadingShellPaths, paths, routeHandlerPaths }; + return { fallbackRoutePatterns, loadingShellPaths, paths, routeHandlerPaths }; } async function resolveAppWarmPaths(options: { @@ -894,12 +954,6 @@ async function resolveAppWarmPaths(options: { } const CACHEABILITY_POLICY_HEADER_NAMES = new Set(CACHEABILITY_POLICY_HEADERS); -const CACHEABILITY_IDENTITY_HEADER_NAMES = new Set([ - ...CACHEABILITY_POLICY_HEADERS, - "set-cookie", - "vary", -]); - function cachePolicyRuleMatchesWarmPath( pathname: string, rule: ResolvedNextConfig["headers"][number], @@ -930,10 +984,53 @@ function cachePolicyRuleMatchesWarmPath( }); } +function cachePolicyRuleSourceMatchesWarmPath( + pathname: string, + rule: ResolvedNextConfig["headers"][number], + config: Pick, +): boolean { + return cachePolicyRuleMatchesWarmPath( + pathname, + { ...rule, has: undefined, missing: undefined }, + config, + ); +} + +function staticConfigPatternSegments(pattern: string): string[] { + const segments: string[] = []; + for (const segment of pattern.split("/").filter(Boolean)) { + if (/[:*()[\]{}]/.test(segment)) break; + segments.push(segment); + } + return segments; +} + +function routePatternCouldIntersectCachePolicyRule( + routePattern: string, + rule: ResolvedNextConfig["headers"][number], + basePath: string, +): boolean { + let ruleSource = rule.source; + if ( + rule.basePath !== false && + basePath && + (ruleSource === basePath || ruleSource.startsWith(`${basePath}/`)) + ) { + ruleSource = ruleSource.slice(basePath.length) || "/"; + } + const routeSegments = staticConfigPatternSegments(routePattern); + const ruleSegments = staticConfigPatternSegments(ruleSource); + const sharedLength = Math.min(routeSegments.length, ruleSegments.length); + for (let index = 0; index < sharedLength; index++) { + if (routeSegments[index] !== ruleSegments[index]) return false; + } + return true; +} + /** - * Certify only probe collapses that cannot hide a path- or request-specific - * next.config cache policy. The manifest contains a closed set of concrete - * identities, so path uniformity is evaluated across that discovered set. + * Certify only route-config bailouts that cannot hide a path- or + * request-specific next.config cache policy. The final Worker still evaluates + * every concrete response before emitting public cache headers. */ function annotateCacheabilityProbeSafety( routePatterns: Record, @@ -942,9 +1039,6 @@ function annotateCacheabilityProbeSafety( const cachePolicyRules = config.headers.filter((rule) => rule.headers.some((header) => CACHEABILITY_POLICY_HEADER_NAMES.has(header.key.toLowerCase())), ); - const identityRules = config.headers.filter((rule) => - rule.headers.some((header) => CACHEABILITY_IDENTITY_HEADER_NAMES.has(header.key.toLowerCase())), - ); const matchingPolicyRules = new Map( Object.keys(routePatterns).map((pathname) => [ pathname, @@ -953,14 +1047,6 @@ function annotateCacheabilityProbeSafety( ), ]), ); - const matchingIdentityRules = new Map( - Object.keys(routePatterns).map((pathname) => [ - pathname, - new Set( - identityRules.filter((rule) => cachePolicyRuleMatchesWarmPath(pathname, rule, config)), - ), - ]), - ); const pathsByPattern = new Map(); for (const [pathname, route] of Object.entries(routePatterns)) { const key = `${route.kind}\0${route.pattern}`; @@ -970,13 +1056,15 @@ function annotateCacheabilityProbeSafety( } const canPrunePatterns = new Map(); for (const [patternKey, patternPaths] of pathsByPattern) { - const relevantRules = new Set(); - for (const path of patternPaths) { - for (const rule of matchingPolicyRules.get(path) ?? []) relevantRules.add(rule); - } + const routePattern = routePatterns[patternPaths[0]].pattern; + const relevantRules = cachePolicyRules.filter( + (rule) => + patternPaths.some((path) => cachePolicyRuleSourceMatchesWarmPath(path, rule, config)) || + routePatternCouldIntersectCachePolicyRule(routePattern, rule, config.basePath), + ); canPrunePatterns.set( patternKey, - Array.from(relevantRules).every( + relevantRules.every( (rule) => !rule.has?.length && !rule.missing?.length && @@ -989,14 +1077,11 @@ function annotateCacheabilityProbeSafety( Object.entries(routePatterns).map(([pathname, route]) => { const patternKey = `${route.kind}\0${route.pattern}`; const canPrunePattern = canPrunePatterns.get(patternKey) ?? false; - const canReuseHtmlForRsc = Array.from(matchingIdentityRules.get(pathname) ?? []).every( - (rule) => !rule.has?.length && !rule.missing?.length, - ); return [ pathname, { ...route, - cacheabilityProbe: { canPrunePattern, canReuseHtmlForRsc }, + cacheabilityProbe: { canPrunePattern }, }, ]; }), @@ -1090,6 +1175,7 @@ export async function emitPrerenderPathManifest( const seenRouteHandlerPaths = new Set(); const discoveredLoadingShellPaths: string[] = []; const seenLoadingShellPaths = new Set(); + const fallbackRoutePatterns: PrerenderRoutePattern[] = []; await withPrerenderEndpoints(async () => { let prodServer: { server: HttpServer; port: number } | null = null; const needsServer = await shouldStartPathDiscoveryServer({ @@ -1164,6 +1250,7 @@ export async function emitPrerenderPathManifest( for (const pathname of appPathResult.routeHandlerPaths) { addPath(discoveredRouteHandlerPaths, seenRouteHandlerPaths, pathname); } + fallbackRoutePatterns.push(...appPathResult.fallbackRoutePatterns); } if (pagesDir) { @@ -1228,7 +1315,14 @@ export async function emitPrerenderPathManifest( htmlPaths: discoveredAppPaths, loadingShellPaths: discoveredLoadingShellPaths, pagesPaths: resolvedPagesWarmPaths, - routePatterns: {}, + routePatterns: pagesDir + ? await resolvePagesWarmRoutePatterns({ + i18n: config.i18n, + pagesDir, + pageExtensions: config.pageExtensions, + paths: resolvedPagesWarmPaths, + }) + : {}, rscPaths: discoveredAppPaths, }; const warmPaths = appDir ? appOwnedWarmPaths.htmlPaths : resolvedPagesWarmPaths; @@ -1245,6 +1339,22 @@ export async function emitPrerenderPathManifest( ), ); const routePatterns = annotateCacheabilityProbeSafety(appOwnedWarmPaths.routePatterns, config); + for (let index = 0; index < resolvedPagesDataWarmPaths.length; index++) { + const route = routePatterns[resolvedPagesDataWarmPaths[index]]; + if (route) { + const htmlPathname = + resolvedPagesDataWarmPaths[index] === "/" + ? config.basePath || "/" + : `${config.basePath}${resolvedPagesDataWarmPaths[index]}`; + routePatterns[pagesDataPaths[index]] = { + ...route, + cacheabilityProbe: { + ...route.cacheabilityProbe!, + concretePathname: htmlPathname, + }, + }; + } + } const manifest: PrerenderPathManifest = { ...(appDir ? { appPaths: appOwnedWarmPaths.appPaths } : {}), @@ -1261,6 +1371,7 @@ export async function emitPrerenderPathManifest( } : {}), ...(excludedWarmPathSet.size > 0 ? { excludedWarmPaths: Array.from(excludedWarmPathSet) } : {}), + ...(fallbackRoutePatterns.length > 0 ? { fallbackRoutePatterns } : {}), ...(rscBuildId ? { rscBuildId } : {}), ...(options.responseVary ? { responseVary: options.responseVary } : {}), ...(options.responseVary ? { rscPaths: appOwnedWarmPaths.rscPaths } : {}), diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index d43701ad1..da5a5add4 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -644,7 +644,12 @@ async function dispatchAppPageInner( return new Response(null, { status: 204 }); } const dynamicConfig = options.dynamicConfig; - const currentRevalidateSeconds = options.revalidateSeconds; + // Next.js treats a dynamic route with generateStaticParams as SSG even when + // the generator returns no concrete paths. Its default `revalidate = false` + // then applies to the first on-demand render of an unknown path. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/build/index.ts + const currentRevalidateSeconds = + options.revalidateSeconds ?? (options.hasGenerateStaticParams ? Infinity : null); const interceptionId = options.isRscRequest ? options.request.headers.get(VINEXT_INTERCEPTION_ID_HEADER) : null; diff --git a/packages/vinext/src/server/app-segment-config.ts b/packages/vinext/src/server/app-segment-config.ts index 195c4f3bf..92b85e14c 100644 --- a/packages/vinext/src/server/app-segment-config.ts +++ b/packages/vinext/src/server/app-segment-config.ts @@ -123,6 +123,36 @@ function getParallelSegments( ); } +/** Resolve the effective `dynamic` mode using the same traversal semantics as rendering. */ +export function resolveAppPageDynamicConfig( + options: Pick< + ResolveAppPageSegmentConfigOptions, + "layouts" | "page" | "parallelBranches" | "parallelSegments" + >, +): AppRouteSegmentDynamic | undefined { + const segments = [...(options.layouts ?? []), options.page]; + const parallelSegments = getParallelSegments(options); + let dynamicConfig: AppRouteSegmentDynamic | undefined; + let hasForceDynamic = false; + + for (const segment of segments) { + if (!isRouteSegmentDynamic(segment?.dynamic)) continue; + if (segment.dynamic === "force-dynamic") hasForceDynamic = true; + dynamicConfig = hasForceDynamic ? "force-dynamic" : segment.dynamic; + } + + for (const segment of parallelSegments) { + if (segment?.dynamic === "force-dynamic") { + hasForceDynamic = true; + dynamicConfig = "force-dynamic"; + } else if (dynamicConfig === undefined && isRouteSegmentDynamic(segment?.dynamic)) { + dynamicConfig = segment.dynamic; + } + } + + return dynamicConfig; +} + function resolveDynamicParamsConfig( options: ResolveAppPageSegmentConfigOptions, ): boolean | undefined { @@ -236,7 +266,9 @@ export function resolveAppPageSegmentConfig( // - dynamicParams: false is sticky across the route tree. // - fetchCache: force/only modes take route-level precedence and reject conflicts. // - revalidate: the shortest numeric interval wins. + const dynamicConfig = resolveAppPageDynamicConfig(options); const config: EffectiveAppPageSegmentConfig = { + ...(dynamicConfig === undefined ? {} : { dynamicConfig }), revalidateSeconds: null, }; config.dynamicParamsConfig = resolveDynamicParamsConfig(options); @@ -245,18 +277,10 @@ export function resolveAppPageSegmentConfig( let hasOnlyCache = false; let hasOnlyNoStore = false; let hasParentDefaultNoStore = false; - let hasForceDynamic = false; for (const segment of segments) { if (!segment) continue; - if (isRouteSegmentDynamic(segment.dynamic)) { - if (segment.dynamic === "force-dynamic") { - hasForceDynamic = true; - } - config.dynamicConfig = hasForceDynamic ? "force-dynamic" : segment.dynamic; - } - if (isRouteSegmentRuntime(segment.runtime)) { config.runtime = segment.runtime; } @@ -311,13 +335,6 @@ export function resolveAppPageSegmentConfig( // chain values remain authoritative when present. Slot-only values still // define the route, while sticky route-wide constraints aggregate across // every active branch. - if (segment.dynamic === "force-dynamic") { - hasForceDynamic = true; - config.dynamicConfig = "force-dynamic"; - } else if (config.dynamicConfig === undefined && isRouteSegmentDynamic(segment.dynamic)) { - config.dynamicConfig = segment.dynamic; - } - if (config.runtime === undefined && isRouteSegmentRuntime(segment.runtime)) { config.runtime = segment.runtime; } diff --git a/packages/vinext/src/server/cacheability-manifest.ts b/packages/vinext/src/server/cacheability-manifest.ts index db04553f1..8633d2fcb 100644 --- a/packages/vinext/src/server/cacheability-manifest.ts +++ b/packages/vinext/src/server/cacheability-manifest.ts @@ -23,19 +23,27 @@ export type CacheabilityRepresentation = | "pages-data" | "rsc-full" | "rsc-loading-shell"; -type CacheabilityManifestRouteState = - | "static-candidate" - | "runtime-check" - | "dynamic" - | "probe-failed"; +export type CacheabilityManifestRouteState = "static-candidate" | "runtime-check"; export type CacheabilityManifestRoute = { kind: CacheabilityRouteKind; pattern: string; - representation: CacheabilityRepresentation; - requestKey: string; + /** Default completed-render policy for authorized non-static representations. */ state: CacheabilityManifestRouteState; - status: number; + /** Permit completed-render admission for paths not observed during probing. */ + allowUnknown?: boolean; + /** Next.js fallback classification for paths not observed during probing. */ + unknownState?: CacheabilityManifestRouteState; + /** Common prefix omitted from every exact path below. */ + pathPrefix?: string; + /** Runtime-checked representation authorized for every path in a pattern. */ + runtimeRepresentation?: CacheabilityRepresentation; + /** Static representation for a literal route that needs no exact path list. */ + 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. */ + staticPaths?: Partial>; }; export type CacheabilityManifest = { @@ -44,38 +52,69 @@ export type CacheabilityManifest = { version: 1; }; -const manifestRoutePatterns = new WeakMap>(); - -function cacheabilityManifestRoutePatternKey(kind: CacheabilityRouteKind, pattern: string): string { - return `${kind}\0${pattern}`; -} - export function cacheabilityManifestRouteKey( kind: CacheabilityManifestRoute["kind"], pattern: string, - representation: CacheabilityRepresentation, - requestKey: string, ): string { - return JSON.stringify([kind, pattern, representation, requestKey]); + return JSON.stringify([kind, pattern]); +} + +function isRouteState(value: unknown): value is CacheabilityManifestRouteState { + return value === "static-candidate" || value === "runtime-check"; } function isRepresentation(value: unknown): value is CacheabilityRepresentation { - return ( - value === "app-route" || - value === "html" || - value === "pages-data" || - value === "rsc-full" || - value === "rsc-loading-shell" - ); + return CACHEABILITY_REPRESENTATIONS.includes(value as CacheabilityRepresentation); } -function isRouteState(value: unknown): value is CacheabilityManifestRouteState { - return ( - value === "static-candidate" || - value === "runtime-check" || - value === "dynamic" || - value === "probe-failed" - ); +const CACHEABILITY_REPRESENTATIONS: readonly CacheabilityRepresentation[] = [ + "app-route", + "html", + "pages-data", + "rsc-full", + "rsc-loading-shell", +]; + +function expandPathToken(pathPrefix: string | undefined, token: string): string | null { + if (!pathPrefix) return token; + const pathname = `${pathPrefix}${token}`; + return pathname === normalizeCacheabilityRoutePathname(pathname) ? pathname : null; +} + +function parsePathList(value: unknown, pathPrefix: string | undefined): string[] | null { + if ( + !Array.isArray(value) || + value.length === 0 || + !value.every( + (token, index, tokens) => + typeof token === "string" && + (pathPrefix !== undefined || token.startsWith("/")) && + expandPathToken(pathPrefix, token) !== null && + (index === 0 || tokens[index - 1] < token), + ) + ) { + return null; + } + return value as string[]; +} + +function parseStaticPaths( + value: unknown, + pathPrefix: string | undefined, +): Partial> | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const entries = Object.entries(value); + if (entries.length === 0) return null; + const parsed: Partial> = {}; + for (const [representation, paths] of entries) { + if (!isRepresentation(representation)) { + return null; + } + const parsedPaths = parsePathList(paths, pathPrefix); + if (!parsedPaths) return null; + parsed[representation as CacheabilityRepresentation] = parsedPaths; + } + return parsed; } function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | null { @@ -85,33 +124,77 @@ function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | nu (route.kind !== "app-page" && route.kind !== "app-route" && route.kind !== "pages-page") || typeof route.pattern !== "string" || !route.pattern.startsWith("/") || - !isRepresentation(route.representation) || - typeof route.requestKey !== "string" || - !route.requestKey.startsWith("/") || - !isRouteState(route.state) || - !Number.isInteger(route.status) || - (route.status as number) < 100 || - (route.status as number) > 599 + !isRouteState(route.state) + ) { + return null; + } + const pathPrefix = + typeof route.pathPrefix === "string" && + route.pathPrefix.startsWith("/") && + new URL(route.pathPrefix, "http://vinext.local").pathname === route.pathPrefix + ? route.pathPrefix + : undefined; + const runtimePaths = + route.runtimePaths === undefined ? undefined : parsePathList(route.runtimePaths, pathPrefix); + const staticPaths = + route.staticPaths === undefined ? undefined : parseStaticPaths(route.staticPaths, pathPrefix); + const staticRepresentation = isRepresentation(route.staticRepresentation) + ? route.staticRepresentation + : undefined; + const runtimeRepresentation = isRepresentation(route.runtimeRepresentation) + ? route.runtimeRepresentation + : undefined; + if ( + (route.allowUnknown !== undefined && route.allowUnknown !== true) || + (route.unknownState !== undefined && route.unknownState !== "static-candidate") || + (route.unknownState !== undefined && route.allowUnknown !== true) || + (route.pathPrefix !== undefined && pathPrefix === undefined) || + (route.staticRepresentation !== undefined && staticRepresentation === undefined) || + (route.runtimeRepresentation !== undefined && runtimeRepresentation === undefined) || + (runtimeRepresentation !== undefined && + (route.state !== "runtime-check" || + route.allowUnknown !== undefined || + pathPrefix !== undefined || + staticRepresentation !== undefined || + runtimePaths !== undefined || + staticPaths !== undefined)) || + (staticRepresentation !== undefined && + (route.state !== "runtime-check" || + /(^|\/):/.test(route.pattern) || + runtimePaths !== undefined || + staticPaths !== undefined)) || + (pathPrefix !== undefined && !runtimePaths && !staticPaths) || + (route.runtimePaths !== undefined && !runtimePaths) || + (route.staticPaths !== undefined && !staticPaths) || + ((runtimePaths || staticPaths || route.allowUnknown === true) && + route.state !== "runtime-check") ) { return null; } const parsed: CacheabilityManifestRoute = { kind: route.kind, pattern: route.pattern, - representation: route.representation, - requestKey: route.requestKey, state: route.state, - status: route.status as number, + ...(route.allowUnknown === true ? { allowUnknown: true } : {}), + ...(route.unknownState === "static-candidate" + ? { unknownState: "static-candidate" as const } + : {}), + ...(pathPrefix ? { pathPrefix } : {}), + ...(runtimeRepresentation ? { runtimeRepresentation } : {}), + ...(staticRepresentation ? { staticRepresentation } : {}), + ...(runtimePaths ? { runtimePaths } : {}), + ...(staticPaths ? { staticPaths } : {}), }; - return key === - cacheabilityManifestRouteKey( - parsed.kind, - parsed.pattern, - parsed.representation, - parsed.requestKey, - ) - ? parsed - : null; + + const observedPaths = new Set(); + for (const tokens of [runtimePaths, ...Object.values(staticPaths ?? {})]) { + for (const token of tokens ?? []) { + const pathname = expandPathToken(pathPrefix, token)!; + if (observedPaths.has(pathname)) return null; + observedPaths.add(pathname); + } + } + return key === cacheabilityManifestRouteKey(parsed.kind, parsed.pattern) ? parsed : null; } export function parseCacheabilityManifest( @@ -134,38 +217,17 @@ export function parseCacheabilityManifest( } const routes: Record = {}; - const routePatterns = new Set(); for (const [key, routeValue] of Object.entries(record.routes)) { const route = parseRoute(key, routeValue); if (!route) return null; routes[key] = route; - routePatterns.add(cacheabilityManifestRoutePatternKey(route.kind, route.pattern)); } - const manifest: CacheabilityManifest = { buildId: expectedBuildId, routes, version: 1 }; - manifestRoutePatterns.set(manifest, routePatterns); - return manifest; + return { buildId: expectedBuildId, routes, version: 1 }; } catch { return null; } } -export function cacheabilityManifestHasRoutePattern( - manifest: CacheabilityManifest, - kind: CacheabilityRouteKind, - pattern: string, -): boolean { - let routePatterns = manifestRoutePatterns.get(manifest); - if (!routePatterns) { - routePatterns = new Set( - Object.values(manifest.routes).map((route) => - cacheabilityManifestRoutePatternKey(route.kind, route.pattern), - ), - ); - manifestRoutePatterns.set(manifest, routePatterns); - } - return routePatterns.has(cacheabilityManifestRoutePatternKey(kind, pattern)); -} - const CONTEXTUAL_RSC_HEADERS = [ NEXT_ROUTER_STATE_TREE_HEADER, NEXT_URL_HEADER, @@ -217,15 +279,105 @@ export function cacheabilityRequestIdentity(request: Request): { return { representation: "rsc-full", requestKey }; } +export function normalizeCacheabilityRoutePathname(pathname: string): string { + const normalized = new URL(pathname, "http://vinext.local").pathname; + return normalized.length > 1 && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized; +} + +export function cacheabilityRoutePathname( + pathname: string, + representation: CacheabilityRepresentation, +): string { + const normalized = new URL(pathname, "http://vinext.local").pathname; + if (representation === "rsc-full" || representation === "rsc-loading-shell") { + return normalizeCacheabilityRoutePathname( + normalized.endsWith(".rsc") ? normalized.slice(0, -4) : normalized, + ); + } + if (representation !== "pages-data") return normalizeCacheabilityRoutePathname(normalized); + + const marker = normalized.indexOf("/_next/data/"); + const restWithBuildId = marker === -1 ? "" : normalized.slice(marker + "/_next/data/".length); + const buildIdEnd = restWithBuildId.indexOf("/"); + if (buildIdEnd === -1 || !restWithBuildId.endsWith(".json")) { + return normalizeCacheabilityRoutePathname(normalized); + } + const assetPath = restWithBuildId.slice(buildIdEnd + 1, -".json".length); + if (!assetPath) return normalizeCacheabilityRoutePathname(normalized); + + let pagePathname: string; + if (assetPath === "index") pagePathname = "/"; + else if (assetPath.endsWith("/index")) { + pagePathname = `/${assetPath.slice(0, -"/index".length)}`; + } else if (assetPath.startsWith("index/")) { + pagePathname = `/${assetPath.slice("index/".length)}`; + } else { + pagePathname = `/${assetPath}`; + } + const basePath = normalized.slice(0, marker); + return normalizeCacheabilityRoutePathname( + pagePathname === "/" ? basePath || "/" : `${basePath}${pagePathname}`, + ); +} + +export function cacheabilityManifestRouteState( + route: CacheabilityManifestRoute, + routePathname: string, + representation?: CacheabilityRepresentation, +): CacheabilityManifestRouteState | null { + const pathname = normalizeCacheabilityRoutePathname(routePathname); + let pathToken = pathname; + if (route.pathPrefix !== undefined) { + if ( + !pathname.startsWith(route.pathPrefix) || + (!route.pathPrefix.endsWith("/") && + pathname.length > route.pathPrefix.length && + pathname[route.pathPrefix.length] !== "/") + ) { + return route.allowUnknown === true ? (route.unknownState ?? route.state) : null; + } + pathToken = pathname.slice(route.pathPrefix.length); + } + const includesPath = (paths: readonly string[] | undefined): boolean => { + if (!paths) return false; + let low = 0; + let high = paths.length - 1; + while (low <= high) { + const middle = (low + high) >>> 1; + const candidate = paths[middle]; + if (candidate === pathToken) return true; + if (candidate < pathToken) low = middle + 1; + else high = middle - 1; + } + return false; + }; + + if (representation && includesPath(route.staticPaths?.[representation])) { + return "static-candidate"; + } + if (representation && route.staticRepresentation === representation) { + return "static-candidate"; + } + if (route.runtimeRepresentation !== undefined) { + return representation === route.runtimeRepresentation ? route.state : null; + } + if (!route.staticPaths && !route.runtimePaths && route.allowUnknown !== true) { + return route.state; + } + if (includesPath(route.runtimePaths)) return route.state; + if (route.staticPaths) { + for (const paths of Object.values(route.staticPaths)) { + if (includesPath(paths)) return route.state; + } + } + if (route.allowUnknown === true) return route.unknownState ?? route.state; + return null; +} + export function findCacheabilityManifestRoute( manifest: CacheabilityManifest, kind: CacheabilityRouteKind, pattern: string, - identity: { representation: CacheabilityRepresentation; requestKey: string }, ): CacheabilityManifestRoute | null { - return ( - manifest.routes[ - cacheabilityManifestRouteKey(kind, pattern, identity.representation, identity.requestKey) - ] ?? null - ); + return manifest.routes[cacheabilityManifestRouteKey(kind, pattern)] ?? null; } diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index 5ad3c6c23..ee08d76b9 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -24,12 +24,14 @@ import { CACHEABILITY_PROBE_TIMEOUT_MS, } from "./cacheability-limits.js"; import { + cacheabilityManifestRouteState, cacheabilityRequestIdentity, - cacheabilityManifestHasRoutePattern, + cacheabilityRoutePathname, findCacheabilityManifestRoute, parseCacheabilityManifest, type CacheabilityManifest, type CacheabilityManifestRoute, + type CacheabilityRepresentation, } from "./cacheability-manifest.js"; type CacheabilityProbeRouteState = @@ -43,6 +45,8 @@ type CacheabilityProbeResult = { kind?: "app-page" | "app-route" | "pages-page"; pattern?: string; reason?: string; + /** The renderer itself completed with a reusable static policy. */ + rendererStatic?: boolean; scope?: "identity" | "pattern"; state: CacheabilityProbeRouteState; status: number; @@ -110,7 +114,16 @@ export function createWorkerCacheabilityAdmissionContext( if (!rawManifest) { if (!requiresCompletedResponseAdmission) return base; const state: RouteCacheabilityState = { - admission: identity ? { policy: "runtime", ...identity } : { policy: "deny" }, + admission: identity + ? { + policy: "runtime", + ...identity, + routePathname: cacheabilityRoutePathname( + new URL(request.url).pathname, + identity.representation, + ), + } + : { policy: "deny" }, captureDeadlineAt: Date.now() + CACHEABILITY_PROBE_TIMEOUT_MS, mode: "admit", }; @@ -123,7 +136,17 @@ export function createWorkerCacheabilityAdmissionContext( const state: RouteCacheabilityState = { admission: - manifest && identity ? { manifest, policy: "manifest", ...identity } : { policy: "deny" }, + manifest && identity + ? { + manifest, + policy: "manifest", + ...identity, + routePathname: cacheabilityRoutePathname( + new URL(request.url).pathname, + identity.representation, + ), + } + : { policy: "deny" }, captureDeadlineAt: Date.now() + CACHEABILITY_PROBE_TIMEOUT_MS, mode: "admit", }; @@ -143,12 +166,14 @@ function probeResponse( routeState: CacheabilityProbeRouteState, outcome: RouteCacheabilityOutcome, status: number, + rendererStatic?: boolean, ): Response { const body: CacheabilityProbeResult = { cacheControl: outcome.cacheControl, kind: state.route?.kind, pattern: state.route?.pattern, reason: outcome.reason, + ...(rendererStatic !== undefined ? { rendererStatic } : {}), ...(routeState === "dynamic" ? { scope: state.patternDynamicReason ? ("pattern" as const) : ("identity" as const) } : {}), @@ -544,12 +569,12 @@ async function finalizeWorkerCacheabilityAdmission( // boundary, so the outer Worker does not buffer them a second time. Config // headers run later, however, and can make an otherwise dynamic response // public. Capture only that unproven final-public case before it can escape. - // A manifest-bearing deployment must also authorize the exact route/path - // identity. Normalize HTML-shaped direct navigations to the same Route - // Handler representation as canonical fetches. + // A manifest-bearing deployment normally authorizes the route pattern. An + // unlisted Route Handler can still opt in with an explicit application or + // config cache policy, but only after this finalizer has checked the fully + // completed response. if (state.route?.kind === "app-route") { let manifestRoute: CacheabilityManifestRoute | null = null; - let manifestContainsRoutePattern = false; const hasExplicitRuntimePolicy = state.explicitResponseCachePolicy === true || state.explicitConfigCachePolicy === true; if ( @@ -562,33 +587,24 @@ async function finalizeWorkerCacheabilityAdmission( } if (admission.policy === "manifest") { const manifest = admission.manifest as CacheabilityManifest; - const representation = - admission.representation === "html" ? "app-route" : admission.representation; manifestRoute = findCacheabilityManifestRoute( manifest, state.route.kind, state.route.pattern, - { - representation: representation as Parameters< - typeof findCacheabilityManifestRoute - >[3]["representation"], - requestKey: admission.requestKey, - }, ); - if (!manifestRoute && hasExplicitRuntimePolicy) { - manifestContainsRoutePattern = cacheabilityManifestHasRoutePattern( - manifest, - state.route.kind, - state.route.pattern, - ); - } } const isManifestAuthorized = - manifestRoute?.state === "static-candidate" && manifestRoute.status === response.status; + manifestRoute !== null && + admission.routePathname !== undefined && + cacheabilityManifestRouteState( + manifestRoute, + admission.routePathname, + admission.representation as CacheabilityRepresentation, + ) !== null; const canUseBoundedRuntimeAdmission = hasExplicitRuntimePolicy && (admission.policy === "runtime" || - (admission.policy === "manifest" && !manifestRoute && !manifestContainsRoutePattern)); + (admission.policy === "manifest" && !isManifestAuthorized)); if ( (!isManifestAuthorized && !canUseBoundedRuntimeAdmission) || response.status >= 500 || @@ -643,20 +659,19 @@ async function finalizeWorkerCacheabilityAdmission( } let manifestRoute: CacheabilityManifestRoute | null = null; + let manifestRouteState: ReturnType = null; if (admission.policy === "manifest") { const manifest = admission.manifest as CacheabilityManifest; - manifestRoute = findCacheabilityManifestRoute(manifest, state.route.kind, state.route.pattern, { - representation: admission.representation as Parameters< - typeof findCacheabilityManifestRoute - >[3]["representation"], - requestKey: admission.requestKey, - }); - if ( - !manifestRoute || - manifestRoute.state === "dynamic" || - manifestRoute.state === "probe-failed" || - manifestRoute.status !== response.status - ) { + manifestRoute = findCacheabilityManifestRoute(manifest, state.route.kind, state.route.pattern); + manifestRouteState = + manifestRoute && admission.routePathname + ? cacheabilityManifestRouteState( + manifestRoute, + admission.routePathname, + admission.representation as CacheabilityRepresentation, + ) + : null; + if (!manifestRoute || !manifestRouteState) { return responseWithCachePolicy(response, response.body, null); } } @@ -694,7 +709,11 @@ async function finalizeWorkerCacheabilityAdmission( // renderer deliberately bypassed its cache-write path (notably draft mode // and nonce-bearing HTML), in which case the completed response must stay // private without being replaced by a 500. - if (manifestRoute?.state === "static-candidate" && outcome?.dynamicUsage === true) { + if ( + manifestRoute && + manifestRouteState === "static-candidate" && + outcome?.dynamicUsage === true + ) { // The replacement 500 does not consume the captured replay stream. Its // cancellation releases the isolate-wide byte reservation immediately. await captured.body?.cancel().catch(() => {}); @@ -800,5 +819,6 @@ export async function finalizeWorkerCacheabilityResponse( : "dynamic", outcome, response.status, + rendererOutcome?.cacheable === true && rendererOutcome.dynamicUsage !== true, ); } diff --git a/packages/vinext/src/shims/cacheability-classification.ts b/packages/vinext/src/shims/cacheability-classification.ts index 688012f79..be2764e56 100644 --- a/packages/vinext/src/shims/cacheability-classification.ts +++ b/packages/vinext/src/shims/cacheability-classification.ts @@ -25,6 +25,7 @@ export type RouteCacheabilityState = { policy: "deny" | "manifest" | "runtime"; representation?: string; requestKey?: string; + routePathname?: string; }; /** Optional admission budget override used by focused runtime tests. */ captureBudget?: { maxBytes: number; reservedBytes: number }; diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index ab84bf8ff..cdfbd00be 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -1956,6 +1956,27 @@ describe("app page dispatch", () => { await expect(response.text()).resolves.toBe("static cached"); }); + it("treats an empty generateStaticParams route as an on-demand static page", async () => { + // Ported from Next.js build behavior: a defined empty prerenderedRoutes + // array still marks the route as SSG with the default revalidate=false. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/build/index.ts + const { options } = createDispatchOptions({ + generateStaticParams: async () => [], + isProduction: true, + route: createRoute({ isDynamic: true }), + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(200); + // Ordinary streaming misses remain private until the response completes; + // the CDN-admission E2E covers the manifest-backed public header. + expect(response.headers.get("cache-control")).toContain("no-store"); + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + await expect(response.text()).resolves.toBe("page"); + expect(options.isrSet).toHaveBeenCalled(); + }); + it("returns method policy responses instead of rendering unsupported methods", async () => { const { options } = createDispatchOptions({ async buildPageElement() { diff --git a/tests/app-segment-config.test.ts b/tests/app-segment-config.test.ts index cbd0e6045..a0bf837ce 100644 --- a/tests/app-segment-config.test.ts +++ b/tests/app-segment-config.test.ts @@ -1,12 +1,31 @@ import { describe, expect, it } from "vite-plus/test"; import { isEdgeRuntime, + resolveAppPageDynamicConfig, resolveAppPageFetchCacheMode, resolveAppPageSegmentConfig, resolveAppRouteHandlerFetchCacheMode, } from "../packages/vinext/src/server/app-segment-config.js"; describe("resolveAppPageSegmentConfig", () => { + it("resolves the dynamic mode shared by build-time discovery and rendering", () => { + // Next.js applies these values while walking the component tree, where the + // nested-most main-chain config wins and force-dynamic remains sticky. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/create-component-tree.tsx + expect( + resolveAppPageDynamicConfig({ + layouts: [{ dynamic: "force-static" }], + page: { dynamic: "auto" }, + }), + ).toBe("auto"); + expect( + resolveAppPageDynamicConfig({ + page: { dynamic: "auto" }, + parallelSegments: [{ dynamic: "force-static" }, { dynamic: "force-dynamic" }], + }), + ).toBe("force-dynamic"); + }); + it("returns defaults when no segment config is present", () => { expect(resolveAppPageSegmentConfig({})).toEqual({ revalidateSeconds: null, diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index e3f4386d1..573fd864e 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -100,17 +100,9 @@ function staticManifestRoute(): { raw: string; route: CacheabilityManifestRoute const route: CacheabilityManifestRoute = { kind: "app-page", pattern: "/page", - representation: "html", - requestKey: "/page", state: "static-candidate", - status: 200, }; - const key = cacheabilityManifestRouteKey( - route.kind, - route.pattern, - route.representation, - route.requestKey, - ); + const key = cacheabilityManifestRouteKey(route.kind, route.pattern); return { raw: JSON.stringify({ buildId: "build-a", routes: { [key]: route }, version: 1 }), route, @@ -121,17 +113,9 @@ function staticPagesManifestRoute(): { raw: string; route: CacheabilityManifestR const route: CacheabilityManifestRoute = { kind: "pages-page", pattern: "/pages-route", - representation: "html", - requestKey: "/pages-route", state: "static-candidate", - status: 200, }; - const key = cacheabilityManifestRouteKey( - route.kind, - route.pattern, - route.representation, - route.requestKey, - ); + const key = cacheabilityManifestRouteKey(route.kind, route.pattern); return { raw: JSON.stringify({ buildId: "build-a", routes: { [key]: route }, version: 1 }), route, @@ -142,17 +126,9 @@ function staticAppRouteManifest(): { raw: string; route: CacheabilityManifestRou const route: CacheabilityManifestRoute = { kind: "app-route", pattern: "/api/data", - representation: "app-route", - requestKey: "/api/data", state: "static-candidate", - status: 200, }; - const key = cacheabilityManifestRouteKey( - route.kind, - route.pattern, - route.representation, - route.requestKey, - ); + const key = cacheabilityManifestRouteKey(route.kind, route.pattern); return { raw: JSON.stringify({ buildId: "build-a", routes: { [key]: route }, version: 1 }), route, @@ -300,6 +276,7 @@ describe("single-request cacheability admission", () => { policy: "runtime", representation: "app-route", requestKey: "/page", + routePathname: "/page", }); }, ); @@ -441,7 +418,7 @@ describe("single-request cacheability admission", () => { }); it.each(["*/*", "text/html"])( - "admits only an exact manifest-backed Route Handler identity for Accept: %s", + "admits a manifest-backed Route Handler pattern for Accept: %s", async (accept) => { const { raw } = staticAppRouteManifest(); const context = createWorkerCacheabilityAdmissionContext( @@ -461,7 +438,7 @@ describe("single-request cacheability admission", () => { }, ); - it("does not let explicit policy bypass an exact manifest status mismatch", async () => { + it("lets the final completed render determine a pattern-backed response status", async () => { const { raw } = staticAppRouteManifest(); const context = createWorkerCacheabilityAdmissionContext( { waitUntil() {} }, @@ -483,10 +460,10 @@ describe("single-request cacheability admission", () => { ); expect(response.status).toBe(302); - expect(response.headers.get("Cache-Control")).toContain("no-store"); + expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=60"); }); - it("keeps an unlisted Route Handler query identity private despite explicit policy", async () => { + it("admits another query identity only after its completed pattern-backed render", async () => { const { raw } = staticAppRouteManifest(); const context = createWorkerCacheabilityAdmissionContext( { waitUntil() {} }, @@ -503,7 +480,7 @@ describe("single-request cacheability admission", () => { context, ); - expect(response.headers.get("Cache-Control")).toContain("no-store"); + expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=60"); }); it("preserves an independently classified hybrid Pages response", async () => { @@ -595,6 +572,195 @@ describe("single-request cacheability admission", () => { await expect(response.text()).resolves.toContain("changed from static to dynamic"); }); + it("checks every sibling render against the route-pattern classification", async () => { + const route: CacheabilityManifestRoute = { + kind: "app-page", + pattern: "/posts/:slug", + state: "runtime-check", + staticPaths: { html: ["/posts/conditional", "/posts/static"] }, + }; + const key = cacheabilityManifestRouteKey(route.kind, route.pattern); + const raw = JSON.stringify({ buildId: "build-a", routes: { [key]: route }, version: 1 }); + + const dynamicContext = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/posts/conditional", { + headers: { Accept: "text/html" }, + }), + raw, + "build-a", + ); + const dynamicState = cacheabilityState(dynamicContext); + dynamicState.route = { kind: "app-page", pattern: route.pattern }; + dynamicState.outcome = { cacheable: false, dynamicUsage: true }; + const dynamicResponse = await finalizeWorkerCacheabilityResponse( + new Response("private sibling"), + dynamicContext, + ); + expect(dynamicResponse.status).toBe(500); + expect(dynamicResponse.headers.get("Cache-Control")).toContain("no-store"); + + const staticContext = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/posts/static", { headers: { Accept: "text/html" } }), + raw, + "build-a", + ); + const staticState = cacheabilityState(staticContext); + staticState.route = { kind: "app-page", pattern: route.pattern }; + staticState.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + const staticResponse = await finalizeWorkerCacheabilityResponse( + new Response("public sibling"), + staticContext, + ); + expect(staticResponse.headers.get("Cache-Control")).toBe("s-maxage=60"); + + const rscContext = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/posts/static.rsc", { + headers: { Accept: "text/x-component", RSC: "1" }, + }), + raw, + "build-a", + ); + const rscState = cacheabilityState(rscContext); + rscState.route = { kind: "app-page", pattern: route.pattern }; + rscState.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + const rscResponse = await finalizeWorkerCacheabilityResponse( + new Response("public RSC sibling"), + rscContext, + ); + expect(rscResponse.headers.get("Cache-Control")).toBe("s-maxage=60"); + }); + + it("renders a concrete dynamic path normally beside an exact static path", async () => { + const route: CacheabilityManifestRoute = { + kind: "app-page", + pattern: "/posts/:slug", + runtimePaths: ["/posts/conditionally-dynamic"], + state: "runtime-check", + staticPaths: { html: ["/posts/static"] }, + }; + const key = cacheabilityManifestRouteKey(route.kind, route.pattern); + const raw = JSON.stringify({ buildId: "build-a", routes: { [key]: route }, version: 1 }); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/posts/conditionally-dynamic", { + headers: { Accept: "text/html" }, + }), + raw, + "build-a", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: route.pattern }; + state.outcome = { cacheable: false, dynamicUsage: true }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("private sibling"), + context, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + await expect(response.text()).resolves.toBe("private sibling"); + }); + + it("keeps a conditionally public exact path dynamic when the condition changes", async () => { + const route: CacheabilityManifestRoute = { + kind: "app-page", + pattern: "/posts/:slug", + runtimePaths: ["/posts/config-public"], + state: "runtime-check", + }; + const key = cacheabilityManifestRouteKey(route.kind, route.pattern); + const raw = JSON.stringify({ buildId: "build-a", routes: { [key]: route }, version: 1 }); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/posts/config-public?preview=1", { + headers: { Accept: "text/html" }, + }), + raw, + "build-a", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: route.pattern }; + state.outcome = { cacheable: false, dynamicUsage: true }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("private preview"), + context, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + await expect(response.text()).resolves.toBe("private preview"); + }); + + it("keeps an unlisted fallback path private for a mixed pattern", async () => { + const route: CacheabilityManifestRoute = { + kind: "app-page", + pattern: "/posts/:slug", + runtimePaths: ["/posts/dynamic"], + state: "runtime-check", + staticPaths: { html: ["/posts/generated"] }, + }; + const key = cacheabilityManifestRouteKey(route.kind, route.pattern); + const raw = JSON.stringify({ buildId: "build-a", routes: { [key]: route }, version: 1 }); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/posts/runtime-fallback", { + headers: { Accept: "text/html" }, + }), + raw, + "build-a", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: route.pattern }; + state.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("runtime fallback"), + context, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + await expect(response.text()).resolves.toBe("runtime fallback"); + }); + + it("preserves static-to-dynamic errors for an all-static pattern fallback", async () => { + const route: CacheabilityManifestRoute = { + allowUnknown: true, + kind: "app-page", + unknownState: "static-candidate", + pattern: "/posts/:slug", + state: "runtime-check", + staticPaths: { html: ["/posts/generated"] }, + }; + const key = cacheabilityManifestRouteKey(route.kind, route.pattern); + const raw = JSON.stringify({ buildId: "build-a", routes: { [key]: route }, version: 1 }); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/posts/runtime-fallback", { + headers: { Accept: "text/html" }, + }), + raw, + "build-a", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: route.pattern }; + state.outcome = { cacheable: false, dynamicUsage: true }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("dynamic fallback"), + context, + ); + + expect(response.status).toBe(500); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + await expect(response.text()).resolves.toContain("changed from static to dynamic"); + }); + const lateFinalPolicyCases: Array<{ finalHeaders: Record; initialPolicy: NonNullable; @@ -680,6 +846,27 @@ describe("single-request cacheability admission", () => { await expect(response.text()).resolves.toBe("pages"); }); + it("serves an unlisted App route normally while withholding CDN admission", async () => { + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + JSON.stringify({ buildId: "build-a", routes: {}, version: 1 }), + "build-a", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: "/page" }; + state.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("still rendered"), + context, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + await expect(response.text()).resolves.toBe("still rendered"); + }); + it("honors an explicit public Pages SSR policy over the default dynamic classification", async () => { // Ported from Next.js: // test/e2e/getserversideprops/test/index.test.ts @@ -747,6 +934,37 @@ describe("cacheability probe finalization", () => { }; } + it("reports whether the renderer itself produced the public cache policy", async () => { + const staticState: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + mode: "probe", + outcome: { cacheable: true, cacheControl: "s-maxage=60" }, + route: { kind: "app-page", pattern: "/posts/:slug" }, + }; + const staticResponse = await finalizeWorkerCacheabilityResponse( + new Response("static"), + contextWith(staticState), + ); + await expect(staticResponse.json()).resolves.toMatchObject({ rendererStatic: true }); + + const configuredState: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + explicitConfigCachePolicy: true, + frameworkResponseCachePolicy: { "cache-control": "no-store" }, + mode: "probe", + outcome: { cacheable: false, dynamicUsage: true }, + route: { kind: "app-page", pattern: "/posts/:slug" }, + }; + const configuredResponse = await finalizeWorkerCacheabilityResponse( + new Response("configured", { headers: { "Cache-Control": "s-maxage=60" } }), + contextWith(configuredState), + ); + await expect(configuredResponse.json()).resolves.toMatchObject({ + rendererStatic: false, + state: "static-candidate", + }); + }); + 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 13e0a8f6c..7dfa2ef97 100644 --- a/tests/cacheability-manifest.test.ts +++ b/tests/cacheability-manifest.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; import { + cacheabilityManifestRouteState, cacheabilityManifestRouteKey, cacheabilityRequestIdentity, + cacheabilityRoutePathname, findCacheabilityManifestRoute, parseCacheabilityManifest, type CacheabilityManifestRoute, @@ -10,46 +12,23 @@ import { const route: CacheabilityManifestRoute = { kind: "app-page", pattern: "/products/:id", - representation: "html", - requestKey: "/products/one?currency=gbp", state: "static-candidate", - status: 200, }; -const key = cacheabilityManifestRouteKey( - route.kind, - route.pattern, - route.representation, - route.requestKey, -); +const key = cacheabilityManifestRouteKey(route.kind, route.pattern); describe("cacheability manifest", () => { - it("accepts only the expected build and exact route key", () => { + it("accepts only the expected build and route-pattern key", () => { const raw = JSON.stringify({ buildId: "build-a", routes: { [key]: route }, version: 1 }); const manifest = parseCacheabilityManifest(raw, "build-a"); expect(manifest).not.toBeNull(); - expect( - findCacheabilityManifestRoute(manifest!, "app-page", "/products/:id", { - representation: "html", - requestKey: "/products/one?currency=gbp", - }), - ).toEqual(route); - expect( - findCacheabilityManifestRoute(manifest!, "app-page", "/products/:id", { - representation: "html", - requestKey: "/products/two?currency=gbp", - }), - ).toBeNull(); + expect(findCacheabilityManifestRoute(manifest!, "app-page", "/products/:id")).toEqual(route); + expect(findCacheabilityManifestRoute(manifest!, "app-page", "/other/:id")).toBeNull(); expect(parseCacheabilityManifest(raw, "build-b")).toBeNull(); }); it("keeps App and Pages routes with the same pattern isolated", () => { const pagesRoute: CacheabilityManifestRoute = { ...route, kind: "pages-page" }; - const pagesKey = cacheabilityManifestRouteKey( - pagesRoute.kind, - pagesRoute.pattern, - pagesRoute.representation, - pagesRoute.requestKey, - ); + const pagesKey = cacheabilityManifestRouteKey(pagesRoute.kind, pagesRoute.pattern); const manifest = parseCacheabilityManifest( JSON.stringify({ buildId: "build-a", @@ -58,37 +37,24 @@ describe("cacheability manifest", () => { }), "build-a", ); - expect( - findCacheabilityManifestRoute(manifest!, "pages-page", "/products/:id", { - representation: "html", - requestKey: "/products/one?currency=gbp", - }), - ).toEqual(pagesRoute); + expect(findCacheabilityManifestRoute(manifest!, "pages-page", "/products/:id")).toEqual( + pagesRoute, + ); }); it("accepts an exact App Route Handler identity", () => { const appRoute: CacheabilityManifestRoute = { ...route, kind: "app-route", - representation: "app-route", - requestKey: "/api/products/one", }; - const appRouteKey = cacheabilityManifestRouteKey( - appRoute.kind, - appRoute.pattern, - appRoute.representation, - appRoute.requestKey, - ); + const appRouteKey = cacheabilityManifestRouteKey(appRoute.kind, appRoute.pattern); const manifest = parseCacheabilityManifest( JSON.stringify({ buildId: "build-a", routes: { [appRouteKey]: appRoute }, version: 1 }), "build-a", ); - expect( - findCacheabilityManifestRoute(manifest!, "app-route", appRoute.pattern, { - representation: "app-route", - requestKey: appRoute.requestKey, - }), - ).toEqual(appRoute); + expect(findCacheabilityManifestRoute(manifest!, "app-route", appRoute.pattern)).toEqual( + appRoute, + ); }); it("rejects malformed routes instead of partially trusting a manifest", () => { @@ -96,7 +62,7 @@ describe("cacheability manifest", () => { parseCacheabilityManifest( JSON.stringify({ buildId: "build-a", - routes: { [key]: { ...route, requestKey: "/different" } }, + routes: { [key]: { ...route, pattern: "/different" } }, version: 1, }), "build-a", @@ -104,6 +70,169 @@ describe("cacheability manifest", () => { ).toBeNull(); }); + it("authorizes only exact concrete static paths for a dynamic pattern", () => { + const mixedRoute: CacheabilityManifestRoute = { + ...route, + pathPrefix: "/products/", + runtimePaths: ["dynamic"], + state: "runtime-check", + staticPaths: { html: ["static"] }, + }; + const manifest = parseCacheabilityManifest( + JSON.stringify({ buildId: "build-a", routes: { [key]: mixedRoute }, version: 1 }), + "build-a", + ); + const parsed = findCacheabilityManifestRoute(manifest!, route.kind, route.pattern)!; + expect(cacheabilityManifestRouteState(parsed, "/products/static", "html")).toBe( + "static-candidate", + ); + expect(cacheabilityManifestRouteState(parsed, "/products/static", "rsc-full")).toBe( + "runtime-check", + ); + expect(cacheabilityManifestRouteState(parsed, "/products/dynamic?preview=1", "html")).toBe( + "runtime-check", + ); + expect(cacheabilityManifestRouteState(parsed, "/products/unlisted")).toBeNull(); + + for (const staticPaths of [[], ["z", "a"], ["a", "a"]]) { + expect( + parseCacheabilityManifest( + JSON.stringify({ + buildId: "build-a", + routes: { + [key]: { + ...route, + pathPrefix: "/products/", + state: "runtime-check", + staticPaths: { html: staticPaths }, + }, + }, + version: 1, + }), + "build-a", + ), + ).toBeNull(); + } + for (const malformedRoute of [ + { + ...mixedRoute, + runtimePaths: ["static"], + }, + { + ...mixedRoute, + pathPrefix: "/products?scope=wrong", + }, + { + ...mixedRoute, + runtimePaths: ["../outside"], + }, + ]) { + expect( + parseCacheabilityManifest( + JSON.stringify({ + buildId: "build-a", + routes: { [key]: malformedRoute }, + version: 1, + }), + "build-a", + ), + ).toBeNull(); + } + + const allStaticRoute: CacheabilityManifestRoute = { + ...mixedRoute, + allowUnknown: true, + runtimePaths: undefined, + unknownState: "static-candidate", + }; + expect(cacheabilityManifestRouteState(allStaticRoute, "/products/unlisted", "html")).toBe( + "static-candidate", + ); + expect(cacheabilityManifestRouteState(allStaticRoute, "/products/static", "rsc-full")).toBe( + "runtime-check", + ); + expect( + parseCacheabilityManifest( + JSON.stringify({ + buildId: "build-a", + routes: { [key]: { ...allStaticRoute, allowUnknown: undefined } }, + 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", + ); + expect( + cacheabilityRoutePathname( + "/docs/_next/data/build-a/products/one.json?currency=gbp", + "pages-data", + ), + ).toBe("/docs/products/one"); + expect(cacheabilityRoutePathname("/_next/data/build-a/index.json", "pages-data")).toBe("/"); + expect(cacheabilityRoutePathname("/docs/products/one?_rsc", "rsc-full")).toBe( + "/docs/products/one", + ); + expect(cacheabilityRoutePathname("/docs/products/one.rsc", "rsc-full")).toBe( + "/docs/products/one", + ); + }); + + it("supports a representation-specific proof for a literal route", () => { + const literal: CacheabilityManifestRoute = { + kind: "app-page", + pattern: "/about", + state: "runtime-check", + staticRepresentation: "html", + }; + const literalKey = cacheabilityManifestRouteKey(literal.kind, literal.pattern); + const manifest = parseCacheabilityManifest( + JSON.stringify({ buildId: "build-a", routes: { [literalKey]: literal }, version: 1 }), + "build-a", + ); + expect(manifest).not.toBeNull(); + expect(cacheabilityManifestRouteState(literal, "/about", "html")).toBe("static-candidate"); + expect(cacheabilityManifestRouteState(literal, "/about", "rsc-full")).toBe("runtime-check"); + + expect( + parseCacheabilityManifest( + JSON.stringify({ + buildId: "build-a", + routes: { + [key]: { ...route, state: "runtime-check", staticRepresentation: "html" }, + }, + version: 1, + }), + "build-a", + ), + ).toBeNull(); + }); + + it("authorizes only the runtime representation certified for a whole pattern", () => { + const shellRoute: CacheabilityManifestRoute = { + kind: "app-page", + pattern: "/posts/:slug", + runtimeRepresentation: "rsc-loading-shell", + state: "runtime-check", + }; + const shellKey = cacheabilityManifestRouteKey(shellRoute.kind, shellRoute.pattern); + expect( + parseCacheabilityManifest( + JSON.stringify({ buildId: "build-a", routes: { [shellKey]: shellRoute }, version: 1 }), + "build-a", + ), + ).not.toBeNull(); + expect(cacheabilityManifestRouteState(shellRoute, "/posts/one", "rsc-loading-shell")).toBe( + "runtime-check", + ); + expect(cacheabilityManifestRouteState(shellRoute, "/posts/one", "rsc-full")).toBeNull(); + expect(cacheabilityManifestRouteState(shellRoute, "/posts/one", "html")).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 d2727462e..f6623f758 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -7,7 +7,6 @@ import { probeStagedWorkerCacheability } from "../packages/cloudflare/src/cachea import { VINEXT_CDN_BUILD_ID_HEADER } from "../packages/cloudflare/src/cache/cdn-build-id.js"; import { cacheabilityManifestRouteKey, - cacheabilityRequestIdentity, type CacheabilityManifestRoute, } from "../packages/vinext/src/server/cacheability-manifest.js"; import { @@ -35,11 +34,12 @@ describe("staged Worker cacheability probes", () => { kind: "html" as const, label: pathname, pathname, + route: optimizableRoute(pathname), sourcePathname: pathname, }); const optimizableRoute = (pattern: string) => ({ - cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: true }, + cacheabilityProbe: { canPrunePattern: true }, kind: "app-page" as const, pattern, }); @@ -50,6 +50,7 @@ describe("staged Worker cacheability probes", () => { return Response.json({ kind: "app-page", pattern: pathname, + rendererStatic: true, state: "static-candidate", status: 200, version: 1, @@ -92,6 +93,7 @@ describe("staged Worker cacheability probes", () => { { kind: "app-page", pattern: "/cached/:slug", + rendererStatic: true, state: "static-candidate", status: 200, version: 1, @@ -105,6 +107,7 @@ describe("staged Worker cacheability probes", () => { kind: "html" as const, label: "/cached/intro", pathname: "/cached/intro", + route: optimizableRoute("/cached/:slug"), sourcePathname: "/cached/intro", }; const result = await probeStagedWorkerCacheability({ @@ -133,11 +136,14 @@ describe("staged Worker cacheability probes", () => { expect(nonces[1]).not.toBe(nonces[2]); const route = Object.values(result.manifest.routes)[0]; - expect(route.requestKey).toBe( - cacheabilityRequestIdentity( - new Request("https://example.com/cached/intro", { headers: target.headers }), - )?.requestKey, - ); + expect(route).toEqual({ + allowUnknown: true, + kind: "app-page", + unknownState: "static-candidate", + pattern: "/cached/:slug", + state: "runtime-check", + staticPaths: { html: ["/cached/intro"] }, + }); expect(result.cacheableTargets).toEqual([target]); }); @@ -179,6 +185,7 @@ describe("staged Worker cacheability probes", () => { return Response.json({ kind: "app-page", pattern: pathname, + rendererStatic: true, state: "static-candidate", status: 200, version: 1, @@ -209,6 +216,7 @@ describe("staged Worker cacheability probes", () => { return Response.json({ kind: "app-page", pattern: pathname, + rendererStatic: true, state: "static-candidate", status: 200, version: 1, @@ -225,7 +233,7 @@ describe("staged Worker cacheability probes", () => { expect(result).toMatchObject({ classified: 3, probed: 3, skipped: 0 }); }); - it("classifies paired App HTML and RSC identities from one completed HTML render", async () => { + it("authorizes every App representation from one concrete-path probe", async () => { const root = createProbeRoot(); const route = optimizableRoute("/posts/:slug"); const html = { ...target("/posts/one"), route }; @@ -241,6 +249,7 @@ describe("staged Worker cacheability probes", () => { Response.json({ kind: "app-page", pattern: route.pattern, + rendererStatic: true, state: "static-candidate", status: 200, version: 1, @@ -257,15 +266,22 @@ describe("staged Worker cacheability probes", () => { }); expect(fetchImpl).toHaveBeenCalledTimes(1); - expect(result).toMatchObject({ classified: 2, probed: 1, skipped: 0 }); + expect(result).toMatchObject({ classified: 1, probed: 1, skipped: 0 }); expect(result.cacheableTargets).toEqual([html, rsc]); - expect(Object.values(result.manifest.routes).map((entry) => entry.representation)).toEqual([ - "html", - "rsc-full", + expect(result.speculativeTargets).toEqual([rsc]); + expect(Object.values(result.manifest.routes)).toEqual([ + expect.objectContaining({ + allowUnknown: true, + kind: "app-page", + unknownState: "static-candidate", + pattern: route.pattern, + state: "runtime-check", + staticPaths: { html: ["/posts/one"] }, + }), ]); }); - it("probes terminal HTML and RSC identities separately because their statuses can differ", async () => { + it("leaves representation-specific statuses to the final completed render", async () => { const root = createProbeRoot(); const route = { kind: "app-page" as const, pattern: "/missing" }; const html = { ...target("/missing"), route }; @@ -282,6 +298,7 @@ describe("staged Worker cacheability probes", () => { return Response.json({ kind: "app-page", pattern: route.pattern, + rendererStatic: true, state: "static-candidate", status: isRsc ? 200 : 404, version: 1, @@ -297,17 +314,20 @@ describe("staged Worker cacheability probes", () => { targets: [rsc, html], }); - expect(fetchImpl).toHaveBeenCalledTimes(2); - expect(result).toMatchObject({ classified: 2, probed: 2, skipped: 0 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ classified: 1, probed: 1, skipped: 0 }); expect(Object.values(result.manifest.routes)).toEqual([ - expect.objectContaining({ representation: "html", status: 404 }), - expect.objectContaining({ representation: "rsc-full", status: 200 }), + expect.objectContaining({ + pattern: route.pattern, + state: "runtime-check", + staticRepresentation: "html", + }), ]); }); it("requires matching discovered route ownership before sharing an HTML classification", async () => { const root = createProbeRoot(); - const html = target("/posts/one"); + const html = { ...target("/posts/one"), route: optimizableRoute("/posts/:slug") }; const rsc = { headers: { Accept: "text/x-component", RSC: "1" }, kind: "rsc-full" as const, @@ -319,6 +339,7 @@ describe("staged Worker cacheability probes", () => { Response.json({ kind: "app-page", pattern: "/posts/:slug", + rendererStatic: true, state: "static-candidate", status: 200, version: 1, @@ -334,8 +355,8 @@ describe("staged Worker cacheability probes", () => { targets: [rsc, html], }); - expect(fetchImpl).toHaveBeenCalledTimes(2); - expect(result).toMatchObject({ classified: 2, probed: 2, skipped: 0 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(result.failures).toEqual(["1 warm target is missing route-pattern metadata"]); }); it("uses pattern-wide dynamic proof to skip sibling HTML and RSC renders", async () => { @@ -364,7 +385,6 @@ describe("staged Worker cacheability probes", () => { const result = await probeStagedWorkerCacheability({ buildId: "application-build", - concurrency: 1, fetchImpl, retries: 0, root, @@ -373,31 +393,122 @@ describe("staged Worker cacheability probes", () => { }); expect(fetchImpl).toHaveBeenCalledTimes(1); - expect(result).toMatchObject({ classified: 1, dynamic: 1, probed: 1, skipped: 3 }); + expect(result).toMatchObject({ classified: 1, dynamic: 1, probed: 1, skipped: 1 }); expect(result.cacheableTargets).toEqual([]); expect(result.manifest.routes).toEqual({}); }); + it("retains loading-shell candidates after pattern-wide dynamic proof", async () => { + const root = createProbeRoot(); + const route = optimizableRoute("/posts/:slug"); + const html = (slug: string) => ({ ...target(`/posts/${slug}`), route }); + const loading = (slug: string) => ({ + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-loading-shell" as const, + label: `/posts/${slug} (RSC loading shell)`, + pathname: `/posts/${slug}?_rsc=loading`, + route, + sourcePathname: `/posts/${slug}`, + }); + const fetchImpl = vi.fn(async () => + Response.json({ + kind: "app-page", + pattern: route.pattern, + scope: "pattern", + state: "dynamic", + status: 200, + version: 1, + }), + ); + + const loadingOne = loading("one"); + const loadingTwo = loading("two"); + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [loadingOne, loadingTwo, html("one"), html("two")], + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ classified: 1, dynamic: 1, probed: 1, skipped: 1 }); + expect(result.cacheableTargets).toEqual([loadingOne, loadingTwo]); + expect(result.speculativeTargets).toEqual([loadingOne, loadingTwo]); + expect(Object.values(result.manifest.routes)).toEqual([ + expect.objectContaining({ + pattern: route.pattern, + runtimeRepresentation: "rsc-loading-shell", + state: "runtime-check", + }), + ]); + }); + + it("classifies each concrete path while storing only exact static paths", async () => { + // Next.js renders each concrete generateStaticParams candidate during its + // prerender pass. One representative request cannot provide equivalent + // evidence for siblings whose dynamic API usage may depend on params. + const root = createProbeRoot(); + const route = optimizableRoute("/posts/:slug"); + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + return Response.json({ + kind: "app-page", + pattern: route.pattern, + rendererStatic: !pathname.endsWith("/conditionally-dynamic"), + scope: pathname.endsWith("/conditionally-dynamic") ? "identity" : undefined, + state: pathname.endsWith("/conditionally-dynamic") ? "dynamic" : "static-candidate", + status: 200, + version: 1, + }); + }, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [ + { ...target("/posts/static"), route }, + { ...target("/posts/conditionally-dynamic"), route }, + ], + }); + + expect(result).toMatchObject({ classified: 1, dynamic: 1, probed: 2, skipped: 0 }); + expect(result.cacheableTargets).toHaveLength(1); + expect(Object.values(result.manifest.routes)).toEqual([ + expect.objectContaining({ + pattern: route.pattern, + runtimePaths: ["/posts/conditionally-dynamic"], + state: "runtime-check", + staticPaths: { html: ["/posts/static"] }, + }), + ]); + }); + it("does not prune siblings when a config cache policy varies within the route pattern", async () => { const root = createProbeRoot(); const route = { - cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: true }, + cacheabilityProbe: { canPrunePattern: false }, kind: "app-page" as const, pattern: "/posts/:slug", }; const fetchImpl = vi.fn(async (input) => { const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + const isOrdinary = pathname === "/posts/z-ordinary"; return Response.json({ kind: "app-page", pattern: route.pattern, - scope: pathname === "/posts/ordinary" ? "pattern" : undefined, - state: pathname === "/posts/ordinary" ? "dynamic" : "static-candidate", + rendererStatic: !isOrdinary, + scope: isOrdinary ? "pattern" : undefined, + state: isOrdinary ? "dynamic" : "static-candidate", status: 200, version: 1, }); }); - const special = { ...target("/posts/special"), route }; + const special = { ...target("/posts/a-special"), route }; + const ordinary = { ...target("/posts/z-ordinary"), route }; const result = await probeStagedWorkerCacheability({ buildId: "application-build", concurrency: 1, @@ -405,18 +516,26 @@ describe("staged Worker cacheability probes", () => { retries: 0, root, targetUrl: "https://example.com", - targets: [{ ...target("/posts/ordinary"), route }, special], + targets: [ordinary, special], }); expect(fetchImpl).toHaveBeenCalledTimes(2); - expect(result).toMatchObject({ classified: 2, dynamic: 1, probed: 2, skipped: 0 }); + expect(result).toMatchObject({ classified: 1, dynamic: 1, probed: 2, skipped: 0 }); expect(result.cacheableTargets).toEqual([special]); + expect(Object.values(result.manifest.routes)).toEqual([ + expect.objectContaining({ + pattern: route.pattern, + runtimePaths: ["/posts/z-ordinary"], + state: "runtime-check", + staticPaths: { html: ["/posts/a-special"] }, + }), + ]); }); - it("probes RSC independently when config cache policy conditions distinguish identities", async () => { + it("does not duplicate a concrete-path probe for conditional RSC policy", async () => { const root = createProbeRoot(); const route = { - cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: false }, + cacheabilityProbe: { canPrunePattern: false }, kind: "app-page" as const, pattern: "/conditional", }; @@ -434,6 +553,7 @@ describe("staged Worker cacheability probes", () => { return Response.json({ kind: "app-page", pattern: route.pattern, + rendererStatic: !isRsc, scope: isRsc ? "pattern" : undefined, state: isRsc ? "dynamic" : "static-candidate", status: 200, @@ -451,9 +571,17 @@ describe("staged Worker cacheability probes", () => { targets: [rsc, html], }); - expect(fetchImpl).toHaveBeenCalledTimes(2); - expect(result).toMatchObject({ classified: 2, dynamic: 1, probed: 2, skipped: 0 }); - expect(result.cacheableTargets).toEqual([html]); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ classified: 1, dynamic: 0, probed: 1, skipped: 0 }); + expect(result.cacheableTargets).toEqual([html, rsc]); + expect(result.speculativeTargets).toEqual([rsc]); + expect(Object.values(result.manifest.routes)).toEqual([ + expect.objectContaining({ + pattern: route.pattern, + state: "runtime-check", + staticRepresentation: "html", + }), + ]); }); it("does not prune siblings from an identity-scoped dynamic observation", async () => { @@ -489,11 +617,70 @@ describe("staged Worker cacheability probes", () => { targets: [rsc("one"), rsc("two"), html("one"), html("two")], }); - expect(fetchImpl).toHaveBeenCalledTimes(4); - expect(result).toMatchObject({ classified: 4, dynamic: 4, probed: 4, skipped: 0 }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ classified: 1, dynamic: 1, probed: 2, skipped: 0 }); + expect(result.cacheableTargets).toEqual([rsc("one"), rsc("two")]); + expect(result.speculativeTargets).toEqual([rsc("one"), rsc("two")]); + expect(Object.values(result.manifest.routes)).toEqual([ + expect.objectContaining({ + pattern: route.pattern, + runtimePaths: ["/posts/one", "/posts/two"], + state: "runtime-check", + }), + ]); }); - it("classifies a nodejs.org-sized paired workload with one render per App path", async () => { + it("keeps a loading-shell warm candidate when the full page is dynamic", async () => { + const root = createProbeRoot(); + const route = optimizableRoute("/posts/:slug"); + const html = { ...target("/posts/one"), route }; + const fullRsc = { + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-full" as const, + label: "/posts/one (RSC full)", + pathname: "/posts/one?_rsc", + route, + sourcePathname: "/posts/one", + }; + const loadingShell = { + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-loading-shell" as const, + label: "/posts/one (RSC loading shell)", + pathname: "/posts/one?_rsc=loading", + route, + sourcePathname: "/posts/one", + }; + + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async () => + Response.json({ + kind: "app-page", + pattern: route.pattern, + scope: "identity", + state: "dynamic", + status: 200, + version: 1, + }), + retries: 0, + root, + targetUrl: "https://example.com", + targets: [loadingShell, fullRsc, html], + }); + + expect(result.probed).toBe(1); + expect(result.cacheableTargets).toEqual([fullRsc, loadingShell]); + expect(result.speculativeTargets).toEqual([fullRsc, loadingShell]); + expect(Object.values(result.manifest.routes)).toEqual([ + expect.objectContaining({ + pattern: route.pattern, + runtimePaths: ["/posts/one"], + state: "runtime-check", + }), + ]); + }); + + it("classifies every nodejs.org path while storing one compact exact-path record", async () => { const root = createProbeRoot(); const pathCount = 2_272; const htmlTargets = Array.from({ length: pathCount }, (_, index) => { @@ -512,15 +699,19 @@ describe("staged Worker cacheability probes", () => { sourcePathname: htmlTarget.sourcePathname, })); const progress: number[] = []; - const fetchImpl = vi.fn(async () => - Response.json({ + const fetchImpl = vi.fn(async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + const isDynamic = pathname === `/docs/${pathCount - 1}`; + return Response.json({ kind: "app-page", pattern: "/docs/:slug", - state: "static-candidate", + rendererStatic: !isDynamic, + scope: isDynamic ? "identity" : undefined, + state: isDynamic ? "dynamic" : "static-candidate", status: 200, version: 1, - }), - ); + }); + }); const result = await probeStagedWorkerCacheability({ buildId: "application-build", @@ -536,11 +727,28 @@ describe("staged Worker cacheability probes", () => { expect(fetchImpl).toHaveBeenCalledTimes(pathCount); expect(result).toMatchObject({ - classified: pathCount * 2, + classified: 1, + dynamic: 1, probed: pathCount, skipped: 0, }); - expect(progress.at(-1)).toBe(pathCount * 2); + expect(result.cacheableTargets).toHaveLength((pathCount - 1) * 2 + 1); + expect(Object.keys(result.manifest.routes)).toHaveLength(1); + expect(Object.values(result.manifest.routes)).toEqual([ + expect.objectContaining({ + pattern: "/docs/:slug", + pathPrefix: "/docs/", + runtimePaths: [`${pathCount - 1}`], + state: "runtime-check", + staticPaths: { + html: 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); + expect(progress.at(-1)).toBe(pathCount); }); it("rejects oversized probe envelopes without buffering the full response", async () => { @@ -569,7 +777,7 @@ describe("staged Worker cacheability probes", () => { expect(cancelled).toBe(true); }); - it("omits dynamic identities from the deployed manifest and final warm targets", async () => { + it("keeps identity-dynamic patterns eligible for authoritative final-render checks", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-cacheability-probe-")); roots.push(root); fs.mkdirSync(path.join(root, "dist", "server"), { recursive: true }); @@ -583,6 +791,7 @@ describe("staged Worker cacheability probes", () => { kind: "html" as const, label: "/static", pathname: "/static", + route: optimizableRoute("/static"), sourcePathname: "/static", }, { @@ -590,6 +799,7 @@ describe("staged Worker cacheability probes", () => { kind: "html" as const, label: "/dynamic", pathname: "/dynamic", + route: optimizableRoute("/dynamic"), sourcePathname: "/dynamic", }, ]; @@ -600,6 +810,7 @@ describe("staged Worker cacheability probes", () => { return Response.json({ kind: "app-page", pattern: pathname, + rendererStatic: pathname === "/static", state: pathname === "/static" ? "static-candidate" : "dynamic", status: 200, version: 1, @@ -613,11 +824,19 @@ describe("staged Worker cacheability probes", () => { expect(result.failures).toEqual([]); expect(result.cacheableTargets).toEqual([targets[0]]); expect(Object.values(result.manifest.routes)).toEqual([ - expect.objectContaining({ pattern: "/static", state: "static-candidate" }), + expect.objectContaining({ + pattern: "/dynamic", + state: "runtime-check", + }), + expect.objectContaining({ + pattern: "/static", + state: "runtime-check", + staticRepresentation: "html", + }), ]); }); - it("stops launching probes when another cacheable identity exceeds the route bound", async () => { + it("enforces the route bound after concrete-path classification", async () => { const root = createProbeRoot(); const fetchImpl = createStaticProbeFetch(); @@ -632,32 +851,20 @@ describe("staged Worker cacheability probes", () => { targetUrl: "https://example.com", targets: [target("/one"), target("/two"), target("/three")], }), - ).rejects.toThrow("produced 2 cacheable identities; the limit is 1"); - expect(fetchImpl).toHaveBeenCalledTimes(2); + ).rejects.toThrow("produced 2 cacheable route patterns; the limit is 1"); + expect(fetchImpl).toHaveBeenCalledTimes(3); }); it("uses the exact serialized-byte boundary and stops before later probes", async () => { const root = createProbeRoot(); const firstTarget = target("/one"); - const identity = cacheabilityRequestIdentity( - new Request(new URL(firstTarget.pathname, "https://example.com"), { - headers: firstTarget.headers, - }), - )!; const route: CacheabilityManifestRoute = { kind: "app-page", pattern: firstTarget.pathname, - representation: identity.representation, - requestKey: identity.requestKey, - state: "static-candidate", - status: 200, + state: "runtime-check", + staticRepresentation: "html", }; - const key = cacheabilityManifestRouteKey( - route.kind, - route.pattern, - route.representation, - route.requestKey, - ); + const key = cacheabilityManifestRouteKey(route.kind, route.pattern); const exactBytes = Buffer.byteLength( JSON.stringify({ buildId: "application-build", @@ -694,7 +901,7 @@ describe("staged Worker cacheability probes", () => { targets: [firstTarget, target("/two"), target("/three")], }), ).rejects.toThrow(`the limit is ${exactBytes} bytes`); - expect(overflowFetch).toHaveBeenCalledTimes(2); + expect(overflowFetch).toHaveBeenCalledTimes(3); }); it("records Pages Router probe envelopes without changing request identity", async () => { @@ -710,6 +917,11 @@ describe("staged Worker cacheability probes", () => { kind: "html" as const, label: "/posts/one", pathname: "/posts/one", + route: { + cacheabilityProbe: { canPrunePattern: true }, + kind: "pages-page" as const, + pattern: "/posts/:slug", + }, sourcePathname: "/posts/one", }; const result = await probeStagedWorkerCacheability({ @@ -718,6 +930,7 @@ describe("staged Worker cacheability probes", () => { Response.json({ kind: "pages-page", pattern: "/posts/:slug", + rendererStatic: true, state: "static-candidate", status: 200, version: 1, @@ -730,13 +943,151 @@ describe("staged Worker cacheability probes", () => { expect(result.failures).toEqual([]); expect(Object.values(result.manifest.routes)).toEqual([ expect.objectContaining({ + allowUnknown: true, kind: "pages-page", + unknownState: "static-candidate", + pattern: "/posts/:slug", + state: "runtime-check", + staticPaths: { html: ["/posts/one"] }, + }), + ]); + }); + + it("embeds empty generateStaticParams fallbacks without a render probe", async () => { + const root = createProbeRoot(); + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fallbackRoutePatterns: [{ kind: "app-page", pattern: "/posts/:slug" }], + root, + targetUrl: "https://example.com", + targets: [], + }); + + expect(result).toMatchObject({ classified: 1, probed: 0 }); + expect(Object.values(result.manifest.routes)).toEqual([ + { + kind: "app-page", pattern: "/posts/:slug", - requestKey: "/posts/one", + state: "static-candidate", + }, + ]); + }); + + it("keeps response-policy-only cacheability as an exact runtime check", async () => { + const root = createProbeRoot(); + const route = optimizableRoute("/posts/:slug"); + const html = { ...target("/posts/config-public"), route }; + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async () => + Response.json({ + kind: "app-page", + pattern: route.pattern, + rendererStatic: false, + state: "static-candidate", + status: 200, + version: 1, + }), + retries: 0, + root, + targetUrl: "https://example.com", + targets: [html], + }); + + expect(result.cacheableTargets).toEqual([html]); + expect(Object.values(result.manifest.routes)).toEqual([ + expect.objectContaining({ + pattern: route.pattern, + runtimePaths: ["/posts/config-public"], + state: "runtime-check", }), ]); }); + it("keeps a literal response-policy-only route as a runtime check", async () => { + const root = createProbeRoot(); + const route = optimizableRoute("/config-public"); + const html = { ...target("/config-public"), route }; + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async () => + Response.json({ + kind: "app-page", + pattern: route.pattern, + rendererStatic: false, + state: "static-candidate", + status: 200, + version: 1, + }), + retries: 0, + root, + targetUrl: "https://example.com", + targets: [html], + }); + + expect(Object.values(result.manifest.routes)).toEqual([ + { + kind: "app-page", + pattern: route.pattern, + state: "runtime-check", + }, + ]); + }); + + it("probes basePath default-locale Pages HTML and data as one concrete path", async () => { + const root = createProbeRoot(); + const htmlRoute = { + cacheabilityProbe: { canPrunePattern: true }, + kind: "pages-page" as const, + pattern: "/posts/:slug", + }; + const html = { + headers: { Accept: "text/html" }, + kind: "html" as const, + label: "/docs/posts/one/", + pathname: "/docs/posts/one/", + route: htmlRoute, + sourcePathname: "/docs/posts/one/", + }; + const data = { + headers: { Accept: "application/json" }, + kind: "pages-data" as const, + label: "/docs/_next/data/build-a/en/posts/one.json (Pages data)", + pathname: "/docs/_next/data/build-a/en/posts/one.json", + route: { + ...htmlRoute, + cacheabilityProbe: { + canPrunePattern: true, + concretePathname: "/docs/posts/one", + }, + }, + sourcePathname: "/docs/_next/data/build-a/en/posts/one.json", + }; + const fetchImpl = vi.fn(async () => + Response.json({ + kind: "pages-page", + pattern: htmlRoute.pattern, + rendererStatic: true, + state: "static-candidate", + status: 200, + version: 1, + }), + ); + + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [data, html], + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(result.cacheableTargets).toEqual([html, data]); + expect(result.speculativeTargets).toEqual([data]); + }); + it("records a statically eligible App Route Handler identity", async () => { const root = createProbeRoot(); const appRouteTarget = { @@ -744,6 +1095,11 @@ describe("staged Worker cacheability probes", () => { kind: "app-route" as const, label: "/api/data (Route Handler)", pathname: "/api/data", + route: { + cacheabilityProbe: { canPrunePattern: true }, + kind: "app-route" as const, + pattern: "/api/data", + }, sourcePathname: "/api/data", }; const result = await probeStagedWorkerCacheability({ @@ -766,8 +1122,7 @@ describe("staged Worker cacheability probes", () => { expect(Object.values(result.manifest.routes)).toEqual([ expect.objectContaining({ kind: "app-route", - representation: "app-route", - requestKey: "/api/data", + state: "static-candidate", }), ]); }); diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index d48495606..afbb865ed 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -105,12 +105,16 @@ function writeTwoStageWorkerArtifact(): void { ); } -function appPageProbeResponse(state: "static-candidate" | "probe-failed" = "static-candidate") { +function appPageProbeResponse( + state: "static-candidate" | "probe-failed" = "static-candidate", + pattern = "/about", +) { return Response.json( { kind: "app-page", - pattern: "/about", + pattern, ...(state === "probe-failed" ? { reason: "render classification failed" } : {}), + ...(state === "static-candidate" ? { rendererStatic: true } : {}), state, status: 200, version: 1, @@ -124,6 +128,19 @@ function appPageProbeResponse(state: "static-candidate" | "probe-failed" = "stat ); } +function appPageRoutePatterns(paths: readonly string[], pattern = "/about") { + return Object.fromEntries( + paths.map((pathname) => [ + pathname, + { + cacheabilityProbe: { canPrunePattern: true }, + kind: "app-page" as const, + pattern, + }, + ]), + ); +} + function mockTwoStageWrangler( options: { failFinalUpload?: boolean; @@ -205,6 +222,7 @@ function pagesPageProbeResponse() { { kind: "pages-page", pattern: "/pages-about", + rendererStatic: true, state: "static-candidate", status: 200, version: 1, @@ -335,7 +353,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { ).not.toThrow(); }); - it("rejects a manifest with more exact identities than the deployment bound", () => { + it("rejects a manifest with more route patterns than the deployment bound", () => { writeTwoStageWorkerArtifact(); const route = { kind: "app-page" as const, @@ -362,7 +380,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { ).toThrow(`the limit is ${MAX_CACHEABILITY_MANIFEST_ROUTES}`); }); - it("uploads only static identities, then warms once and promotes the final version", async () => { + it("warms concrete static paths while tolerating a private paired representation", async () => { writeTwoStageWorkerArtifact(); const events: string[] = []; let uploadCount = 0; @@ -441,7 +459,8 @@ describe("Cloudflare CDN warmup deploy flow", () => { return Response.json( { kind: "app-page", - pattern: "/dynamic", + pattern: "/:slug", + scope: "identity", state: "dynamic", status: 200, version: 1, @@ -461,7 +480,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { { headers: { [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a" } }, ); } - return appPageProbeResponse(); + return appPageProbeResponse("static-candidate", "/:slug"); } if (isReadinessFetch(input)) events.push("readiness"); else { @@ -476,6 +495,26 @@ describe("Cloudflare CDN warmup deploy flow", () => { const isRsc = headers.get("RSC") === "1"; const cacheKey = `${pathname}${isRsc ? "?_rsc" : ""}`; const cacheStatus = (cacheRequestCounts.get(cacheKey) ?? 0) > 1 ? "HIT" : "MISS"; + if (pathname === "/about" && isRsc) { + return new Response("private paired rsc", { + headers: { + "Cache-Control": "no-store", + "CF-Cache-Status": "BYPASS", + "Content-Type": "text/x-component", + [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a", + }, + }); + } + if (pathname === "/dynamic") { + return new Response(isRsc ? "dynamic rsc" : "dynamic html", { + headers: { + "Cache-Control": "no-store", + "CF-Cache-Status": "BYPASS", + "Content-Type": isRsc ? "text/x-component" : "text/html", + [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a", + }, + }); + } return isRsc ? cacheableRsc() : pathname.startsWith("/_next/data/") @@ -498,17 +537,30 @@ describe("Cloudflare CDN warmup deploy flow", () => { routeHandlerPaths: ["/api/data"], routePatterns: { "/about": { - cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: true }, + cacheabilityProbe: { canPrunePattern: true }, kind: "app-page", - pattern: "/about", + pattern: "/:slug", + }, + "/api/data": { + cacheabilityProbe: { canPrunePattern: true }, + kind: "app-route", + pattern: "/api/data", }, - "/api/data": { kind: "app-route", pattern: "/api/data" }, "/dynamic": { - cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: true }, + cacheabilityProbe: { canPrunePattern: true }, kind: "app-page", - pattern: "/dynamic", + pattern: "/:slug", + }, + "/pages-about": { + cacheabilityProbe: { canPrunePattern: true }, + kind: "pages-page", + pattern: "/pages-about", + }, + "/_next/data/app-build-a/pages-about.json": { + cacheabilityProbe: { canPrunePattern: true }, + kind: "pages-page", + pattern: "/pages-about", }, - "/pages-about": { kind: "pages-page", pattern: "/pages-about" }, }, rscPaths: ["/about", "/dynamic"], }), @@ -523,6 +575,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(statusCount).toBe(7); expect(Array.from(cacheRequestCounts.entries())).toEqual([ ["/about?_rsc", 1], + ["/dynamic?_rsc", 1], ["/_next/data/app-build-a/pages-about.json", 1], ["/api/data", 1], ["/about", 1], @@ -535,11 +588,11 @@ describe("Cloudflare CDN warmup deploy flow", () => { "status-2", "readiness", "probe:/about", - "probe:/dynamic", - "probe:/dynamic:rsc", "probe:/pages-about", - "probe:/_next/data/app-build-a/pages-about.json", "probe:/api/data", + // Probe one representative for every pattern before probing siblings so + // a pattern-wide dynamic result can prune the remaining concrete paths. + "probe:/dynamic", "status-3", "upload-final", "status-4", @@ -549,6 +602,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "triggers", "readiness", "warm:/about?_rsc", + "warm:/dynamic?_rsc", "warm:/_next/data/app-build-a/pages-about.json", "warm:/api/data", "warm:/about", @@ -562,40 +616,39 @@ describe("Cloudflare CDN warmup deploy flow", () => { ) as string; const manifest = JSON.parse(manifestJson) as { buildId: string; - routes: Record; + routes: Record< + string, + { + allowUnknown?: boolean; + pattern: string; + pathPrefix?: string; + runtimePaths?: string[]; + staticRepresentation?: string; + staticPaths?: Record; + state: string; + } + >; }; expect(manifest.buildId).toBe("app-build-a"); expect(Object.values(manifest.routes)).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "app-page", - pattern: "/about", - representation: "html", - state: "static-candidate", - }), - expect.objectContaining({ - kind: "app-page", - pattern: "/about", - representation: "rsc-full", - state: "static-candidate", + pattern: "/:slug", + runtimePaths: ["/dynamic"], + staticPaths: { html: ["/about"] }, + state: "runtime-check", }), expect.objectContaining({ kind: "app-route", pattern: "/api/data", - representation: "app-route", state: "static-candidate", }), expect.objectContaining({ kind: "pages-page", pattern: "/pages-about", - representation: "html", - state: "static-candidate", - }), - expect.objectContaining({ - kind: "pages-page", - pattern: "/pages-about", - representation: "pages-data", - state: "static-candidate", + state: "runtime-check", + staticRepresentation: "html", }), ]), ); @@ -633,7 +686,47 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(JSON.parse(manifestJson)).toEqual({ buildId: "app-build-a", routes: {}, version: 1 }); }); - it("promotes an empty manifest when every discovered identity is dynamic", async () => { + it("promotes compact pattern-only admission for an empty static params route", async () => { + writeTwoStageWorkerArtifact(); + const wrangler = mockTwoStageWrangler(); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await expect( + deployWithCdnWarmup(tmpDir, [], { + cacheabilityProbe: true, + config: "dist/server/wrangler.json", + discoverWarmPlan: async () => ({ + appPaths: [], + buildId: "app-build-a", + buildIdentity: "app-build-a", + fallbackRoutePatterns: [{ kind: "app-page", pattern: "/posts/:slug" }], + loadingShellPaths: [], + paths: [], + rscPaths: [], + }), + }), + ).resolves.toBe("https://my-worker.example.workers.dev"); + + expect(wrangler.uploads).toBe(2); + expect(wrangler.promoted).toBe(true); + expect(fetch).not.toHaveBeenCalled(); + const manifestJson = JSON.parse( + wrangler.finalManifestSource!.slice("export default ".length, -2), + ) as string; + expect(JSON.parse(manifestJson)).toEqual({ + buildId: "app-build-a", + routes: { + '["app-page","/posts/:slug"]': { + kind: "app-page", + pattern: "/posts/:slug", + state: "static-candidate", + }, + }, + version: 1, + }); + }); + + it("promotes an empty manifest when every discovered pattern is dynamic", async () => { writeTwoStageWorkerArtifact(); const wrangler = mockTwoStageWrangler(); vi.mocked(fetch).mockImplementation(async (input, init) => @@ -642,6 +735,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { { kind: "app-page", pattern: "/dynamic", + scope: "pattern", state: "dynamic", status: 200, version: 1, @@ -664,6 +758,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/dynamic"], + routePatterns: appPageRoutePatterns(["/dynamic"], "/dynamic"), rscPaths: [], }), warmCdnReadinessProbes: 1, @@ -680,6 +775,67 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(JSON.parse(manifestJson)).toEqual({ buildId: "app-build-a", routes: {}, version: 1 }); }); + it("probes a Pages-only concrete path once before warming HTML and data", async () => { + writeTwoStageWorkerArtifact(); + const wrangler = mockTwoStageWrangler(); + vi.mocked(fetch).mockImplementation(async (input, init) => { + if (new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1") { + return pagesPageProbeResponse(); + } + if (isReadinessFetch(input)) return cacheableHtml(); + return new URL(formatFetchUrl(input)).pathname.startsWith("/_next/data/") + ? cacheablePagesData() + : cacheableHtml(); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await expect( + deployWithCdnWarmup(tmpDir, [], { + cacheabilityProbe: true, + config: "dist/server/wrangler.json", + discoverWarmPlan: async () => ({ + buildId: "app-build-a", + buildIdentity: "app-build-a", + loadingShellPaths: [], + pagesDataPaths: ["/_next/data/app-build-a/pages-about.json"], + pagesPaths: ["/pages-about"], + paths: ["/pages-about"], + routePatterns: { + "/_next/data/app-build-a/pages-about.json": { + cacheabilityProbe: { canPrunePattern: true }, + kind: "pages-page", + pattern: "/pages-about", + }, + "/pages-about": { + cacheabilityProbe: { canPrunePattern: true }, + kind: "pages-page", + pattern: "/pages-about", + }, + }, + rscPaths: [], + }), + warmCdnPromotionDelay: 0, + warmCdnReadinessProbes: 1, + warmCdnRetries: 0, + }), + ).resolves.toBe("https://my-worker.example.workers.dev"); + + const requests = vi.mocked(fetch).mock.calls; + expect( + requests.filter(([, init]) => + new Headers(init?.headers).has(VINEXT_CACHEABILITY_PROBE_HEADER), + ), + ).toHaveLength(1); + expect( + requests.filter( + ([input, init]) => + !isReadinessFetch(input) && + !new Headers(init?.headers).has(VINEXT_CACHEABILITY_PROBE_HEADER), + ), + ).toHaveLength(2); + expect(wrangler.promoted).toBe(true); + }); + it("leaves an empty-manifest final Worker staged when promotion is disabled", async () => { writeTwoStageWorkerArtifact(); const wrangler = mockTwoStageWrangler(); @@ -769,6 +925,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), dangerouslyPromoteOnCdnWarmError: true, @@ -815,6 +972,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), dangerouslyPromoteOnCdnWarmError: true, @@ -890,6 +1048,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnCertify: true, @@ -909,7 +1068,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { ).toBe(false); }); - it("leaves production triggers untouched when an exact request cannot be classified", async () => { + it("leaves production triggers untouched when a route pattern cannot be classified", async () => { writeTwoStageWorkerArtifact(); const wrangler = mockTwoStageWrangler(); vi.mocked(fetch).mockImplementation(async (input, init) => @@ -931,6 +1090,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -965,6 +1125,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -1009,6 +1170,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -1055,6 +1217,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/first", "/queued"], + routePatterns: appPageRoutePatterns(["/first", "/queued"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -1120,6 +1283,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -1186,6 +1350,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -1248,6 +1413,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -1315,6 +1481,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -1382,6 +1549,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index a46de1d05..d87f3be46 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -100,6 +100,7 @@ describe("Cloudflare CDN warmup", () => { buildId: "build-a", buildIdentity: "rsc-build-a", deploymentId: "dpl_123", + fallbackRoutePatterns: [{ kind: "app-page", pattern: "/posts/:slug" }], loadingShellPaths: ["/dashboard"], pagesDataPaths: ["/docs/_next/data/build-a/pages.json"], paths: ["/dashboard", "/dynamic", "/pages"], @@ -119,6 +120,7 @@ describe("Cloudflare CDN warmup", () => { buildId: "build-a", buildIdentity: "rsc-build-a", deploymentId: "dpl_123", + fallbackRoutePatterns: [{ kind: "app-page", pattern: "/posts/:slug" }], loadingShellPaths: ["/docs/dashboard/"], pagesDataPaths: ["/docs/_next/data/build-a/pages.json"], paths: ["/docs/dashboard/", "/docs/dynamic/", "/docs/pages/"], @@ -233,6 +235,7 @@ describe("Cloudflare CDN warmup", () => { skipped: 0, failed: 0, failures: [], + skippedTargets: [], warmedPlan: { loadingShellPaths: ["/search?q=x"], pagesDataPaths: ["/_next/data/build-a/pages.json"], @@ -327,16 +330,15 @@ describe("Cloudflare CDN warmup", () => { }); }); - await expect( - warmCdnCache({ - expectedRscBuildId: "rsc-build-a", - fetchImpl: fetchImpl as typeof fetch, - paths: ["/dynamic"], - rscPaths: ["/dynamic"], - strict: true, - targetUrl: "https://app.example.com", - }), - ).resolves.toEqual({ + const result = await warmCdnCache({ + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: ["/dynamic"], + rscPaths: ["/dynamic"], + strict: true, + targetUrl: "https://app.example.com", + }); + expect(result).toMatchObject({ total: 2, warmed: 0, skipped: 2, @@ -345,6 +347,7 @@ describe("Cloudflare CDN warmup", () => { warmedPlan: { loadingShellPaths: [], pagesDataPaths: [], paths: [], rscPaths: [] }, retryPlan: { loadingShellPaths: [], pagesDataPaths: [], paths: [], rscPaths: [] }, }); + expect(result.skippedTargets).toHaveLength(2); }); it("rejects a Pages data response with a non-JSON representation", async () => { diff --git a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts index e45c927a3..f45f738a0 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts @@ -1,8 +1,6 @@ import { expect, test } from "@playwright/test"; -test("admits only exact manifest-backed App Page responses after clean EOF", async ({ - request, -}) => { +test("admits pattern-backed App responses only after each clean EOF", async ({ request }) => { const certified = await request.get("/cacheability/static", { headers: { Accept: "text/html" }, }); @@ -60,8 +58,8 @@ test("admits only exact manifest-backed App Page responses after clean EOF", asy headers: { Accept: "text/html" }, }); expect(unlistedQuery.status()).toBe(200); - expect(unlistedQuery.headers()["cache-control"]).toContain("no-store"); - expect(unlistedQuery.headers()["cdn-cache-control"]).toBeUndefined(); + expect(unlistedQuery.headers()["cdn-cache-control"]).toContain("public"); + expect(unlistedQuery.headers()["cache-control"]).toContain("must-revalidate"); const knownDynamic = await request.get("/cacheability/dynamic", { headers: { Accept: "text/html" }, @@ -76,6 +74,91 @@ test("admits only exact manifest-backed App Page responses after clean EOF", asy expect(configPublicDynamic.status()).toBe(200); expect(configPublicDynamic.headers()["cdn-cache-control"]).toContain("max-age=32"); + const configPrivateDynamic = await request.get("/cacheability/config-public-dynamic?preview=1", { + headers: { Accept: "text/html" }, + }); + expect(configPrivateDynamic.status()).toBe(200); + expect(configPrivateDynamic.headers()["cache-control"]).toContain("no-store"); + expect(configPrivateDynamic.headers()["cdn-cache-control"]).toBeUndefined(); + + const ordinaryConfigPattern = await request.get("/cacheability/config-public-pattern/ordinary", { + headers: { Accept: "text/html" }, + }); + expect(ordinaryConfigPattern.status()).toBe(200); + expect(ordinaryConfigPattern.headers()["cache-control"]).toContain("no-store"); + expect(ordinaryConfigPattern.headers()["cdn-cache-control"]).toBeUndefined(); + + const publicConfigPattern = await request.get("/cacheability/config-public-pattern/special", { + headers: { Accept: "text/html" }, + }); + expect(publicConfigPattern.status()).toBe(200); + expect(publicConfigPattern.headers()["cdn-cache-control"]).toContain("max-age=33"); + + const publicConfigRepresentation = await request.get( + "/cacheability/config-public-representation", + { headers: { Accept: "text/html" } }, + ); + expect(publicConfigRepresentation.status()).toBe(200); + expect(publicConfigRepresentation.headers()["cdn-cache-control"]).toContain("max-age=34"); + + const privateConfigRepresentation = await request.get( + "/cacheability/config-public-representation?_rsc", + { headers: { Accept: "text/x-component", RSC: "1" } }, + ); + expect(privateConfigRepresentation.status()).toBe(200); + expect(privateConfigRepresentation.headers()["cache-control"]).toContain("no-store"); + expect(privateConfigRepresentation.headers()["cdn-cache-control"]).toBeUndefined(); + + // Next.js evaluates each generateStaticParams candidate separately: one + // sibling can remain ISR while another becomes request-time dynamic. The + // pattern manifest must preserve that behavior by probing each concrete + // path once while deduplicating its HTML/RSC identities. + // Ported from the concrete-path classification in Next.js: + // packages/next/src/build/index.ts + const staticPatternSibling = await request.get("/cacheability/pattern-runtime-dynamic/static", { + headers: { Accept: "text/html" }, + }); + expect(staticPatternSibling.status()).toBe(200); + expect(staticPatternSibling.headers()["cdn-cache-control"]).toContain("public"); + + const dynamicPatternSibling = await request.get("/cacheability/pattern-runtime-dynamic/dynamic", { + headers: { Accept: "text/html" }, + }); + expect(dynamicPatternSibling.status()).toBe(200); + const dynamicPatternBody = await dynamicPatternSibling.text(); + expect(dynamicPatternBody).toContain("pattern runtime "); + expect(dynamicPatternBody).toContain("dynamic"); + expect(dynamicPatternSibling.headers()["cache-control"]).toContain("no-store"); + expect(dynamicPatternSibling.headers()["cdn-cache-control"]).toBeUndefined(); + + const unlistedPatternSibling = await request.get( + "/cacheability/pattern-runtime-dynamic/unlisted", + { headers: { Accept: "text/html" } }, + ); + expect(unlistedPatternSibling.status()).toBe(200); + const unlistedPatternBody = await unlistedPatternSibling.text(); + expect(unlistedPatternBody).toContain("pattern runtime "); + expect(unlistedPatternBody).toContain("unlisted"); + expect(unlistedPatternSibling.headers()["cache-control"]).toContain("no-store"); + expect(unlistedPatternSibling.headers()["cdn-cache-control"]).toBeUndefined(); + + const allStaticFallback = await request.get( + "/cacheability/pattern-runtime-static/runtime-fallback", + { headers: { Accept: "text/html" } }, + ); + expect(allStaticFallback.status()).toBe(200); + expect(allStaticFallback.headers()["cdn-cache-control"]).toContain("public"); + const allStaticFallbackBody = await allStaticFallback.text(); + expect(allStaticFallbackBody).toContain("runtime static "); + expect(allStaticFallbackBody).toContain("runtime-fallback"); + + const emptyStaticFallback = await request.get("/cacheability/static-empty/on-demand", { + headers: { Accept: "text/html" }, + }); + expect(emptyStaticFallback.status()).toBe(200); + expect(emptyStaticFallback.headers()["cdn-cache-control"]).toContain("public"); + expect(await emptyStaticFallback.text()).toContain("empty fallback on-demand"); + const staticToDynamic = await request.get("/cacheability/static-to-dynamic/runtime", { headers: { Accept: "text/html", "X-Probe-Value": "private-value" }, }); @@ -84,6 +167,14 @@ test("admits only exact manifest-backed App Page responses after clean EOF", asy expect(staticToDynamic.headers()["cache-control"]).toContain("no-store"); expect(staticToDynamic.headers()["cdn-cache-control"]).toBeUndefined(); + const fallbackStaticToDynamic = await request.get("/cacheability/static-to-dynamic/unlisted", { + headers: { Accept: "text/html", "X-Probe-Value": "private-value" }, + }); + expect(fallbackStaticToDynamic.status()).toBe(500); + expect(await fallbackStaticToDynamic.text()).toContain("changed from static to dynamic"); + expect(fallbackStaticToDynamic.headers()["cache-control"]).toContain("no-store"); + expect(fallbackStaticToDynamic.headers()["cdn-cache-control"]).toBeUndefined(); + const uncertifiedRsc = await request.get("/cacheability/prerender-phase/known?_rsc", { headers: { Accept: "text/x-component", RSC: "1" }, }); @@ -100,8 +191,7 @@ test("admits only exact manifest-backed App Page responses after clean EOF", asy "/cacheability/route-handler-static?user=one", ); expect(unlistedRouteHandlerQuery.status()).toBe(200); - expect(unlistedRouteHandlerQuery.headers()["cache-control"]).toContain("no-store"); - expect(unlistedRouteHandlerQuery.headers()["cdn-cache-control"]).toBeUndefined(); + expect(unlistedRouteHandlerQuery.headers()["cdn-cache-control"]).toContain("public"); // Next.js does not statically generate a GET+POST Route Handler, so this // route is intentionally absent from the probe manifest. Its handler-owned diff --git a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts index 00400d8e8..1d01ee06e 100644 --- a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts +++ b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts @@ -119,8 +119,14 @@ test("classifies Pages Router data contracts inside the staged Worker", async ({ } }); -test("admits only exact manifest-backed Pages Router responses", async ({ request }) => { - for (const pathname of ["/cacheability-pages/isr", "/cacheability-pages/posts/known"]) { +test("admits pattern-backed Pages Router responses after each completed render", async ({ + request, +}) => { + for (const pathname of [ + "/cacheability-pages/isr", + "/cacheability-pages/isr?unlisted=1", + "/cacheability-pages/posts/known", + ]) { const response = await request.get(pathname, { headers: { Accept: "text/html" } }); expect(response.status(), pathname).toBe(200); expect(response.headers()["cdn-cache-control"], pathname).toContain("max-age=60"); @@ -128,6 +134,7 @@ test("admits only exact manifest-backed Pages Router responses", async ({ reques for (const pathname of [ `/_next/data/${buildId}/cacheability-pages/isr.json`, + `/_next/data/${buildId}/cacheability-pages/isr.json?unlisted=1`, `/_next/data/${buildId}/cacheability-pages/posts/known.json`, ]) { const response = await request.get(pathname, { headers: { Accept: "application/json" } }); @@ -150,10 +157,8 @@ test("admits only exact manifest-backed Pages Router responses", async ({ reques for (const pathname of [ "/cacheability-pages/gssp", "/cacheability-pages/get-initial-props", - "/cacheability-pages/isr?unlisted=1", "/cacheability-pages/posts/unknown", `/_next/data/${buildId}/cacheability-pages/gssp.json`, - `/_next/data/${buildId}/cacheability-pages/isr.json?unlisted=1`, `/_next/data/${buildId}/cacheability-pages/posts/unknown.json`, ]) { const response = await request.get(pathname, { diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-runtime-dynamic/[slug]/page.tsx b/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-runtime-dynamic/[slug]/page.tsx new file mode 100644 index 000000000..097120823 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-runtime-dynamic/[slug]/page.tsx @@ -0,0 +1,17 @@ +import { headers } from "next/headers"; + +export const revalidate = 60; + +export function generateStaticParams() { + return [{ slug: "static" }, { slug: "dynamic" }]; +} + +export default async function PatternRuntimeDynamicPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + if (slug === "dynamic") await headers(); + return
pattern runtime {slug}
; +} diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-runtime-static/[slug]/page.tsx b/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-runtime-static/[slug]/page.tsx new file mode 100644 index 000000000..6370c472e --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/pattern-runtime-static/[slug]/page.tsx @@ -0,0 +1,14 @@ +export const revalidate = 60; + +export function generateStaticParams() { + return [{ slug: "generated" }]; +} + +export default async function PatternRuntimeStaticPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + return
runtime static {slug}
; +} diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/static-empty/[slug]/page.tsx b/tests/fixtures/ppr-impact-demo/app/cacheability/static-empty/[slug]/page.tsx new file mode 100644 index 000000000..fea137094 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/static-empty/[slug]/page.tsx @@ -0,0 +1,8 @@ +export function generateStaticParams() { + return []; +} + +export default async function StaticEmptyPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + return
{`empty fallback ${slug}`}
; +} diff --git a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json index b9fa7b7f2..5722fa7d6 100644 --- a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json +++ b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json @@ -1,205 +1,99 @@ { "buildId": "ppr-impact-demo-cacheability", "routes": { - "[\"app-page\",\"/cacheability/config-public-dynamic\",\"html\",\"/cacheability/config-public-dynamic\"]": { + "[\"app-page\",\"/cacheability/config-public-dynamic\"]": { "kind": "app-page", "pattern": "/cacheability/config-public-dynamic", - "representation": "html", - "requestKey": "/cacheability/config-public-dynamic", - "state": "static-candidate", - "status": 200 + "state": "runtime-check" }, - "[\"app-page\",\"/cacheability/dynamic\",\"html\",\"/cacheability/dynamic\"]": { + "[\"app-page\",\"/cacheability/config-public-pattern/:slug\"]": { "kind": "app-page", - "pattern": "/cacheability/dynamic", - "representation": "html", - "requestKey": "/cacheability/dynamic", - "state": "dynamic", - "status": 200 + "pattern": "/cacheability/config-public-pattern/:slug", + "state": "runtime-check" }, - "[\"app-page\",\"/cacheability/static\",\"html\",\"/cacheability/static\"]": { + "[\"app-page\",\"/cacheability/config-public-representation\"]": { "kind": "app-page", - "pattern": "/cacheability/static", - "representation": "html", - "requestKey": "/cacheability/static", - "state": "static-candidate", - "status": 200 + "pattern": "/cacheability/config-public-representation", + "state": "runtime-check" }, - "[\"app-page\",\"/cacheability/static\",\"html\",\"/cacheability/static?runtime=1\"]": { + "[\"app-page\",\"/cacheability/pattern-runtime-dynamic/:slug\"]": { "kind": "app-page", - "pattern": "/cacheability/static", - "representation": "html", - "requestKey": "/cacheability/static?runtime=1", + "pattern": "/cacheability/pattern-runtime-dynamic/:slug", + "runtimePaths": ["/cacheability/pattern-runtime-dynamic/dynamic"], "state": "runtime-check", - "status": 200 - }, - "[\"app-page\",\"/cacheability/static\",\"html\",\"/cacheability/static?late-policy=set-cookie\"]": { - "kind": "app-page", - "pattern": "/cacheability/static", - "representation": "html", - "requestKey": "/cacheability/static?late-policy=set-cookie", - "state": "static-candidate", - "status": 200 + "staticPaths": { + "html": ["/cacheability/pattern-runtime-dynamic/static"] + } }, - "[\"app-page\",\"/cacheability/static\",\"html\",\"/cacheability/static?late-policy=cache-control\"]": { + "[\"app-page\",\"/cacheability/pattern-runtime-static/:slug\"]": { + "allowUnknown": true, "kind": "app-page", - "pattern": "/cacheability/static", - "representation": "html", - "requestKey": "/cacheability/static?late-policy=cache-control", - "state": "static-candidate", - "status": 200 - }, - "[\"app-page\",\"/cacheability/static\",\"html\",\"/cacheability/static?late-policy=cdn-cache-control\"]": { - "kind": "app-page", - "pattern": "/cacheability/static", - "representation": "html", - "requestKey": "/cacheability/static?late-policy=cdn-cache-control", - "state": "static-candidate", - "status": 200 - }, - "[\"app-page\",\"/cacheability/static\",\"html\",\"/cacheability/static?late-policy=cloudflare-cdn-cache-control\"]": { - "kind": "app-page", - "pattern": "/cacheability/static", - "representation": "html", - "requestKey": "/cacheability/static?late-policy=cloudflare-cdn-cache-control", - "state": "static-candidate", - "status": 200 + "unknownState": "static-candidate", + "pattern": "/cacheability/pattern-runtime-static/:slug", + "state": "runtime-check", + "staticPaths": { + "html": ["/cacheability/pattern-runtime-static/generated"] + } }, - "[\"app-page\",\"/cacheability/static\",\"rsc-full\",\"/cacheability/static?_rsc\"]": { + "[\"app-page\",\"/cacheability/static\"]": { "kind": "app-page", "pattern": "/cacheability/static", - "representation": "rsc-full", - "requestKey": "/cacheability/static?_rsc", - "state": "static-candidate", - "status": 200 + "state": "static-candidate" }, - "[\"app-page\",\"/cacheability/static\",\"rsc-loading-shell\",\"/cacheability/static?_rsc=9qLBDIU2NgN178cB\"]": { + "[\"app-page\",\"/cacheability/static-empty/:slug\"]": { "kind": "app-page", - "pattern": "/cacheability/static", - "representation": "rsc-loading-shell", - "requestKey": "/cacheability/static?_rsc=9qLBDIU2NgN178cB", - "state": "static-candidate", - "status": 200 + "pattern": "/cacheability/static-empty/:slug", + "state": "static-candidate" }, - "[\"app-page\",\"/cacheability/static-to-dynamic/:slug\",\"html\",\"/cacheability/static-to-dynamic/runtime\"]": { + "[\"app-page\",\"/cacheability/static-to-dynamic/:slug\"]": { + "allowUnknown": true, "kind": "app-page", + "unknownState": "static-candidate", "pattern": "/cacheability/static-to-dynamic/:slug", - "representation": "html", - "requestKey": "/cacheability/static-to-dynamic/runtime", - "state": "static-candidate", - "status": 200 + "state": "runtime-check", + "staticPaths": { + "html": ["/cacheability/static-to-dynamic/runtime"] + } }, - "[\"app-route\",\"/cacheability/route-handler-config-public-late-error\",\"app-route\",\"/cacheability/route-handler-config-public-late-error\"]": { + "[\"app-route\",\"/cacheability/route-handler-config-public-late-error\"]": { "kind": "app-route", "pattern": "/cacheability/route-handler-config-public-late-error", - "representation": "app-route", - "requestKey": "/cacheability/route-handler-config-public-late-error", - "state": "static-candidate", - "status": 200 + "state": "static-candidate" }, - "[\"app-route\",\"/cacheability/route-handler-explicit-dynamic\",\"app-route\",\"/cacheability/route-handler-explicit-dynamic\"]": { + "[\"app-route\",\"/cacheability/route-handler-explicit-dynamic\"]": { "kind": "app-route", "pattern": "/cacheability/route-handler-explicit-dynamic", - "representation": "app-route", - "requestKey": "/cacheability/route-handler-explicit-dynamic", - "state": "static-candidate", - "status": 200 + "state": "static-candidate" }, - "[\"app-route\",\"/cacheability/route-handler-static\",\"app-route\",\"/cacheability/route-handler-static\"]": { + "[\"app-route\",\"/cacheability/route-handler-static\"]": { "kind": "app-route", "pattern": "/cacheability/route-handler-static", - "representation": "app-route", - "requestKey": "/cacheability/route-handler-static", - "state": "static-candidate", - "status": 200 - }, - "[\"pages-page\",\"/cacheability-pages/isr\",\"html\",\"/cacheability-pages/isr\"]": { - "kind": "pages-page", - "pattern": "/cacheability-pages/isr", - "representation": "html", - "requestKey": "/cacheability-pages/isr", - "state": "static-candidate", - "status": 200 + "state": "static-candidate" }, - "[\"pages-page\",\"/cacheability-pages/isr\",\"pages-data\",\"/_next/data/ppr-impact-demo-cacheability/cacheability-pages/isr.json\"]": { - "kind": "pages-page", - "pattern": "/cacheability-pages/isr", - "representation": "pages-data", - "requestKey": "/_next/data/ppr-impact-demo-cacheability/cacheability-pages/isr.json", - "state": "static-candidate", - "status": 200 - }, - "[\"pages-page\",\"/cacheability-pages/middleware\",\"html\",\"/cacheability-pages/middleware\"]": { - "kind": "pages-page", - "pattern": "/cacheability-pages/middleware", - "representation": "html", - "requestKey": "/cacheability-pages/middleware", - "state": "static-candidate", - "status": 200 - }, - "[\"pages-page\",\"/cacheability-pages/config-header\",\"html\",\"/cacheability-pages/config-header\"]": { + "[\"pages-page\",\"/cacheability-pages/config-header\"]": { "kind": "pages-page", "pattern": "/cacheability-pages/config-header", - "representation": "html", - "requestKey": "/cacheability-pages/config-header", - "state": "static-candidate", - "status": 200 - }, - "[\"pages-page\",\"/cacheability-pages/gssp\",\"html\",\"/cacheability-pages/gssp\"]": { - "kind": "pages-page", - "pattern": "/cacheability-pages/gssp", - "representation": "html", - "requestKey": "/cacheability-pages/gssp", - "state": "dynamic", - "status": 200 + "state": "static-candidate" }, - "[\"pages-page\",\"/cacheability-pages/gssp\",\"pages-data\",\"/_next/data/ppr-impact-demo-cacheability/cacheability-pages/gssp.json\"]": { - "kind": "pages-page", - "pattern": "/cacheability-pages/gssp", - "representation": "pages-data", - "requestKey": "/_next/data/ppr-impact-demo-cacheability/cacheability-pages/gssp.json", - "state": "dynamic", - "status": 200 - }, - "[\"pages-page\",\"/cacheability-pages/gssp-public\",\"html\",\"/cacheability-pages/gssp-public\"]": { - "kind": "pages-page", - "pattern": "/cacheability-pages/gssp-public", - "representation": "html", - "requestKey": "/cacheability-pages/gssp-public", - "state": "static-candidate", - "status": 200 - }, - "[\"pages-page\",\"/cacheability-pages/gssp-public\",\"pages-data\",\"/_next/data/ppr-impact-demo-cacheability/cacheability-pages/gssp-public.json\"]": { + "[\"pages-page\",\"/cacheability-pages/gssp-public\"]": { "kind": "pages-page", "pattern": "/cacheability-pages/gssp-public", - "representation": "pages-data", - "requestKey": "/_next/data/ppr-impact-demo-cacheability/cacheability-pages/gssp-public.json", - "state": "static-candidate", - "status": 200 + "state": "static-candidate" }, - "[\"pages-page\",\"/cacheability-pages/get-initial-props\",\"html\",\"/cacheability-pages/get-initial-props\"]": { + "[\"pages-page\",\"/cacheability-pages/isr\"]": { "kind": "pages-page", - "pattern": "/cacheability-pages/get-initial-props", - "representation": "html", - "requestKey": "/cacheability-pages/get-initial-props", - "state": "dynamic", - "status": 200 + "pattern": "/cacheability-pages/isr", + "state": "static-candidate" }, - "[\"pages-page\",\"/cacheability-pages/posts/:slug\",\"html\",\"/cacheability-pages/posts/known\"]": { + "[\"pages-page\",\"/cacheability-pages/middleware\"]": { "kind": "pages-page", - "pattern": "/cacheability-pages/posts/:slug", - "representation": "html", - "requestKey": "/cacheability-pages/posts/known", - "state": "static-candidate", - "status": 200 + "pattern": "/cacheability-pages/middleware", + "state": "static-candidate" }, - "[\"pages-page\",\"/cacheability-pages/posts/:slug\",\"pages-data\",\"/_next/data/ppr-impact-demo-cacheability/cacheability-pages/posts/known.json\"]": { + "[\"pages-page\",\"/cacheability-pages/posts/:slug\"]": { "kind": "pages-page", "pattern": "/cacheability-pages/posts/:slug", - "representation": "pages-data", - "requestKey": "/_next/data/ppr-impact-demo-cacheability/cacheability-pages/posts/known.json", - "state": "static-candidate", - "status": 200 + "state": "static-candidate" } }, "version": 1 diff --git a/tests/fixtures/ppr-impact-demo/next.config.ts b/tests/fixtures/ppr-impact-demo/next.config.ts index ab1b189ea..50f3fed13 100644 --- a/tests/fixtures/ppr-impact-demo/next.config.ts +++ b/tests/fixtures/ppr-impact-demo/next.config.ts @@ -24,6 +24,7 @@ export default { headers: async () => [ { source: "/cacheability/config-public-dynamic", + missing: [{ type: "query", key: "preview" }], headers: [{ key: "Cache-Control", value: "s-maxage=32" }], }, { diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index a9c904992..6dd33d92b 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -44,6 +44,12 @@ describe("prerender path manifest", () => { ) { return Response.json([{ slug: "ordinary" }, { slug: "special" }]); } + if ( + url.pathname === "/__vinext/prerender/static-params" && + url.searchParams.get("pattern") === "/unlisted/:slug" + ) { + return Response.json([{ slug: "known" }]); + } if ( url.pathname === "/__vinext/prerender/static-params" && url.searchParams.get("pattern") === "/:path+" @@ -128,22 +134,22 @@ describe("prerender path manifest", () => { responseVary: "verbatim", routePatterns: { "/": { - cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: true }, + cacheabilityProbe: { canPrunePattern: true }, kind: "app-page", pattern: "/", }, "/cached/featured": { - cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: true }, + cacheabilityProbe: { canPrunePattern: true }, kind: "app-page", pattern: "/cached/:slug", }, "/cached/intro": { - cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: true }, + cacheabilityProbe: { canPrunePattern: true }, kind: "app-page", pattern: "/cached/:slug", }, "/dynamic": { - cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: true }, + cacheabilityProbe: { canPrunePattern: true }, kind: "app-page", pattern: "/dynamic", }, @@ -193,6 +199,14 @@ describe("prerender path manifest", () => { "export const dynamic = 'force-dynamic'; export default function Page() { return null; }\n", ); writeFile("app/cookie/page.tsx", "export default function Page() { return null; }\n"); + writeFile( + "app/unlisted/[slug]/page.tsx", + [ + "export const dynamic = 'force-dynamic';", + "export function generateStaticParams() { return [{ slug: 'known' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); writeFile("app/wildcard/path/page.tsx", "export default function Page() { return null; }\n"); writeFile( "next.config.mjs", @@ -202,6 +216,7 @@ describe("prerender path manifest", () => { " { source: '/policy/special', headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },", " { source: '/conditional', missing: [{ type: 'query', key: '_rsc' }], headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },", " { source: '/cookie', has: [{ type: 'query', key: '_rsc', value: '.*' }], headers: [{ key: 'Set-Cookie', value: 'rsc=1' }] },", + " { source: '/unlisted/public-only', headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },", " { source: '/wildcard/*', missing: [{ type: 'query', key: '_rsc', value: '.*' }], headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },", " ],", "};", @@ -218,19 +233,22 @@ describe("prerender path manifest", () => { expect(manifest?.routePatterns).toMatchObject({ "/conditional": { - cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: false }, + cacheabilityProbe: { canPrunePattern: false }, }, "/cookie": { - cacheabilityProbe: { canPrunePattern: true, canReuseHtmlForRsc: false }, + cacheabilityProbe: { canPrunePattern: true }, }, "/policy/ordinary": { - cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: true }, + cacheabilityProbe: { canPrunePattern: false }, }, "/policy/special": { - cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: true }, + cacheabilityProbe: { canPrunePattern: false }, + }, + "/unlisted/known": { + cacheabilityProbe: { canPrunePattern: false }, }, "/wildcard/path": { - cacheabilityProbe: { canPrunePattern: false, canReuseHtmlForRsc: false }, + cacheabilityProbe: { canPrunePattern: false }, }, }); }); @@ -888,6 +906,156 @@ describe("prerender path manifest", () => { expect(manifest?.pagesPaths).toEqual(["/pages-dir/foobar"]); }); + it("retains empty generateStaticParams patterns for on-demand admission", async () => { + // Ported from Next.js: test/e2e/app-dir/fallback-prefetch + // https://github.com/vercel/next.js/tree/canary/test/e2e/app-dir/fallback-prefetch + 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/posts/[slug]/page.tsx", + [ + "export function generateStaticParams() { return []; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + vi.mocked(fetch).mockResolvedValue(Response.json([])); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual([]); + expect(manifest?.fallbackRoutePatterns).toEqual([ + { kind: "app-page", pattern: "/posts/:slug" }, + ]); + }); + + it("retains force-static patterns without 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/posts/[slug]/page.tsx", + [ + 'export const dynamic = "force-static";', + "export default function Page() { return null; }", + ].join("\n"), + ); + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 204 })); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" }); + + expect(manifest?.fallbackRoutePatterns).toEqual([ + { kind: "app-page", pattern: "/posts/:slug" }, + ]); + }); + + it("does not certify an empty static params pattern beneath force-dynamic config", 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/posts/layout.tsx", 'export const dynamic = "force-dynamic";\n'); + writeFile( + "app/posts/[slug]/page.tsx", + [ + 'export const dynamic = "force-static";', + "export function generateStaticParams() { return []; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + vi.mocked(fetch).mockResolvedValue(Response.json([])); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" }); + + expect(manifest?.fallbackRoutePatterns).toBeUndefined(); + }); + + it("does not certify empty static params with a force-dynamic parallel default", 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/posts/[slug]/page.tsx", + [ + 'export const dynamic = "force-static";', + "export function generateStaticParams() { return []; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile( + "app/posts/@sidebar/default.tsx", + [ + 'export const dynamic = "force-dynamic";', + "export default function Sidebar() { return null; }", + ].join("\n"), + ); + vi.mocked(fetch).mockResolvedValue(Response.json([])); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" }); + + expect(manifest?.fallbackRoutePatterns).toBeUndefined(); + }); + + it("does not treat revalidate without generateStaticParams as a static fallback", async () => { + // Next.js only treats a dynamic route without generateStaticParams as static + // when its effective dynamic config is `error` or `force-static`. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/build/index.ts + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/posts/[slug]/page.tsx", + ["export const revalidate = 60;", "export default function Page() { return null; }"].join( + "\n", + ), + ); + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 204 })); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" }); + + expect(manifest?.fallbackRoutePatterns).toBeUndefined(); + }); + + it("does not inherit force-static past an explicit child auto without generateStaticParams", async () => { + // Next.js uses the nested-most dynamic config on the main segment chain. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/build/utils.ts#L1028 + 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/posts/layout.tsx", 'export const dynamic = "force-static";\n'); + writeFile( + "app/posts/[slug]/page.tsx", + ['export const dynamic = "auto";', "export default function Page() { return null; }"].join( + "\n", + ), + ); + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 204 })); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" }); + + expect(manifest?.fallbackRoutePatterns).toBeUndefined(); + }); + it("uses the runtime-best App route for App-only loading-shell discovery", async () => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); @@ -1205,6 +1373,13 @@ describe("prerender path manifest", () => { expect(manifest?.paths).toEqual(["/pages-only"]); expect(manifest?.pagesPaths).toEqual(["/pages-only"]); + expect(manifest?.routePatterns).toEqual({ + "/pages-only": { + cacheabilityProbe: { canPrunePattern: true }, + kind: "pages-page", + pattern: "/pages-only", + }, + }); }); it("excludes Pages API handlers from Pages-only concrete warm paths", async () => { @@ -1313,6 +1488,10 @@ describe("prerender path manifest", () => { "/docs/_next/data/build-a/en/posts/%7Euser.json", "/docs/_next/data/build-a/en/posts/a%2fb.json", ]); + expect( + manifest?.routePatterns?.["/docs/_next/data/build-a/en/posts/hello.json"]?.cacheabilityProbe + ?.concretePathname, + ).toBe("/docs/posts/hello"); expect(fetch).toHaveBeenCalledWith( "http://127.0.0.1:43210/__vinext/prerender/pages-static-paths?pattern=%2Fposts%2F%3Aslug&locales=%5B%22en%22%2C%22fr%22%5D&defaultLocale=en", expect.any(Object), @@ -1486,6 +1665,18 @@ describe("prerender path manifest", () => { expect(manifest?.pagesPaths).toEqual(["/gssp"]); expect(manifest?.pagesDataPaths).toEqual(["/_next/data/build-a/gssp.json"]); + expect(manifest?.routePatterns).toEqual({ + "/_next/data/build-a/gssp.json": { + cacheabilityProbe: { canPrunePattern: true, concretePathname: "/gssp" }, + kind: "pages-page", + pattern: "/gssp", + }, + "/gssp": { + cacheabilityProbe: { canPrunePattern: true }, + kind: "pages-page", + pattern: "/gssp", + }, + }); }); it("does not reload disk config when supplied resolved config", async () => { From 2da8056853a1cdf83e5ab0b7ebdcf4680a9ef4ad Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 14:20:53 +0100 Subject: [PATCH 08/24] fix(cloudflare): retain zero-path static fallbacks --- packages/vinext/src/build/prerender-paths.ts | 31 ++++++++- tests/cloudflare-cacheability-probe.test.ts | 20 +++++- tests/cloudflare-cdn-warm-deploy.test.ts | 18 ++++- tests/prerender-paths.test.ts | 72 ++++++++++++++++++++ 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 349f9b7d0..847ae9731 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -501,7 +501,11 @@ async function collectPagesPaths(options: { pageExtensions: readonly string[]; retryOptions?: PathDiscoveryRetryOptions; secretHeaders: Record; -}): Promise<{ dataPaths: string[]; paths: string[] }> { +}): Promise<{ + dataPaths: string[]; + fallbackRoutePatterns: PrerenderRoutePattern[]; + paths: string[]; +}> { const [pageRoutes, apiRoutes] = await Promise.all([ pagesRouter(options.pagesDir, options.pageExtensions), apiRouter(options.pagesDir, options.pageExtensions), @@ -511,6 +515,7 @@ async function collectPagesPaths(options: { const seen = new Set(); const dataPaths: string[] = []; const seenDataPaths = new Set(); + const fallbackRoutePatterns: PrerenderRoutePattern[] = []; for (const route of pageRoutes) { if (apiPatterns.has(route.pattern)) continue; @@ -560,6 +565,9 @@ async function collectPagesPaths(options: { } const pathsResult = validatePagesStaticPathsResult(JSON.parse(text), route.pattern); + if (pathsResult.fallback !== false) { + fallbackRoutePatterns.push({ kind: "pages-page", pattern: route.pattern }); + } for (const item of pathsResult.paths) { const validatedItem = validatePagesStaticPathsEntry(item, route.pattern); let itemToNormalize = validatedItem; @@ -601,7 +609,7 @@ async function collectPagesPaths(options: { } } - return { dataPaths, paths }; + return { dataPaths, fallbackRoutePatterns, paths }; } async function excludePagesApiWarmPaths(options: { @@ -806,6 +814,24 @@ async function collectAppPaths(options: { } if (!paramSets?.length) { + if (isRouteHandler) { + // App Route Handlers do not inherit page layouts or parallel slots. + // Match Next.js's route-module eligibility: an empty + // generateStaticParams result remains an on-demand static fallback, + // while a handler without generateStaticParams needs an explicit + // force-static/error contract. + const dynamicConfig = extractExportConstString( + fs.readFileSync(renderEntryPath, "utf8"), + "dynamic", + ); + const hasStaticFallback = + paramSets !== null || dynamicConfig === "force-static" || dynamicConfig === "error"; + if (hasStaticFallback) { + fallbackRoutePatterns.push({ kind: "app-route", pattern: route.pattern }); + } + continue; + } + const parallelSegments = route.parallelSlots.flatMap((slot) => [ slot.layoutPath, @@ -1269,6 +1295,7 @@ export async function emitPrerenderPathManifest( for (const pathname of pagesPathResult.dataPaths) { addPath(discoveredPagesDataPaths, seenPagesDataPaths, pathname); } + fallbackRoutePatterns.push(...pagesPathResult.fallbackRoutePatterns); } } finally { if (prodServer) { diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index f6623f758..d28050a67 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -953,23 +953,37 @@ describe("staged Worker cacheability probes", () => { ]); }); - it("embeds empty generateStaticParams fallbacks without a render probe", async () => { + it("embeds zero-path static fallback patterns without render probes", async () => { const root = createProbeRoot(); const result = await probeStagedWorkerCacheability({ buildId: "application-build", - fallbackRoutePatterns: [{ kind: "app-page", pattern: "/posts/:slug" }], + fallbackRoutePatterns: [ + { kind: "app-page", pattern: "/posts/:slug" }, + { kind: "app-route", pattern: "/api/posts/:slug" }, + { kind: "pages-page", pattern: "/legacy/:slug" }, + ], root, targetUrl: "https://example.com", targets: [], }); - expect(result).toMatchObject({ classified: 1, probed: 0 }); + expect(result).toMatchObject({ classified: 3, probed: 0 }); expect(Object.values(result.manifest.routes)).toEqual([ { kind: "app-page", pattern: "/posts/:slug", state: "static-candidate", }, + { + kind: "app-route", + pattern: "/api/posts/:slug", + state: "static-candidate", + }, + { + kind: "pages-page", + pattern: "/legacy/:slug", + state: "static-candidate", + }, ]); }); diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index afbb865ed..78dea3b79 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -686,7 +686,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(JSON.parse(manifestJson)).toEqual({ buildId: "app-build-a", routes: {}, version: 1 }); }); - it("promotes compact pattern-only admission for an empty static params route", async () => { + it("promotes compact pattern-only admission for zero-path static fallbacks", async () => { writeTwoStageWorkerArtifact(); const wrangler = mockTwoStageWrangler(); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -699,7 +699,11 @@ describe("Cloudflare CDN warmup deploy flow", () => { appPaths: [], buildId: "app-build-a", buildIdentity: "app-build-a", - fallbackRoutePatterns: [{ kind: "app-page", pattern: "/posts/:slug" }], + fallbackRoutePatterns: [ + { kind: "app-page", pattern: "/posts/:slug" }, + { kind: "app-route", pattern: "/api/posts/:slug" }, + { kind: "pages-page", pattern: "/legacy/:slug" }, + ], loadingShellPaths: [], paths: [], rscPaths: [], @@ -721,6 +725,16 @@ describe("Cloudflare CDN warmup deploy flow", () => { pattern: "/posts/:slug", state: "static-candidate", }, + '["app-route","/api/posts/:slug"]': { + kind: "app-route", + pattern: "/api/posts/:slug", + state: "static-candidate", + }, + '["pages-page","/legacy/:slug"]': { + kind: "pages-page", + pattern: "/legacy/:slug", + state: "static-candidate", + }, }, version: 1, }); diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 6dd33d92b..b25c54f93 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -935,6 +935,78 @@ describe("prerender path manifest", () => { ]); }); + it.each([ + [ + "empty generateStaticParams", + [ + "export function generateStaticParams() { return []; }", + "export function GET() { return Response.json({ ok: true }); }", + ].join("\n"), + Response.json([]), + ], + [ + "force-static without generateStaticParams", + [ + 'export const dynamic = "force-static";', + "export function GET() { return Response.json({ ok: true }); }", + ].join("\n"), + new Response(null, { status: 204 }), + ], + ])("retains dynamic App Route Handler patterns with %s", async (_name, source, response) => { + // Ported from Next.js static App Route eligibility: + // packages/next/src/server/route-modules/app-route/helpers/is-static-gen-enabled.ts + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile("app/api/layout.tsx", 'export const dynamic = "force-dynamic";\n'); + writeFile("app/api/posts/[slug]/route.ts", source); + vi.mocked(fetch).mockResolvedValue(response); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" }); + + expect(manifest?.routeHandlerPaths).toBeUndefined(); + expect(manifest?.fallbackRoutePatterns).toEqual([ + { kind: "app-route", pattern: "/api/posts/:slug" }, + ]); + }); + + it.each([ + [true, true], + ["blocking", true], + [false, false], + ] as const)( + "retains zero-path Pages fallback=%s eligibility as a pattern record", + async (fallback, shouldRetain) => { + // Ported from Next.js: + // test/e2e/prerender/pages/non-json/[p].js + // test/e2e/prerender/pages/non-json-blocking/[p].js + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/entry.js", "export default {};\n"); + writeFile( + "pages/posts/[slug].tsx", + [ + "export function getStaticPaths() { return { paths: [], fallback: false }; }", + "export function getStaticProps() { return { props: {}, revalidate: 60 }; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + vi.mocked(fetch).mockResolvedValue(Response.json({ fallback, paths: [] })); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ root: tmpDir }); + + expect(manifest?.paths).toEqual([]); + expect(manifest?.fallbackRoutePatterns).toEqual( + shouldRetain ? [{ kind: "pages-page", pattern: "/posts/:slug" }] : undefined, + ); + }, + ); + it("retains force-static patterns without generateStaticParams", async () => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); From 034a97dc191e81b4055853a0b98a9fe44abaf41e Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 14:24:46 +0100 Subject: [PATCH 09/24] test(cloudflare): cover on-demand static fallbacks --- packages/cloudflare/src/cacheability-probe.ts | 12 +++++- packages/cloudflare/src/cdn-warm.ts | 4 +- tests/cloudflare-cacheability-probe.test.ts | 43 +++++++++++++++++++ tests/cloudflare-cdn-warm.test.ts | 12 +++++- .../cacheability-admission.spec.ts | 10 +++++ .../pages-cacheability.spec.ts | 4 +- .../[slug]/route.ts | 7 +++ .../cacheability-manifest.json | 5 +++ .../pages/cacheability-pages/posts/[slug].tsx | 2 +- 9 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-static-empty/[slug]/route.ts diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index f9a0ca906..b2969290a 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -604,9 +604,16 @@ export async function probeStagedWorkerCacheability(options: { let classified = 0; let dynamic = 0; let skipped = 0; + const fallbackRoutes = new Map(); for (const fallbackRoute of options.fallbackRoutePatterns ?? []) { const key = cacheabilityManifestRouteKey(fallbackRoute.kind, fallbackRoute.pattern); - if (routes[key]) continue; + fallbackRoutes.set(key, fallbackRoute); + } + for (const [key, fallbackRoute] of fallbackRoutes) { + // A pattern with concrete targets is classified below. Its fallback fact + // is merged into that one route record so exact runtime results still win + // without counting or serializing the pattern twice. + if (patterns.has(key)) continue; if ( !addRouteWithinManifestLimits(key, { kind: fallbackRoute.kind, @@ -683,6 +690,7 @@ export async function probeStagedWorkerCacheability(options: { const allObservedPathsStaticallyGenerated = allObservedPathsStatic && Array.from(pattern.results.values()).every((result) => result.rendererStatic); + const hasStaticFallback = fallbackRoutes.has(pattern.key); const soleGroup = pattern.groups.length === 1 ? pattern.groups[0] : null; const literalPatternNamesSolePath = soleGroup !== null && @@ -721,7 +729,7 @@ export async function probeStagedWorkerCacheability(options: { kind: pattern.route.kind, pattern: pattern.route.pattern, state: "runtime-check", - ...(allObservedPathsStaticallyGenerated + ...(hasStaticFallback || allObservedPathsStaticallyGenerated ? { allowUnknown: true, unknownState: "static-candidate" as const } : {}), ...(runtimePathSet.size > 0 ? { runtimePaths: Array.from(runtimePathSet).sort() } : {}), diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 9235221b8..6756ae1b0 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -164,7 +164,9 @@ function readPrerenderPathManifest(manifestPath: string): PrerenderPathManifest route !== null && typeof route === "object" && !Array.isArray(route) && - route.kind === "app-page" && + (route.kind === "app-page" || + route.kind === "app-route" || + route.kind === "pages-page") && typeof route.pattern === "string" && route.pattern.startsWith("/"), ))) || diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index d28050a67..9964b2015 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -6,6 +6,7 @@ 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 { + cacheabilityManifestRouteState, cacheabilityManifestRouteKey, type CacheabilityManifestRoute, } from "../packages/vinext/src/server/cacheability-manifest.js"; @@ -987,6 +988,48 @@ describe("staged Worker cacheability probes", () => { ]); }); + it("merges Pages fallback eligibility with exact private results", async () => { + const root = createProbeRoot(); + const route = { + cacheabilityProbe: { canPrunePattern: true }, + kind: "pages-page" as const, + pattern: "/legacy/:slug", + }; + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fallbackRoutePatterns: [{ kind: "pages-page", pattern: route.pattern }], + fetchImpl: async () => + Response.json({ + kind: "pages-page", + pattern: route.pattern, + state: "dynamic", + status: 200, + version: 1, + }), + root, + targetUrl: "https://example.com", + targets: [{ ...target("/legacy/known"), route }], + }); + + expect(result).toMatchObject({ classified: 1, dynamic: 1, probed: 1 }); + const manifestRoute = + result.manifest.routes[cacheabilityManifestRouteKey("pages-page", route.pattern)]; + expect(manifestRoute).toEqual({ + allowUnknown: true, + kind: "pages-page", + pattern: route.pattern, + runtimePaths: ["/legacy/known"], + state: "runtime-check", + unknownState: "static-candidate", + }); + expect(cacheabilityManifestRouteState(manifestRoute, "/legacy/known", "html")).toBe( + "runtime-check", + ); + expect(cacheabilityManifestRouteState(manifestRoute, "/legacy/unlisted", "html")).toBe( + "static-candidate", + ); + }); + it("keeps response-policy-only cacheability as an exact runtime check", async () => { const root = createProbeRoot(); const route = optimizableRoute("/posts/:slug"); diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index d87f3be46..39c3b28f5 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -100,7 +100,11 @@ describe("Cloudflare CDN warmup", () => { buildId: "build-a", buildIdentity: "rsc-build-a", deploymentId: "dpl_123", - fallbackRoutePatterns: [{ kind: "app-page", pattern: "/posts/:slug" }], + fallbackRoutePatterns: [ + { kind: "app-page", pattern: "/posts/:slug" }, + { kind: "app-route", pattern: "/api/posts/:slug" }, + { kind: "pages-page", pattern: "/legacy/:slug" }, + ], loadingShellPaths: ["/dashboard"], pagesDataPaths: ["/docs/_next/data/build-a/pages.json"], paths: ["/dashboard", "/dynamic", "/pages"], @@ -120,7 +124,11 @@ describe("Cloudflare CDN warmup", () => { buildId: "build-a", buildIdentity: "rsc-build-a", deploymentId: "dpl_123", - fallbackRoutePatterns: [{ kind: "app-page", pattern: "/posts/:slug" }], + fallbackRoutePatterns: [ + { kind: "app-page", pattern: "/posts/:slug" }, + { kind: "app-route", pattern: "/api/posts/:slug" }, + { kind: "pages-page", pattern: "/legacy/:slug" }, + ], loadingShellPaths: ["/docs/dashboard/"], pagesDataPaths: ["/docs/_next/data/build-a/pages.json"], paths: ["/docs/dashboard/", "/docs/dynamic/", "/docs/pages/"], diff --git a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts index f45f738a0..a24726849 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts @@ -187,6 +187,16 @@ test("admits pattern-backed App responses only after each clean EOF", async ({ r await expect(certifiedRouteHandler.json()).resolves.toEqual({ kind: "static-route-handler" }); expect(certifiedRouteHandler.headers()["cdn-cache-control"]).toContain("public"); + const emptyStaticRouteHandler = await request.get( + "/cacheability/route-handler-static-empty/on-demand", + ); + expect(emptyStaticRouteHandler.status()).toBe(200); + await expect(emptyStaticRouteHandler.json()).resolves.toEqual({ + kind: "static-empty", + slug: "on-demand", + }); + expect(emptyStaticRouteHandler.headers()["cdn-cache-control"]).toContain("public"); + const unlistedRouteHandlerQuery = await request.get( "/cacheability/route-handler-static?user=one", ); diff --git a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts index 1d01ee06e..8e27d8f0c 100644 --- a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts +++ b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts @@ -126,6 +126,7 @@ test("admits pattern-backed Pages Router responses after each completed render", "/cacheability-pages/isr", "/cacheability-pages/isr?unlisted=1", "/cacheability-pages/posts/known", + "/cacheability-pages/posts/unknown", ]) { const response = await request.get(pathname, { headers: { Accept: "text/html" } }); expect(response.status(), pathname).toBe(200); @@ -136,6 +137,7 @@ test("admits pattern-backed Pages Router responses after each completed render", `/_next/data/${buildId}/cacheability-pages/isr.json`, `/_next/data/${buildId}/cacheability-pages/isr.json?unlisted=1`, `/_next/data/${buildId}/cacheability-pages/posts/known.json`, + `/_next/data/${buildId}/cacheability-pages/posts/unknown.json`, ]) { const response = await request.get(pathname, { headers: { Accept: "application/json" } }); expect(response.status(), pathname).toBe(200); @@ -157,9 +159,7 @@ test("admits pattern-backed Pages Router responses after each completed render", for (const pathname of [ "/cacheability-pages/gssp", "/cacheability-pages/get-initial-props", - "/cacheability-pages/posts/unknown", `/_next/data/${buildId}/cacheability-pages/gssp.json`, - `/_next/data/${buildId}/cacheability-pages/posts/unknown.json`, ]) { const response = await request.get(pathname, { headers: { Accept: pathname.includes("/_next/data/") ? "application/json" : "text/html" }, diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-static-empty/[slug]/route.ts b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-static-empty/[slug]/route.ts new file mode 100644 index 000000000..bfa3f2d2c --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-static-empty/[slug]/route.ts @@ -0,0 +1,7 @@ +export function generateStaticParams() { + return []; +} + +export function GET(_request: Request, context: { params: Promise<{ slug: string }> }) { + return context.params.then(({ slug }) => Response.json({ kind: "static-empty", slug })); +} diff --git a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json index 5722fa7d6..f5e114237 100644 --- a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json +++ b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json @@ -70,6 +70,11 @@ "pattern": "/cacheability/route-handler-static", "state": "static-candidate" }, + "[\"app-route\",\"/cacheability/route-handler-static-empty/:slug\"]": { + "kind": "app-route", + "pattern": "/cacheability/route-handler-static-empty/:slug", + "state": "static-candidate" + }, "[\"pages-page\",\"/cacheability-pages/config-header\"]": { "kind": "pages-page", "pattern": "/cacheability-pages/config-header", diff --git a/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/posts/[slug].tsx b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/posts/[slug].tsx index cd69e3b79..add4a6163 100644 --- a/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/posts/[slug].tsx +++ b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/posts/[slug].tsx @@ -1,5 +1,5 @@ export async function getStaticPaths() { - return { fallback: false, paths: [{ params: { slug: "known" } }] }; + return { fallback: "blocking", paths: [] }; } export async function getStaticProps({ params }: { params: { slug: string } }) { From 4de0594b13e984f1da2c07d6112feb6e464faae5 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 14:37:27 +0100 Subject: [PATCH 10/24] fix(cloudflare): admit custom vary cache variants --- .../src/cache/cdn-adapter.runtime.ts | 1 + packages/cloudflare/src/cdn-warm.ts | 11 --- .../vinext/src/server/app-router-entry.ts | 5 +- .../vinext/src/server/cacheability-request.ts | 21 +++-- .../vinext/src/server/pages-router-entry.ts | 5 +- .../src/shims/cacheability-classification.ts | 2 + packages/vinext/src/shims/cdn-cache.ts | 6 ++ tests/cacheability-admission.test.ts | 85 ++++++++++++++++--- tests/cloudflare-cdn-warm.test.ts | 12 +-- .../cacheability-admission.spec.ts | 1 + .../route-handler-static/route.ts | 2 +- 11 files changed, 112 insertions(+), 39 deletions(-) diff --git a/packages/cloudflare/src/cache/cdn-adapter.runtime.ts b/packages/cloudflare/src/cache/cdn-adapter.runtime.ts index 2f7fff903..a1eb01b3e 100644 --- a/packages/cloudflare/src/cache/cdn-adapter.runtime.ts +++ b/packages/cloudflare/src/cache/cdn-adapter.runtime.ts @@ -181,6 +181,7 @@ function formatCacheTag(tags: readonly string[]): string | null { export class CloudflareCdnCacheAdapter implements CdnCacheAdapter { readonly requiresCompletedResponseAdmission = true; + readonly responseVary = "verbatim" as const; constructor( private readonly versionMetadata?: WorkerVersionMetadata, diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 6756ae1b0..a6a7d27ef 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -756,10 +756,6 @@ function validateRscWarmResponse( if (missingVary) { return { outcome: "failed", error: `response Vary is missing ${missingVary}` }; } - const extraVary = Array.from(vary).find((name) => !REQUIRED_RSC_VARY_HEADERS.includes(name)); - if (extraVary) { - return { outcome: "failed", error: `response Vary has unsupported field ${extraVary}` }; - } return { outcome: "warmed" }; } @@ -781,13 +777,6 @@ function validateHtmlWarmResponse( } const cachePolicyValidation = validateCachePolicy(response, true, requireCacheHit); if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation; - const extraVary = (response.headers.get("Vary") ?? "") - .split(",") - .map((name) => name.trim().toLowerCase()) - .find((name) => name && !REQUIRED_RSC_VARY_HEADERS.includes(name)); - if (extraVary) { - return { outcome: "failed", error: `response Vary has unsupported field ${extraVary}` }; - } return { outcome: "warmed" }; } diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts index 0acfa3c91..6f3be41a8 100644 --- a/packages/vinext/src/server/app-router-entry.ts +++ b/packages/vinext/src/server/app-router-entry.ts @@ -123,6 +123,7 @@ async function handleRequest( // Registration must precede admission setup: the active adapter declares // whether a completed response is required before public cache headers. registerConfiguredCacheAdapters(env as Record | undefined); + const cdnCacheAdapter = getCdnCacheAdapter(); let ctx = createWorkerPrerenderDiscoveryContext(requestCtx, request, __rscPrerenderSecret); let finalizeCacheabilityResponse: | ((response: Response, ctx: ExecutionContextLike) => Promise) @@ -135,6 +136,7 @@ async function handleRequest( ctx, request, __rscPrerenderSecret, + cdnCacheAdapter.responseVary, ); if (probeContext !== ctx) { ctx = probeContext; @@ -151,7 +153,7 @@ async function handleRequest( } } const requiresCompletedResponseAdmission = - getCdnCacheAdapter().requiresCompletedResponseAdmission === true; + cdnCacheAdapter.requiresCompletedResponseAdmission === true; if ( !finalizeCacheabilityResponse && (__rscCacheabilityManifest || requiresCompletedResponseAdmission) && @@ -164,6 +166,7 @@ async function handleRequest( __rscCacheabilityManifest, process.env.__VINEXT_BUILD_ID, requiresCompletedResponseAdmission, + cdnCacheAdapter.responseVary, ); if (admissionContext !== ctx) { ctx = admissionContext; diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index ee08d76b9..a52be426d 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -53,14 +53,15 @@ type CacheabilityProbeResult = { version: 1; }; -const SUPPORTED_CACHEABILITY_VARY_FIELDS = new Set( +const FRAMEWORK_CACHEABILITY_VARY_FIELDS = new Set( VINEXT_RSC_VARY_HEADER.split(",").map((name) => name.trim().toLowerCase()), ); -function hasUnsupportedCacheabilityVary(headers: Headers): boolean { +function hasUnsupportedCacheabilityVary(headers: Headers, state: RouteCacheabilityState): boolean { + if (state.responseVary === "verbatim") return false; return (headers.get("Vary") ?? "").split(",").some((name) => { const normalized = name.trim().toLowerCase(); - return normalized.length > 0 && !SUPPORTED_CACHEABILITY_VARY_FIELDS.has(normalized); + return normalized.length > 0 && !FRAMEWORK_CACHEABILITY_VARY_FIELDS.has(normalized); }); } @@ -68,6 +69,7 @@ export function createWorkerCacheabilityContext( base: ExecutionContextLike, request: Request, expectedSecret: string | null | undefined, + responseVary?: "verbatim", ): ExecutionContextLike { const requestedMode = request.headers.get(VINEXT_CACHEABILITY_PROBE_HEADER); if (requestedMode !== "1" && requestedMode !== "identity") return base; @@ -84,6 +86,7 @@ export function createWorkerCacheabilityContext( const state: RouteCacheabilityState = { captureDeadlineAt: Date.now() + CACHEABILITY_PROBE_TIMEOUT_MS, mode: requestedMode === "identity" ? "identity" : "probe", + responseVary, }; return Object.assign(Object.create(Object.getPrototypeOf(base)), base, { [CACHEABILITY_REQUEST_STATE]: state, @@ -109,6 +112,7 @@ export function createWorkerCacheabilityAdmissionContext( rawManifest: string | null | undefined, buildId: string | null | undefined, requiresCompletedResponseAdmission = rawManifest != null, + responseVary?: "verbatim", ): ExecutionContextLike { const identity = cacheabilityRequestIdentity(request); if (!rawManifest) { @@ -126,6 +130,7 @@ export function createWorkerCacheabilityAdmissionContext( : { policy: "deny" }, captureDeadlineAt: Date.now() + CACHEABILITY_PROBE_TIMEOUT_MS, mode: "admit", + responseVary, }; return Object.assign(Object.create(Object.getPrototypeOf(base)), base, { [CACHEABILITY_REQUEST_STATE]: state, @@ -149,6 +154,7 @@ export function createWorkerCacheabilityAdmissionContext( : { policy: "deny" }, captureDeadlineAt: Date.now() + CACHEABILITY_PROBE_TIMEOUT_MS, mode: "admit", + responseVary, }; return Object.assign(Object.create(Object.getPrototypeOf(base)), base, { [CACHEABILITY_REQUEST_STATE]: state, @@ -494,8 +500,8 @@ function completedRouteOutcome( if (response.headers.has("set-cookie")) { return { cacheable: false, reason: "response sets a cookie" }; } - if (hasUnsupportedCacheabilityVary(response.headers)) { - return { cacheable: false, reason: "response has unsupported Vary fields" }; + if (hasUnsupportedCacheabilityVary(response.headers, state)) { + return { cacheable: false, reason: "response cache does not support custom Vary fields" }; } return inferPagesPageCacheability(response); } @@ -610,7 +616,7 @@ async function finalizeWorkerCacheabilityAdmission( response.status >= 500 || state.forcedDynamicReason || hasStrictFinalResponseVeto(response, state) || - hasUnsupportedCacheabilityVary(response.headers) + hasUnsupportedCacheabilityVary(response.headers, state) ) { return responseWithCachePolicy(response, response.body, null); } @@ -682,10 +688,9 @@ async function finalizeWorkerCacheabilityAdmission( if (hasStrictFinalResponseVeto(response, state)) { return responseWithCachePolicy(response, response.body, null); } - if (hasUnsupportedCacheabilityVary(response.headers)) { + if (hasUnsupportedCacheabilityVary(response.headers, state)) { return responseWithCachePolicy(response, response.body, null); } - let captured: CapturedAdmissionBody; try { captured = await captureCacheabilityAdmissionBody( diff --git a/packages/vinext/src/server/pages-router-entry.ts b/packages/vinext/src/server/pages-router-entry.ts index f34e0aed3..1ff6119db 100644 --- a/packages/vinext/src/server/pages-router-entry.ts +++ b/packages/vinext/src/server/pages-router-entry.ts @@ -131,6 +131,7 @@ async function handleRequest( // whether public response headers require a completed-response proof even // when this build has no embedded two-stage manifest. registerConfiguredCacheAdapters(env); + const cdnCacheAdapter = getCdnCacheAdapter(); let ctx = createWorkerPrerenderDiscoveryContext(requestCtx, request, pagesEntry.prerenderSecret); let finalizeCacheabilityResponse: | ((response: Response, ctx: ExecutionContextLike) => Promise) @@ -141,6 +142,7 @@ async function handleRequest( ctx, request, pagesEntry.prerenderSecret, + cdnCacheAdapter.responseVary, ); if (probeContext !== ctx) { ctx = probeContext; @@ -153,7 +155,7 @@ async function handleRequest( } } const requiresCompletedResponseAdmission = - getCdnCacheAdapter().requiresCompletedResponseAdmission === true; + cdnCacheAdapter.requiresCompletedResponseAdmission === true; if ( !finalizeCacheabilityResponse && (__cacheabilityManifest || requiresCompletedResponseAdmission) @@ -165,6 +167,7 @@ async function handleRequest( __cacheabilityManifest, pagesEntry.buildId, requiresCompletedResponseAdmission, + cdnCacheAdapter.responseVary, ); if (admissionContext !== ctx) { ctx = admissionContext; diff --git a/packages/vinext/src/shims/cacheability-classification.ts b/packages/vinext/src/shims/cacheability-classification.ts index be2764e56..ce4e4195d 100644 --- a/packages/vinext/src/shims/cacheability-classification.ts +++ b/packages/vinext/src/shims/cacheability-classification.ts @@ -43,6 +43,8 @@ export type RouteCacheabilityState = { mode: "admit" | "identity" | "probe"; outcome?: RouteCacheabilityOutcome; preserveResponseCachePolicy?: boolean; + /** Cache-key behavior declared by the active CDN adapter. */ + responseVary?: "verbatim"; probeBailout?: { kind: "private-cache"; outcome: RouteCacheabilityOutcome; diff --git a/packages/vinext/src/shims/cdn-cache.ts b/packages/vinext/src/shims/cdn-cache.ts index 1b25142bc..a06c00ec6 100644 --- a/packages/vinext/src/shims/cdn-cache.ts +++ b/packages/vinext/src/shims/cdn-cache.ts @@ -100,6 +100,12 @@ export function isNonCacheableCacheControl(cacheControl: string): boolean { // `buildResponseHeaders` / `ownsBackgroundRevalidation` have no data-cache // equivalent and stay CDN-specific. export type CdnCacheAdapter = { + /** + * The shared cache selects response variants using every request header + * named by `Vary`, comparing values verbatim. + */ + readonly responseVary?: "verbatim"; + /** * Fresh App Page responses must reach clean EOF before this adapter may emit * shared-cache headers. Used by edge adapters whose cache sits in front of diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index 573fd864e..eb2906cad 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -418,23 +418,31 @@ describe("single-request cacheability admission", () => { }); it.each(["*/*", "text/html"])( - "admits a manifest-backed Route Handler pattern for Accept: %s", + "admits a manifest-backed Route Handler pattern with custom Vary for Accept: %s", async (accept) => { + // Ported from Next.js: + // test/e2e/vary-header/test/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/vary-header/test/index.test.ts const { raw } = staticAppRouteManifest(); const context = createWorkerCacheabilityAdmissionContext( { waitUntil() {} }, new Request("https://example.com/api/data", { headers: { Accept: accept } }), raw, "build-a", + true, + "verbatim", ); cacheabilityState(context).route = { kind: "app-route", pattern: "/api/data" }; const response = await finalizeWorkerCacheabilityResponse( - new Response("public", { headers: { "Cache-Control": "public, s-maxage=60" } }), + new Response("public", { + headers: { "Cache-Control": "public, s-maxage=60", Vary: "User-Agent" }, + }), context, ); expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=60"); + expect(response.headers.get("Vary")).toBe("User-Agent"); }, ); @@ -502,13 +510,20 @@ describe("single-request cacheability admission", () => { await expect(response.text()).resolves.toBe("pages"); }); - it("keeps responses with unsupported Vary fields private", async () => { + it("admits custom Vary fields when the CDN keys them verbatim", async () => { + // Next.js preserves application Vary fields alongside its RSC selectors. + // The Cloudflare adapter's responseVary capability guarantees that the + // Workers Cache uses each named request header in the cache key. + // Ported from Next.js: + // test/e2e/vary-header/test/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/vary-header/test/index.test.ts const context = createWorkerCacheabilityAdmissionContext( { waitUntil() {} }, request, null, "build-a", true, + "verbatim", ); const state = cacheabilityState(context); state.route = { kind: "app-page", pattern: "/page" }; @@ -522,10 +537,35 @@ describe("single-request cacheability admission", () => { context, ); - expect(response.headers.get("Cache-Control")).toContain("no-store"); + expect(response.headers.get("Cache-Control")).toContain("s-maxage=60"); + expect(response.headers.get("Vary")).toBe("RSC, Cookie"); await expect(response.text()).resolves.toBe("contextual"); }); + it("keeps custom Vary fields private when the CDN cannot key them", async () => { + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + null, + "build-a", + true, + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: "/page" }; + state.outcome = { + cacheable: true, + cacheControl: "s-maxage=60, stale-while-revalidate=540", + }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("contextual", { headers: { Vary: "RSC, Cookie" } }), + context, + ); + + expect(response.headers.get("Cache-Control")).toContain("no-store"); + expect(response.headers.get("Vary")).toBe("RSC, Cookie"); + }); + it("serves an intentionally private certified response without treating it as static-to-dynamic", async () => { // Next.js bypasses the Full Route Cache in draft mode. The HTML renderer // intentionally returns no-store without opening a cache-write completion, @@ -1019,11 +1059,14 @@ describe("cacheability probe finalization", () => { const state: RouteCacheabilityState = { captureDeadlineAt: Date.now() + 1_000, mode: "probe", + responseVary: "verbatim", route: { kind: "app-route", pattern: "/api/data" }, }; const response = await finalizeWorkerCacheabilityResponse( - new Response("static", { headers: { "Cache-Control": "public, s-maxage=60" } }), + new Response("static", { + headers: { "Cache-Control": "public, s-maxage=60", Vary: "User-Agent" }, + }), contextWith(state), ); @@ -1039,10 +1082,30 @@ describe("cacheability probe finalization", () => { } }); - it.each([ - ["Set-Cookie", "session=private; Path=/", "response sets a cookie"], - ["Vary", "User-Agent", "response has unsupported Vary fields"], - ])("keeps Route Handler probes with unsafe %s private", async (name, value, reason) => { + it("keeps Route Handler probes that set cookies private", async () => { + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + mode: "probe", + route: { kind: "app-route", pattern: "/api/data" }, + }; + const response = await finalizeWorkerCacheabilityResponse( + new Response("unsafe", { + headers: { + "Cache-Control": "public, s-maxage=60", + "Set-Cookie": "session=private; Path=/", + }, + }), + contextWith(state), + ); + + await expect(response.json()).resolves.toMatchObject({ + kind: "app-route", + reason: "response sets a cookie", + state: "dynamic", + }); + }); + + it("keeps custom Route Handler Vary private for caches without header variants", async () => { const state: RouteCacheabilityState = { captureDeadlineAt: Date.now() + 1_000, mode: "probe", @@ -1050,14 +1113,14 @@ describe("cacheability probe finalization", () => { }; const response = await finalizeWorkerCacheabilityResponse( new Response("unsafe", { - headers: { "Cache-Control": "public, s-maxage=60", [name]: value }, + headers: { "Cache-Control": "public, s-maxage=60", Vary: "User-Agent" }, }), contextWith(state), ); await expect(response.json()).resolves.toMatchObject({ kind: "app-route", - reason, + reason: "response cache does not support custom Vary fields", state: "dynamic", }); }); diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index 39c3b28f5..e7fc0985b 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -745,7 +745,7 @@ describe("Cloudflare CDN warmup", () => { ).resolves.toMatchObject({ warmed: 2, skipped: 0, failed: 0 }); }); - it("requires browser-reusable variance for cacheable terminal responses", async () => { + it("accepts custom Vary fields on cacheable terminal responses", async () => { const fetchImpl = vi.fn(async () => { const response = cacheableRsc("flight not found"); response.headers.set("vary", `${VINEXT_RSC_VARY_HEADER}, User-Agent`); @@ -762,7 +762,7 @@ describe("Cloudflare CDN warmup", () => { strict: true, targetUrl: "https://app.example.com", }), - ).rejects.toThrow("response Vary has unsupported field user-agent"); + ).resolves.toMatchObject({ warmed: 1, skipped: 0, failed: 0 }); }); it("does not treat same-build server errors as terminal route responses", async () => { @@ -936,7 +936,7 @@ describe("Cloudflare CDN warmup", () => { ).rejects.toThrow("response is missing CF-Cache-Status"); }); - it("rejects HTML variants that the warmer request cannot share with browsers", async () => { + it("accepts HTML responses with custom Vary fields", async () => { const fetchImpl = vi.fn(async () => { const response = cacheableHtml(); response.headers.set("vary", `${VINEXT_RSC_VARY_HEADER}, User-Agent`); @@ -951,7 +951,7 @@ describe("Cloudflare CDN warmup", () => { strict: true, targetUrl: "https://app.example.com", }), - ).rejects.toThrow("response Vary has unsupported field user-agent"); + ).resolves.toMatchObject({ warmed: 1, skipped: 0, failed: 0 }); }); it("accepts HTML varied only by framework RSC selector headers", async () => { @@ -1042,7 +1042,7 @@ describe("Cloudflare CDN warmup", () => { it("does not retry permanent validation failures from the uploaded build", async () => { const fetchImpl = vi.fn(async () => { const response = cacheableRsc(); - response.headers.set("vary", `${VINEXT_RSC_VARY_HEADER}, User-Agent`); + response.headers.set("vary", "User-Agent"); return response; }); @@ -1059,7 +1059,7 @@ describe("Cloudflare CDN warmup", () => { strict: true, targetUrl: "https://app.example.com", }), - ).rejects.toThrow("response Vary has unsupported field user-agent"); + ).rejects.toThrow("response Vary is missing rsc"); expect(fetchImpl).toHaveBeenCalledTimes(1); }); diff --git a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts index a24726849..3ecff87d1 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts @@ -186,6 +186,7 @@ test("admits pattern-backed App responses only after each clean EOF", async ({ r expect(certifiedRouteHandler.status()).toBe(200); await expect(certifiedRouteHandler.json()).resolves.toEqual({ kind: "static-route-handler" }); expect(certifiedRouteHandler.headers()["cdn-cache-control"]).toContain("public"); + expect(certifiedRouteHandler.headers()["vary"]?.toLowerCase()).toContain("user-agent"); const emptyStaticRouteHandler = await request.get( "/cacheability/route-handler-static-empty/on-demand", diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-static/route.ts b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-static/route.ts index fedbd9fb0..2f626d1e9 100644 --- a/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-static/route.ts +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-static/route.ts @@ -1,5 +1,5 @@ export const revalidate = 60; export function GET() { - return Response.json({ kind: "static-route-handler" }); + return Response.json({ kind: "static-route-handler" }, { headers: { Vary: "User-Agent" } }); } From 0e0303707a3da597c26665a9a8f9a57eb359a6cc Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 14:41:57 +0100 Subject: [PATCH 11/24] fix(cloudflare): keep wildcard vary private --- .../vinext/src/server/cacheability-request.ts | 28 +++-- tests/cacheability-admission.test.ts | 103 ++++++++++++++++++ tests/cloudflare-cdn-warm-deploy.test.ts | 61 +++++++++++ 3 files changed, 181 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index a52be426d..760ad1fc5 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -57,12 +57,19 @@ const FRAMEWORK_CACHEABILITY_VARY_FIELDS = new Set( VINEXT_RSC_VARY_HEADER.split(",").map((name) => name.trim().toLowerCase()), ); -function hasUnsupportedCacheabilityVary(headers: Headers, state: RouteCacheabilityState): boolean { - if (state.responseVary === "verbatim") return false; - return (headers.get("Vary") ?? "").split(",").some((name) => { - const normalized = name.trim().toLowerCase(); - return normalized.length > 0 && !FRAMEWORK_CACHEABILITY_VARY_FIELDS.has(normalized); - }); +function cacheabilityVaryRejectionReason( + headers: Headers, + state: RouteCacheabilityState, +): string | null { + const fields = (headers.get("Vary") ?? "") + .split(",") + .map((name) => name.trim().toLowerCase()) + .filter(Boolean); + if (fields.includes("*")) return "response uses Vary: *"; + if (state.responseVary === "verbatim") return null; + return fields.some((name) => !FRAMEWORK_CACHEABILITY_VARY_FIELDS.has(name)) + ? "response cache does not support custom Vary fields" + : null; } export function createWorkerCacheabilityContext( @@ -500,9 +507,8 @@ function completedRouteOutcome( if (response.headers.has("set-cookie")) { return { cacheable: false, reason: "response sets a cookie" }; } - if (hasUnsupportedCacheabilityVary(response.headers, state)) { - return { cacheable: false, reason: "response cache does not support custom Vary fields" }; - } + const varyRejectionReason = cacheabilityVaryRejectionReason(response.headers, state); + if (varyRejectionReason) return { cacheable: false, reason: varyRejectionReason }; return inferPagesPageCacheability(response); } if (state.route?.kind === "app-page") { @@ -616,7 +622,7 @@ async function finalizeWorkerCacheabilityAdmission( response.status >= 500 || state.forcedDynamicReason || hasStrictFinalResponseVeto(response, state) || - hasUnsupportedCacheabilityVary(response.headers, state) + cacheabilityVaryRejectionReason(response.headers, state) !== null ) { return responseWithCachePolicy(response, response.body, null); } @@ -688,7 +694,7 @@ async function finalizeWorkerCacheabilityAdmission( if (hasStrictFinalResponseVeto(response, state)) { return responseWithCachePolicy(response, response.body, null); } - if (hasUnsupportedCacheabilityVary(response.headers, state)) { + if (cacheabilityVaryRejectionReason(response.headers, state) !== null) { return responseWithCachePolicy(response, response.body, null); } let captured: CapturedAdmissionBody; diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index eb2906cad..89cbb6408 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -566,6 +566,31 @@ describe("single-request cacheability admission", () => { expect(response.headers.get("Vary")).toBe("RSC, Cookie"); }); + it("keeps Vary wildcard responses private for verbatim caches", async () => { + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + null, + "build-a", + true, + "verbatim", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: "/page" }; + state.outcome = { + cacheable: true, + cacheControl: "s-maxage=60, stale-while-revalidate=540", + }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("wildcard", { headers: { Vary: "*" } }), + context, + ); + + expect(response.headers.get("Cache-Control")).toContain("no-store"); + expect(response.headers.get("Vary")).toBe("*"); + }); + it("serves an intentionally private certified response without treating it as static-to-dynamic", async () => { // Next.js bypasses the Full Route Cache in draft mode. The HTML renderer // intentionally returns no-store without opening a cache-write completion, @@ -933,6 +958,63 @@ describe("single-request cacheability admission", () => { await expect(response.text()).resolves.toBe("gssp"); }); + it("admits named Pages Vary fields for verbatim caches", async () => { + const pagesRequest = new Request("https://example.com/pages-route", { + headers: { Accept: "text/html" }, + }); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + pagesRequest, + null, + "build-a", + true, + "verbatim", + ); + const state = cacheabilityState(context); + state.route = { kind: "pages-page", pattern: "/pages-route" }; + state.outcome = { cacheable: false, dynamicUsage: true }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("localized", { + headers: { + "Cache-Control": "public, s-maxage=36", + Vary: "Accept-Language", + }, + }), + context, + ); + + expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=36"); + expect(response.headers.get("Vary")).toBe("Accept-Language"); + }); + + it("keeps Pages Vary wildcard responses private for verbatim caches", async () => { + const pagesRequest = new Request("https://example.com/pages-route", { + headers: { Accept: "text/html" }, + }); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + pagesRequest, + null, + "build-a", + true, + "verbatim", + ); + const state = cacheabilityState(context); + state.route = { kind: "pages-page", pattern: "/pages-route" }; + state.outcome = { cacheable: false, dynamicUsage: true }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("wildcard", { + headers: { "Cache-Control": "public, s-maxage=36", Vary: "*" }, + }), + context, + ); + + expect(response.headers.get("Cache-Control")).toContain("no-store"); + expect(response.headers.get("Vary")).toBe("*"); + }); + it("keeps manifest-backed Pages responses with a late Set-Cookie private", async () => { const { raw } = staticPagesManifestRoute(); const pagesRequest = new Request("https://example.com/pages-route", { @@ -1124,4 +1206,25 @@ describe("cacheability probe finalization", () => { state: "dynamic", }); }); + + it("classifies Vary wildcard Route Handlers as dynamic for verbatim caches", async () => { + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + mode: "probe", + responseVary: "verbatim", + route: { kind: "app-route", pattern: "/api/data" }, + }; + const response = await finalizeWorkerCacheabilityResponse( + new Response("wildcard", { + headers: { "Cache-Control": "public, s-maxage=60", Vary: "*" }, + }), + contextWith(state), + ); + + await expect(response.json()).resolves.toMatchObject({ + kind: "app-route", + reason: "response uses Vary: *", + state: "dynamic", + }); + }); }); diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 78dea3b79..58d2c07ba 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -789,6 +789,67 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(JSON.parse(manifestJson)).toEqual({ buildId: "app-build-a", routes: {}, version: 1 }); }); + it("excludes a Vary wildcard route from warming without blocking promotion", async () => { + writeTwoStageWorkerArtifact(); + const wrangler = mockTwoStageWrangler(); + vi.mocked(fetch).mockImplementation(async (input, init) => { + if (new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1") { + return Response.json( + { + kind: "app-page", + pattern: "/wildcard", + reason: "response uses Vary: *", + scope: "identity", + state: "dynamic", + status: 200, + version: 1, + }, + { headers: { [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a" } }, + ); + } + return isReadinessFetch(input) + ? cacheableHtml() + : new Response("unexpected warm request", { status: 500 }); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await expect( + deployWithCdnWarmup(tmpDir, [], { + cacheabilityProbe: true, + config: "dist/server/wrangler.json", + discoverWarmPlan: async () => ({ + appPaths: ["/wildcard"], + buildId: "app-build-a", + buildIdentity: "app-build-a", + loadingShellPaths: [], + paths: ["/wildcard"], + routePatterns: appPageRoutePatterns(["/wildcard"], "/wildcard"), + rscPaths: [], + }), + warmCdnReadinessProbes: 1, + warmCdnRetries: 0, + }), + ).resolves.toBe("https://my-worker.example.workers.dev"); + + expect(wrangler.uploads).toBe(2); + expect(wrangler.promoted).toBe(true); + expect(getRealWarmFetchCalls()).toHaveLength(1); + const manifestJson = JSON.parse( + wrangler.finalManifestSource!.slice("export default ".length, -2), + ) as string; + expect(JSON.parse(manifestJson)).toEqual({ + buildId: "app-build-a", + routes: { + '["app-page","/wildcard"]': { + kind: "app-page", + pattern: "/wildcard", + state: "runtime-check", + }, + }, + version: 1, + }); + }); + it("probes a Pages-only concrete path once before warming HTML and data", async () => { writeTwoStageWorkerArtifact(); const wrangler = mockTwoStageWrangler(); From 15c183559430bbf5fad7ba43bc83bc664b763f3e Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 14:43:42 +0100 Subject: [PATCH 12/24] fix(cloudflare): reject wildcard vary during probing --- .../vinext/src/server/cacheability-request.ts | 4 +- tests/cacheability-admission.test.ts | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index 760ad1fc5..518d330bc 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -503,12 +503,12 @@ function completedRouteOutcome( if (state.forcedDynamicReason) { return { cacheable: false, reason: state.forcedDynamicReason }; } + const varyRejectionReason = cacheabilityVaryRejectionReason(response.headers, state); + if (varyRejectionReason) return { cacheable: false, reason: varyRejectionReason }; if (state.route?.kind === "app-route") { if (response.headers.has("set-cookie")) { return { cacheable: false, reason: "response sets a cookie" }; } - const varyRejectionReason = cacheabilityVaryRejectionReason(response.headers, state); - if (varyRejectionReason) return { cacheable: false, reason: varyRejectionReason }; return inferPagesPageCacheability(response); } if (state.route?.kind === "app-page") { diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index 89cbb6408..0e7e2f471 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -1056,6 +1056,51 @@ describe("cacheability probe finalization", () => { }; } + it.each(["app-page", "pages-page"] as const)( + "classifies named Vary fields as static for verbatim %s probes", + async (kind) => { + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + mode: "probe", + outcome: { cacheable: true, cacheControl: "public, s-maxage=60" }, + responseVary: "verbatim", + route: { kind, pattern: "/page" }, + }; + const response = await finalizeWorkerCacheabilityResponse( + new Response("variant", { headers: { Vary: "Accept-Language" } }), + contextWith(state), + ); + + await expect(response.json()).resolves.toMatchObject({ + kind, + state: "static-candidate", + }); + }, + ); + + it.each(["app-page", "pages-page"] as const)( + "classifies Vary wildcard %s probes as dynamic", + async (kind) => { + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + mode: "probe", + outcome: { cacheable: true, cacheControl: "public, s-maxage=60" }, + responseVary: "verbatim", + route: { kind, pattern: "/page" }, + }; + const response = await finalizeWorkerCacheabilityResponse( + new Response("wildcard", { headers: { Vary: "*" } }), + contextWith(state), + ); + + await expect(response.json()).resolves.toMatchObject({ + kind, + reason: "response uses Vary: *", + state: "dynamic", + }); + }, + ); + it("reports whether the renderer itself produced the public cache policy", async () => { const staticState: RouteCacheabilityState = { captureDeadlineAt: Date.now() + 1_000, From 9c660ab499136b430a722c3d28ed0b5c7794cbeb Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 14:50:54 +0100 Subject: [PATCH 13/24] fix(cloudflare): reject empty Cache Components params --- packages/vinext/src/build/prerender-paths.ts | 9 +++++++ tests/prerender-paths.test.ts | 28 ++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 847ae9731..592dc90c4 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -690,6 +690,7 @@ function extractPagesStaticPathLocale( async function collectAppPaths(options: { appDir: string; baseUrl: string | null; + cacheComponents: boolean; pageExtensions: readonly string[]; retryOptions?: PathDiscoveryRetryOptions; secretHeaders: Record; @@ -730,6 +731,13 @@ async function collectAppPaths(options: { if (!Array.isArray(value)) { throw new Error(`generateStaticParams must return an array for ${pattern}.`); } + if (options.cacheComponents && value.length === 0) { + throw new Error( + "When using Cache Components, all `generateStaticParams` functions must return at least one result. " + + "This is to ensure that we can perform build-time validation that there is no other dynamic accesses that would cause a runtime error.\n\n" + + "Learn more: https://nextjs.org/docs/messages/empty-generate-static-params", + ); + } return value.map((entry) => { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { throw new Error(`generateStaticParams must return parameter objects for ${pattern}.`); @@ -1262,6 +1270,7 @@ export async function emitPrerenderPathManifest( const appPathResult = await collectAppPaths({ appDir, baseUrl, + cacheComponents: config.cacheComponents, pageExtensions: config.pageExtensions, retryOptions: pathDiscoveryRetryOptions, secretHeaders, diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index b25c54f93..bc176a5c1 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -935,6 +935,34 @@ describe("prerender path manifest", () => { ]); }); + it.each([ + ["App Page", "app/posts/[slug]/page.tsx", "export default function Page() { return null; }"], + [ + "App Route Handler", + "app/api/posts/[slug]/route.ts", + "export function GET() { return Response.json({ ok: true }); }", + ], + ])("rejects empty generateStaticParams for a Cache Components %s", async (_name, file, body) => { + // Ported from Next.js: test/e2e/app-dir/empty-generate-static-params + // https://github.com/vercel/next.js/tree/canary/test/e2e/app-dir/empty-generate-static-params + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("next.config.js", "export default { cacheComponents: true };\n"); + 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(file, ["export function generateStaticParams() { return []; }", body].join("\n")); + vi.mocked(fetch).mockResolvedValue(Response.json([])); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + + await expect( + emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" }), + ).rejects.toThrow( + "When using Cache Components, all `generateStaticParams` functions must return at least one result.", + ); + }); + it.each([ [ "empty generateStaticParams", From cc784c90358dbaea4def300faf9540a93da15be6 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 14:56:32 +0100 Subject: [PATCH 14/24] fix(cloudflare): scope empty params validation to pages --- packages/vinext/src/build/prerender-paths.ts | 80 +++++++++++--------- tests/prerender-paths.test.ts | 69 ++++++++++------- 2 files changed, 86 insertions(+), 63 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 592dc90c4..c27cadb37 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -709,49 +709,54 @@ async function collectAppPaths(options: { const seenRouteHandlerPaths = new Set(); const fallbackRoutePatterns: PrerenderRoutePattern[] = []; const staticParamsCache = new Map[] | null>>(); + let requireNonEmptyStaticParams = false; const staticParamsMap = new Proxy({} as StaticParamsMap, { get(_target, pattern: string) { return async ({ params }: { params: Record }) => { if (!options.baseUrl) return null; const cacheKey = `${pattern}\0${JSON.stringify(params)}`; - const cached = staticParamsCache.get(cacheKey); - if (cached !== undefined) return cached; - const request = (async () => { - const search = new URLSearchParams({ pattern }); - if (Object.keys(params).length > 0) { - search.set("parentParams", JSON.stringify(params)); - } - const text = await fetchDiscoveryEndpoint( - `${options.baseUrl}/__vinext/prerender/static-params?${search}`, - options.secretHeaders, - options.retryOptions, - ); - if (text === null) return null; - const value = JSON.parse(text) as unknown; - if (!Array.isArray(value)) { - throw new Error(`generateStaticParams must return an array for ${pattern}.`); - } - if (options.cacheComponents && value.length === 0) { - throw new Error( - "When using Cache Components, all `generateStaticParams` functions must return at least one result. " + - "This is to ensure that we can perform build-time validation that there is no other dynamic accesses that would cause a runtime error.\n\n" + - "Learn more: https://nextjs.org/docs/messages/empty-generate-static-params", - ); - } - return value.map((entry) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - throw new Error(`generateStaticParams must return parameter objects for ${pattern}.`); + let request = staticParamsCache.get(cacheKey); + if (request === undefined) { + request = (async () => { + const search = new URLSearchParams({ pattern }); + if (Object.keys(params).length > 0) { + search.set("parentParams", JSON.stringify(params)); } - return validateDiscoveredParams( - { ...params, ...(entry as Record) }, - pattern, - "generateStaticParams", + const text = await fetchDiscoveryEndpoint( + `${options.baseUrl}/__vinext/prerender/static-params?${search}`, + options.secretHeaders, + options.retryOptions, ); - }); - })(); - void request.catch(() => staticParamsCache.delete(cacheKey)); - staticParamsCache.set(cacheKey, request); - return request; + if (text === null) return null; + const value = JSON.parse(text) as unknown; + if (!Array.isArray(value)) { + throw new Error(`generateStaticParams must return an array for ${pattern}.`); + } + return value.map((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error( + `generateStaticParams must return parameter objects for ${pattern}.`, + ); + } + return validateDiscoveredParams( + { ...params, ...(entry as Record) }, + pattern, + "generateStaticParams", + ); + }); + })(); + void request.catch(() => staticParamsCache.delete(cacheKey)); + staticParamsCache.set(cacheKey, request); + } + const value = await request; + if (requireNonEmptyStaticParams && value?.length === 0) { + throw new Error( + "When using Cache Components, all `generateStaticParams` functions must return at least one result. " + + "This is to ensure that we can perform build-time validation that there is no other dynamic accesses that would cause a runtime error.\n\n" + + "Learn more: https://nextjs.org/docs/messages/empty-generate-static-params", + ); + } + return value; }; }, has() { @@ -788,6 +793,9 @@ async function collectAppPaths(options: { continue; } + // Next.js enables Cache Components PPR validation only for App Pages. + // App Route Handlers still permit empty generateStaticParams results. + requireNonEmptyStaticParams = options.cacheComponents && !isRouteHandler; try { const generateStaticParams = staticParamsMap[route.pattern]; if (typeof generateStaticParams !== "function") continue; diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index bc176a5c1..88c74c4ac 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -935,14 +935,7 @@ describe("prerender path manifest", () => { ]); }); - it.each([ - ["App Page", "app/posts/[slug]/page.tsx", "export default function Page() { return null; }"], - [ - "App Route Handler", - "app/api/posts/[slug]/route.ts", - "export function GET() { return Response.json({ ok: true }); }", - ], - ])("rejects empty generateStaticParams for a Cache Components %s", async (_name, file, body) => { + it("rejects empty generateStaticParams for a Cache Components App Page", async () => { // Ported from Next.js: test/e2e/app-dir/empty-generate-static-params // https://github.com/vercel/next.js/tree/canary/test/e2e/app-dir/empty-generate-static-params writeFile("package.json", JSON.stringify({ type: "module" })); @@ -950,7 +943,13 @@ describe("prerender path manifest", () => { 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(file, ["export function generateStaticParams() { return []; }", body].join("\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 } = @@ -972,6 +971,15 @@ describe("prerender path manifest", () => { ].join("\n"), Response.json([]), ], + [ + "empty generateStaticParams with Cache Components", + [ + "export function generateStaticParams() { return []; }", + "export function GET() { return Response.json({ ok: true }); }", + ].join("\n"), + Response.json([]), + true, + ], [ "force-static without generateStaticParams", [ @@ -979,27 +987,34 @@ describe("prerender path manifest", () => { "export function GET() { return Response.json({ ok: true }); }", ].join("\n"), new Response(null, { status: 204 }), + false, ], - ])("retains dynamic App Route Handler patterns with %s", async (_name, source, response) => { - // Ported from Next.js static App Route eligibility: - // packages/next/src/server/route-modules/app-route/helpers/is-static-gen-enabled.ts - writeFile("package.json", JSON.stringify({ type: "module" })); - writeFile("dist/server/BUILD_ID", "build-a\n"); - writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); - writeFile("dist/server/index.js", "export default {};\n"); - writeFile("app/api/layout.tsx", 'export const dynamic = "force-dynamic";\n'); - writeFile("app/api/posts/[slug]/route.ts", source); - vi.mocked(fetch).mockResolvedValue(response); + ])( + "retains dynamic App Route Handler patterns with %s", + async (_name, source, response, cacheComponents = false) => { + // Ported from Next.js static App Route eligibility: + // packages/next/src/server/route-modules/app-route/helpers/is-static-gen-enabled.ts + writeFile("package.json", JSON.stringify({ type: "module" })); + if (cacheComponents) { + writeFile("next.config.js", "export default { cacheComponents: true };\n"); + } + 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/api/layout.tsx", 'export const dynamic = "force-dynamic";\n'); + writeFile("app/api/posts/[slug]/route.ts", source); + vi.mocked(fetch).mockResolvedValue(response); - const { emitPrerenderPathManifest } = - await import("../packages/vinext/src/build/prerender-paths.js"); - const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" }); + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" }); - expect(manifest?.routeHandlerPaths).toBeUndefined(); - expect(manifest?.fallbackRoutePatterns).toEqual([ - { kind: "app-route", pattern: "/api/posts/:slug" }, - ]); - }); + expect(manifest?.routeHandlerPaths).toBeUndefined(); + expect(manifest?.fallbackRoutePatterns).toEqual([ + { kind: "app-route", pattern: "/api/posts/:slug" }, + ]); + }, + ); it.each([ [true, true], From 99498f583f4bfb09c90a0d86ff019f76d60133b5 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 15:09:52 +0100 Subject: [PATCH 15/24] fix(cloudflare): remove cacheability response size limits --- .../src/server/app-route-handler-execution.ts | 12 +- .../vinext/src/server/cacheability-limits.ts | 6 - .../vinext/src/server/cacheability-request.ts | 116 +++------------ .../src/shims/cacheability-classification.ts | 10 +- tests/app-route-handler-execution.test.ts | 18 ++- tests/cacheability-admission.test.ts | 138 ++++++++++-------- .../cacheability-admission.spec.ts | 9 ++ .../cacheability-probe.spec.ts | 11 ++ .../cacheability/route-handler-large/route.ts | 18 +++ .../cacheability-manifest.json | 5 + tests/fixtures/ppr-impact-demo/wrangler.jsonc | 1 + 11 files changed, 162 insertions(+), 182 deletions(-) create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-large/route.ts diff --git a/packages/vinext/src/server/app-route-handler-execution.ts b/packages/vinext/src/server/app-route-handler-execution.ts index 0028f829c..c2602b75b 100644 --- a/packages/vinext/src/server/app-route-handler-execution.ts +++ b/packages/vinext/src/server/app-route-handler-execution.ts @@ -49,10 +49,7 @@ import { markRouteCacheabilityExplicitResponsePolicy, markRouteCacheabilityResponseBodyComplete, } from "vinext/shims/cacheability-classification"; -import { - CACHEABILITY_PROBE_BODY_LIMIT, - CACHEABILITY_PROBE_TIMEOUT_MS, -} from "./cacheability-limits.js"; +import { CACHEABILITY_PROBE_TIMEOUT_MS } from "./cacheability-limits.js"; export type AppRouteParams = Record; export type AppRouteDynamicUsageFn = () => boolean; @@ -131,16 +128,13 @@ async function completeAppRouteHandlerResponse( // artifact deterministic, this keeps request tracking active for stream // pulls and turns a late body failure into the normal Route Handler error // path before cacheable response headers are applied. - // Workers cannot safely buffer an unbounded or never-ending response. Reuse - // admission's isolate-wide bounded capture and fall back to private streaming - // when the response exceeds either the size or completion deadline. + // Reuse admission's completion deadline and fall back to private streaming + // when the response does not finish in time. const { captureCacheabilityAdmissionBody } = await import("./cacheability-request.js"); const captureOptions = getRouteCacheabilityCaptureOptions(); const captured = await captureCacheabilityAdmissionBody( response.body, captureOptions?.captureDeadlineAt ?? Date.now() + CACHEABILITY_PROBE_TIMEOUT_MS, - CACHEABILITY_PROBE_BODY_LIMIT, - captureOptions?.captureBudget, ); const completed = new Response(captured.body, { headers: response.headers, diff --git a/packages/vinext/src/server/cacheability-limits.ts b/packages/vinext/src/server/cacheability-limits.ts index 9ce869152..155af90b4 100644 --- a/packages/vinext/src/server/cacheability-limits.ts +++ b/packages/vinext/src/server/cacheability-limits.ts @@ -1,8 +1,2 @@ -/** Maximum response body that an authenticated cacheability probe will drain. */ -export const CACHEABILITY_PROBE_BODY_LIMIT = 4 * 1024 * 1024; - -/** Maximum completed-response capture retained across concurrent isolate requests. */ -export const CACHEABILITY_ADMISSION_ISOLATE_BODY_LIMIT = 16 * 1024 * 1024; - /** Leave headroom below the deploy-side request timeout for a fail-closed envelope. */ export const CACHEABILITY_PROBE_TIMEOUT_MS = 20_000; diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index 518d330bc..16e477841 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -18,11 +18,7 @@ import { VINEXT_RSC_VARY_HEADER, } from "./headers.js"; import { workerCapabilityMatches } from "./worker-prerender-discovery.js"; -import { - CACHEABILITY_ADMISSION_ISOLATE_BODY_LIMIT, - CACHEABILITY_PROBE_BODY_LIMIT, - CACHEABILITY_PROBE_TIMEOUT_MS, -} from "./cacheability-limits.js"; +import { CACHEABILITY_PROBE_TIMEOUT_MS } from "./cacheability-limits.js"; import { cacheabilityManifestRouteState, cacheabilityRequestIdentity, @@ -204,7 +200,6 @@ function probeResponse( async function drainProbeBody(response: Response, deadlineAt: number): Promise { if (!response.body) return null; const reader = response.body.getReader(); - let total = 0; let timeout: ReturnType | undefined; try { while (true) { @@ -224,17 +219,18 @@ async function drainProbeBody(response: Response, deadlineAt: number): Promise CACHEABILITY_PROBE_BODY_LIMIT) { - return `response body exceeded ${CACHEABILITY_PROBE_BODY_LIMIT} bytes`; - } } } catch (error) { return error instanceof Error ? error.message : String(error); } finally { if (timeout !== undefined) clearTimeout(timeout); - await reader.cancel().catch(() => {}); - reader.releaseLock(); + // Cancellation is cleanup, not part of classification. A user stream may + // return a never-settling cancel promise; do not let it defeat the probe + // deadline after the body read has already timed out. + void reader.cancel().catch(() => {}); + try { + reader.releaseLock(); + } catch {} } } @@ -242,46 +238,6 @@ type CapturedAdmissionBody = | { body: ReadableStream | null; kind: "captured" } | { body: ReadableStream; kind: "fallback" }; -export type CacheabilityAdmissionCaptureBudget = { - maxBytes: number; - reservedBytes: number; -}; - -const isolateCaptureBudget: CacheabilityAdmissionCaptureBudget = { - maxBytes: CACHEABILITY_ADMISSION_ISOLATE_BODY_LIMIT, - reservedBytes: 0, -}; - -export function createCacheabilityAdmissionCaptureBudget( - maxBytes: number, -): CacheabilityAdmissionCaptureBudget { - return { maxBytes, reservedBytes: 0 }; -} - -type CapturedChunk = { reserved: boolean; value: Uint8Array }; - -function reserveChunk(budget: CacheabilityAdmissionCaptureBudget, byteLength: number): boolean { - if (byteLength > budget.maxBytes - budget.reservedBytes) return false; - budget.reservedBytes += byteLength; - return true; -} - -function releaseChunk(budget: CacheabilityAdmissionCaptureBudget, chunk: CapturedChunk): void { - if (!chunk.reserved) return; - chunk.reserved = false; - budget.reservedBytes -= chunk.value.byteLength; -} - -function releaseChunks( - budget: CacheabilityAdmissionCaptureBudget, - chunks: CapturedChunk[], - start = 0, -): void { - for (let index = start; index < chunks.length; index++) { - releaseChunk(budget, chunks[index]); - } -} - async function readBeforeDeadline( promise: Promise, deadlineAt: number, @@ -303,8 +259,7 @@ async function readBeforeDeadline( function continueCapturedBody( reader: ReadableStreamDefaultReader, - captured: CapturedChunk[], - budget: CacheabilityAdmissionCaptureBudget, + captured: Uint8Array[], pendingRead?: Promise>, ): ReadableStream { let index = 0; @@ -318,9 +273,7 @@ function continueCapturedBody( { async pull(controller) { if (index < captured.length) { - const chunk = captured[index++]; - controller.enqueue(chunk.value); - releaseChunk(budget, chunk); + controller.enqueue(captured[index++]); return; } try { @@ -341,7 +294,6 @@ function continueCapturedBody( try { await reader.cancel(reason); } finally { - releaseChunks(budget, captured, index); release(); } }, @@ -350,10 +302,7 @@ function continueCapturedBody( ); } -function replayCapturedBody( - captured: CapturedChunk[], - budget: CacheabilityAdmissionCaptureBudget, -): ReadableStream { +function replayCapturedBody(captured: Uint8Array[]): ReadableStream { let index = 0; return new ReadableStream( { @@ -362,12 +311,7 @@ function replayCapturedBody( controller.close(); return; } - const chunk = captured[index++]; - controller.enqueue(chunk.value); - releaseChunk(budget, chunk); - }, - cancel() { - releaseChunks(budget, captured, index); + controller.enqueue(captured[index++]); }, }, { highWaterMark: 0 }, @@ -377,40 +321,29 @@ function replayCapturedBody( export async function captureCacheabilityAdmissionBody( body: ReadableStream | null, deadlineAt: number, - limit = CACHEABILITY_PROBE_BODY_LIMIT, - budget = isolateCaptureBudget, ): Promise { if (!body) return { body: null, kind: "captured" }; const reader = body.getReader(); - const chunks: CapturedChunk[] = []; - let total = 0; + const chunks: Uint8Array[] = []; try { while (true) { const pendingRead = reader.read(); const deadlineResult = await readBeforeDeadline(pendingRead, deadlineAt); if (deadlineResult.kind === "timeout") { return { - body: continueCapturedBody(reader, chunks, budget, pendingRead), + body: continueCapturedBody(reader, chunks, pendingRead), kind: "fallback", }; } const result = deadlineResult.value; if (result.done) { reader.releaseLock(); - return { body: replayCapturedBody(chunks, budget), kind: "captured" }; - } - const nextTotal = total + result.value.byteLength; - const withinResponseLimit = nextTotal <= limit; - const reserved = withinResponseLimit && reserveChunk(budget, result.value.byteLength); - chunks.push({ reserved, value: result.value }); - total = nextTotal; - if (!reserved) { - return { body: continueCapturedBody(reader, chunks, budget), kind: "fallback" }; + return { body: replayCapturedBody(chunks), kind: "captured" }; } + chunks.push(result.value); } } catch (error) { await reader.cancel(error).catch(() => {}); - releaseChunks(budget, chunks); reader.releaseLock(); throw error; } @@ -635,12 +568,7 @@ async function finalizeWorkerCacheabilityAdmission( let captured: CapturedAdmissionBody; try { - captured = await captureCacheabilityAdmissionBody( - response.body, - state.captureDeadlineAt, - CACHEABILITY_PROBE_BODY_LIMIT, - state.captureBudget ?? isolateCaptureBudget, - ); + captured = await captureCacheabilityAdmissionBody(response.body, state.captureDeadlineAt); } catch { return cacheabilityEvaluationFailureResponse(state.route.pattern); } @@ -699,12 +627,7 @@ async function finalizeWorkerCacheabilityAdmission( } let captured: CapturedAdmissionBody; try { - captured = await captureCacheabilityAdmissionBody( - response.body, - state.captureDeadlineAt, - CACHEABILITY_PROBE_BODY_LIMIT, - state.captureBudget ?? isolateCaptureBudget, - ); + captured = await captureCacheabilityAdmissionBody(response.body, state.captureDeadlineAt); } catch { return cacheabilityEvaluationFailureResponse(state.route.pattern); } @@ -725,8 +648,7 @@ async function finalizeWorkerCacheabilityAdmission( manifestRouteState === "static-candidate" && outcome?.dynamicUsage === true ) { - // The replacement 500 does not consume the captured replay stream. Its - // cancellation releases the isolate-wide byte reservation immediately. + // The replacement 500 does not consume the captured replay stream. await captured.body?.cancel().catch(() => {}); return staticToDynamicResponse(manifestRoute); } diff --git a/packages/vinext/src/shims/cacheability-classification.ts b/packages/vinext/src/shims/cacheability-classification.ts index ce4e4195d..70127991b 100644 --- a/packages/vinext/src/shims/cacheability-classification.ts +++ b/packages/vinext/src/shims/cacheability-classification.ts @@ -27,8 +27,6 @@ export type RouteCacheabilityState = { requestKey?: string; routePathname?: string; }; - /** Optional admission budget override used by focused runtime tests. */ - captureBudget?: { maxBytes: number; reservedBytes: number }; captureDeadlineAt: number; complete?: (outcome: RouteCacheabilityOutcome) => void; completion?: Promise; @@ -106,15 +104,13 @@ export function getRouteCacheabilityDynamicReason(): string | null { return readRouteCacheabilityState()?.forcedDynamicReason ?? null; } -/** Reuse the active request's bounded response-capture envelope when available. */ +/** Reuse the active request's response-completion deadline when available. */ export function getRouteCacheabilityCaptureOptions(): Pick< RouteCacheabilityState, - "captureBudget" | "captureDeadlineAt" + "captureDeadlineAt" > | null { const state = readRouteCacheabilityState(); - return state - ? { captureBudget: state.captureBudget, captureDeadlineAt: state.captureDeadlineAt } - : null; + return state ? { captureDeadlineAt: state.captureDeadlineAt } : null; } /** Keep a completed response private without treating it as static-to-dynamic. */ diff --git a/tests/app-route-handler-execution.test.ts b/tests/app-route-handler-execution.test.ts index 668837c9f..27b855cee 100644 --- a/tests/app-route-handler-execution.test.ts +++ b/tests/app-route-handler-execution.test.ts @@ -856,7 +856,7 @@ describe("app route handler execution helpers", () => { ).resolves.toHaveProperty("explicitResponseCachePolicy", true); }); - it("falls back to private streaming and defers cleanup when bounded completion overflows", async () => { + it("falls back to private streaming and defers cleanup when completion times out", async () => { const request = new Request("https://example.com/api/large", { headers: { Accept: "*/*" }, }); @@ -868,7 +868,7 @@ describe("app route handler execution helpers", () => { true, ); const state = Reflect.get(context, CACHEABILITY_REQUEST_STATE) as RouteCacheabilityState; - state.captureBudget = { maxBytes: 1, reservedBytes: 0 }; + state.captureDeadlineAt = Date.now() + 5; let cleared = false; const phaseCalls: string[] = []; @@ -896,7 +896,15 @@ describe("app route handler execution helpers", () => { }, handler: { dynamic: "auto", revalidate: 60 }, handlerFn() { - return new Response("large"); + return new Response( + new ReadableStream({ + async pull(controller) { + await new Promise((resolve) => setTimeout(resolve, 20)); + controller.enqueue(new TextEncoder().encode("slow")); + controller.close(); + }, + }), + ); }, isAutoHead: false, isProduction: true, @@ -904,7 +912,7 @@ describe("app route handler execution helpers", () => { return pathname; }, async isrSet() { - throw new Error("overflow response must not be persisted"); + throw new Error("incomplete response must not be persisted"); }, markDynamicUsage() {}, method: "GET", @@ -923,7 +931,7 @@ describe("app route handler execution helpers", () => { expect(cleared).toBe(false); expect(response.headers.get("cache-control")).toContain("no-store"); - await expect(response.text()).resolves.toBe("large"); + await expect(response.text()).resolves.toBe("slow"); expect(cleared).toBe(true); expect(phaseCalls).toEqual(["route-handler", "render"]); }); diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index 0e7e2f471..6023428f5 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { captureCacheabilityAdmissionBody, - createCacheabilityAdmissionCaptureBudget, createWorkerCacheabilityAdmissionContext, createWorkerCacheabilityContext, finalizeWorkerCacheabilityResponse, @@ -23,20 +22,6 @@ import { CloudflareCdnCacheAdapter } from "../packages/cloudflare/src/cache/cdn- const encoder = new TextEncoder(); describe("cacheability admission capture", () => { - it("preserves all bytes when the bounded capture limit is exceeded", async () => { - const body = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode("first")); - controller.enqueue(encoder.encode("second")); - controller.close(); - }, - }); - - const captured = await captureCacheabilityAdmissionBody(body, Date.now() + 1_000, 5); - expect(captured.kind).toBe("fallback"); - await expect(new Response(captured.body).text()).resolves.toBe("firstsecond"); - }); - it("falls back to the private stream without cancelling a slow response", async () => { const body = new ReadableStream({ async start(controller) { @@ -50,46 +35,6 @@ describe("cacheability admission capture", () => { expect(captured.kind).toBe("fallback"); await expect(new Response(captured.body).text()).resolves.toBe("slow"); }); - - it("bounds completed captures across concurrent isolate requests", async () => { - const budget = createCacheabilityAdmissionCaptureBudget(5); - const first = await captureCacheabilityAdmissionBody( - new Response("first").body, - Date.now() + 1_000, - 10, - budget, - ); - expect(first.kind).toBe("captured"); - expect(budget.reservedBytes).toBe(5); - - const second = await captureCacheabilityAdmissionBody( - new Response("second").body, - Date.now() + 1_000, - 10, - budget, - ); - expect(second.kind).toBe("fallback"); - await expect(new Response(second.body).text()).resolves.toBe("second"); - expect(budget.reservedBytes).toBe(5); - - await expect(new Response(first.body).text()).resolves.toBe("first"); - expect(budget.reservedBytes).toBe(0); - }); - - it("releases the isolate reservation when a captured response is cancelled", async () => { - const budget = createCacheabilityAdmissionCaptureBudget(5); - const captured = await captureCacheabilityAdmissionBody( - new Response("first").body, - Date.now() + 1_000, - 10, - budget, - ); - expect(captured.kind).toBe("captured"); - expect(budget.reservedBytes).toBe(5); - - await captured.body?.cancel(); - expect(budget.reservedBytes).toBe(0); - }); }); function cacheabilityState(context: object): RouteCacheabilityState { @@ -160,6 +105,28 @@ describe("single-request cacheability admission", () => { await expect(response.text()).resolves.toBe("static"); }); + it("admits a completed static response larger than the removed 16 MiB limit", async () => { + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + null, + "build-a", + true, + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: "/page" }; + state.outcome = { + cacheable: true, + cacheControl: "s-maxage=60, stale-while-revalidate=540", + }; + const body = new Uint8Array(16 * 1024 * 1024 + 1); + + const response = await finalizeWorkerCacheabilityResponse(new Response(body), context); + + expect(response.headers.get("Cache-Control")).toBe("s-maxage=60, stale-while-revalidate=540"); + expect((await response.arrayBuffer()).byteLength).toBe(body.byteLength); + }); + it("honors a final public App Page cache policy", async () => { // Ported from Next.js: // test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts @@ -257,6 +224,64 @@ describe("single-request cacheability admission", () => { }); }); + it("drains a probe response larger than 4 MiB without retaining its body", async () => { + const context = createWorkerCacheabilityContext( + { waitUntil() {} }, + new Request("https://example.com/page", { + headers: { + "X-Vinext-Cacheability-Probe": "1", + "X-Vinext-Prerender-Secret": "probe-secret", + }, + }), + "probe-secret", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: "/page" }; + state.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response(new Uint8Array(4 * 1024 * 1024 + 1)), + context, + ); + + await expect(response.json()).resolves.toMatchObject({ + cacheControl: "s-maxage=60", + kind: "app-page", + pattern: "/page", + state: "static-candidate", + status: 200, + }); + }); + + it("returns after the probe deadline when stream cancellation never settles", async () => { + const context = createWorkerCacheabilityContext( + { waitUntil() {} }, + new Request("https://example.com/page", { + headers: { + "X-Vinext-Cacheability-Probe": "1", + "X-Vinext-Prerender-Secret": "probe-secret", + }, + }), + "probe-secret", + ); + const state = cacheabilityState(context); + state.captureDeadlineAt = Date.now() + 10; + state.route = { kind: "app-page", pattern: "/page" }; + state.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + const body = new ReadableStream({ + cancel: () => new Promise(() => {}), + pull: () => new Promise(() => {}), + }); + + const response = await finalizeWorkerCacheabilityResponse(new Response(body), context); + + await expect(response.json()).resolves.toMatchObject({ + reason: "response body did not complete before the probe deadline", + state: "probe-failed", + status: 200, + }); + }); + it.each([undefined, "*/*", "application/json"])( "creates fail-closed request state without an HTML Accept header (%s)", (accept) => { @@ -625,15 +650,12 @@ describe("single-request cacheability admission", () => { "build-a", ); const state = cacheabilityState(context); - const captureBudget = createCacheabilityAdmissionCaptureBudget(7); - state.captureBudget = captureBudget; state.route = { kind: "app-page", pattern: "/page" }; state.outcome = { cacheable: false, dynamicUsage: true }; const response = await finalizeWorkerCacheabilityResponse(new Response("dynamic"), context); expect(response.status).toBe(500); expect(response.headers.get("Cache-Control")).toContain("no-store"); - expect(captureBudget.reservedBytes).toBe(0); await expect(response.text()).resolves.toContain("changed from static to dynamic"); }); diff --git a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts index 3ecff87d1..87f27bb87 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts @@ -188,6 +188,15 @@ test("admits pattern-backed App responses only after each clean EOF", async ({ r expect(certifiedRouteHandler.headers()["cdn-cache-control"]).toContain("public"); expect(certifiedRouteHandler.headers()["vary"]?.toLowerCase()).toContain("user-agent"); + const largeRouteHandler = await request.get("/cacheability/route-handler-large"); + expect(largeRouteHandler.status()).toBe(200); + expect((await largeRouteHandler.body()).byteLength).toBe(4 * 1024 * 1024 + 1); + expect(largeRouteHandler.headers()["cdn-cache-control"]).toContain("public"); + + const cachedLargeRouteHandler = await request.get("/cacheability/route-handler-large"); + expect(cachedLargeRouteHandler.status()).toBe(200); + expect((await cachedLargeRouteHandler.body()).byteLength).toBe(4 * 1024 * 1024 + 1); + const emptyStaticRouteHandler = await request.get( "/cacheability/route-handler-static-empty/on-demand", ); diff --git a/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts index e1a15daad..2611f15a1 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts @@ -173,6 +173,17 @@ test("classifies completed App Page renders inside workerd", async ({ request }) version: 1, }); + const largeRouteHandlerProbe = await request.get("/cacheability/route-handler-large", { + headers: { ...headers, Accept: "*/*" }, + }); + await expect(largeRouteHandlerProbe.json()).resolves.toMatchObject({ + kind: "app-route", + pattern: "/cacheability/route-handler-large", + state: "static-candidate", + status: 200, + version: 1, + }); + // Next.js lets revalidate make a Route Handler statically eligible, but a // dynamic API used by the completed handler still opts that route out. // Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-large/route.ts b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-large/route.ts new file mode 100644 index 000000000..9c7b50a3d --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-large/route.ts @@ -0,0 +1,18 @@ +export const revalidate = 60; + +const BODY_SIZE = 4 * 1024 * 1024 + 1; + +export function GET() { + return new Response( + new ReadableStream( + { + pull(controller) { + controller.enqueue(new Uint8Array(BODY_SIZE).fill(97)); + controller.close(); + }, + }, + { highWaterMark: 0 }, + ), + { headers: { "Content-Type": "application/octet-stream" } }, + ); +} diff --git a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json index f5e114237..9695b7d4d 100644 --- a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json +++ b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json @@ -60,6 +60,11 @@ "pattern": "/cacheability/route-handler-config-public-late-error", "state": "static-candidate" }, + "[\"app-route\",\"/cacheability/route-handler-large\"]": { + "kind": "app-route", + "pattern": "/cacheability/route-handler-large", + "state": "static-candidate" + }, "[\"app-route\",\"/cacheability/route-handler-explicit-dynamic\"]": { "kind": "app-route", "pattern": "/cacheability/route-handler-explicit-dynamic", diff --git a/tests/fixtures/ppr-impact-demo/wrangler.jsonc b/tests/fixtures/ppr-impact-demo/wrangler.jsonc index 2da3c937a..1f983f1b9 100644 --- a/tests/fixtures/ppr-impact-demo/wrangler.jsonc +++ b/tests/fixtures/ppr-impact-demo/wrangler.jsonc @@ -3,5 +3,6 @@ "name": "ppr-impact-demo", "compatibility_date": "2026-04-08", "compatibility_flags": ["nodejs_compat"], + "cache": { "enabled": true }, "main": "vinext/server/fetch-handler", } From 5b34270616f37d099cb92395477e11bc97e2270e Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 15:15:04 +0100 Subject: [PATCH 16/24] fix(cloudflare): bound completed cache admission --- .../src/server/app-route-handler-execution.ts | 11 +- .../vinext/src/server/cacheability-limits.ts | 6 + .../vinext/src/server/cacheability-request.ts | 117 +++++++++++++++--- .../src/shims/cacheability-classification.ts | 10 +- tests/cacheability-admission.test.ts | 76 +++++++++++- 5 files changed, 196 insertions(+), 24 deletions(-) diff --git a/packages/vinext/src/server/app-route-handler-execution.ts b/packages/vinext/src/server/app-route-handler-execution.ts index c2602b75b..afeffe3e6 100644 --- a/packages/vinext/src/server/app-route-handler-execution.ts +++ b/packages/vinext/src/server/app-route-handler-execution.ts @@ -49,7 +49,10 @@ import { markRouteCacheabilityExplicitResponsePolicy, markRouteCacheabilityResponseBodyComplete, } from "vinext/shims/cacheability-classification"; -import { CACHEABILITY_PROBE_TIMEOUT_MS } from "./cacheability-limits.js"; +import { + CACHEABILITY_ADMISSION_RESPONSE_BODY_LIMIT, + CACHEABILITY_PROBE_TIMEOUT_MS, +} from "./cacheability-limits.js"; export type AppRouteParams = Record; export type AppRouteDynamicUsageFn = () => boolean; @@ -128,13 +131,15 @@ async function completeAppRouteHandlerResponse( // artifact deterministic, this keeps request tracking active for stream // pulls and turns a late body failure into the normal Route Handler error // path before cacheable response headers are applied. - // Reuse admission's completion deadline and fall back to private streaming - // when the response does not finish in time. + // Reuse admission's bounded capture envelope and fall back to private + // streaming when the response exceeds the memory or completion deadline. const { captureCacheabilityAdmissionBody } = await import("./cacheability-request.js"); const captureOptions = getRouteCacheabilityCaptureOptions(); const captured = await captureCacheabilityAdmissionBody( response.body, captureOptions?.captureDeadlineAt ?? Date.now() + CACHEABILITY_PROBE_TIMEOUT_MS, + CACHEABILITY_ADMISSION_RESPONSE_BODY_LIMIT, + captureOptions?.captureBudget, ); const completed = new Response(captured.body, { headers: response.headers, diff --git a/packages/vinext/src/server/cacheability-limits.ts b/packages/vinext/src/server/cacheability-limits.ts index 155af90b4..1a13cb683 100644 --- a/packages/vinext/src/server/cacheability-limits.ts +++ b/packages/vinext/src/server/cacheability-limits.ts @@ -1,2 +1,8 @@ +/** Maximum completed response retained before CDN admission. */ +export const CACHEABILITY_ADMISSION_RESPONSE_BODY_LIMIT = 16 * 1024 * 1024; + +/** Maximum completed-response capture retained across concurrent isolate requests. */ +export const CACHEABILITY_ADMISSION_ISOLATE_BODY_LIMIT = 32 * 1024 * 1024; + /** Leave headroom below the deploy-side request timeout for a fail-closed envelope. */ export const CACHEABILITY_PROBE_TIMEOUT_MS = 20_000; diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index 16e477841..bb7cba200 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -18,7 +18,11 @@ import { VINEXT_RSC_VARY_HEADER, } from "./headers.js"; import { workerCapabilityMatches } from "./worker-prerender-discovery.js"; -import { CACHEABILITY_PROBE_TIMEOUT_MS } from "./cacheability-limits.js"; +import { + CACHEABILITY_ADMISSION_ISOLATE_BODY_LIMIT, + CACHEABILITY_ADMISSION_RESPONSE_BODY_LIMIT, + CACHEABILITY_PROBE_TIMEOUT_MS, +} from "./cacheability-limits.js"; import { cacheabilityManifestRouteState, cacheabilityRequestIdentity, @@ -238,6 +242,46 @@ type CapturedAdmissionBody = | { body: ReadableStream | null; kind: "captured" } | { body: ReadableStream; kind: "fallback" }; +export type CacheabilityAdmissionCaptureBudget = { + maxBytes: number; + reservedBytes: number; +}; + +const isolateCaptureBudget: CacheabilityAdmissionCaptureBudget = { + maxBytes: CACHEABILITY_ADMISSION_ISOLATE_BODY_LIMIT, + reservedBytes: 0, +}; + +export function createCacheabilityAdmissionCaptureBudget( + maxBytes: number, +): CacheabilityAdmissionCaptureBudget { + return { maxBytes, reservedBytes: 0 }; +} + +type CapturedChunk = { reserved: boolean; value: Uint8Array }; + +function reserveChunk(budget: CacheabilityAdmissionCaptureBudget, byteLength: number): boolean { + if (byteLength > budget.maxBytes - budget.reservedBytes) return false; + budget.reservedBytes += byteLength; + return true; +} + +function releaseChunk(budget: CacheabilityAdmissionCaptureBudget, chunk: CapturedChunk): void { + if (!chunk.reserved) return; + chunk.reserved = false; + budget.reservedBytes -= chunk.value.byteLength; +} + +function releaseChunks( + budget: CacheabilityAdmissionCaptureBudget, + chunks: CapturedChunk[], + start = 0, +): void { + for (let index = start; index < chunks.length; index++) { + releaseChunk(budget, chunks[index]); + } +} + async function readBeforeDeadline( promise: Promise, deadlineAt: number, @@ -259,7 +303,8 @@ async function readBeforeDeadline( function continueCapturedBody( reader: ReadableStreamDefaultReader, - captured: Uint8Array[], + captured: CapturedChunk[], + budget: CacheabilityAdmissionCaptureBudget, pendingRead?: Promise>, ): ReadableStream { let index = 0; @@ -267,13 +312,17 @@ function continueCapturedBody( const release = () => { if (released) return; released = true; - reader.releaseLock(); + try { + reader.releaseLock(); + } catch {} }; return new ReadableStream( { async pull(controller) { if (index < captured.length) { - controller.enqueue(captured[index++]); + const chunk = captured[index++]; + controller.enqueue(chunk.value); + releaseChunk(budget, chunk); return; } try { @@ -290,10 +339,16 @@ function continueCapturedBody( controller.error(error); } }, - async cancel(reason) { + cancel(reason) { + releaseChunks(budget, captured, index); try { - await reader.cancel(reason); - } finally { + // A user stream may return a never-settling cancellation promise. + // Cleanup of the outer request must not wait for it. + void reader + .cancel(reason) + .catch(() => {}) + .finally(release); + } catch { release(); } }, @@ -302,7 +357,10 @@ function continueCapturedBody( ); } -function replayCapturedBody(captured: Uint8Array[]): ReadableStream { +function replayCapturedBody( + captured: CapturedChunk[], + budget: CacheabilityAdmissionCaptureBudget, +): ReadableStream { let index = 0; return new ReadableStream( { @@ -311,7 +369,12 @@ function replayCapturedBody(captured: Uint8Array[]): ReadableStream controller.close(); return; } - controller.enqueue(captured[index++]); + const chunk = captured[index++]; + controller.enqueue(chunk.value); + releaseChunk(budget, chunk); + }, + cancel() { + releaseChunks(budget, captured, index); }, }, { highWaterMark: 0 }, @@ -321,29 +384,40 @@ function replayCapturedBody(captured: Uint8Array[]): ReadableStream export async function captureCacheabilityAdmissionBody( body: ReadableStream | null, deadlineAt: number, + limit = CACHEABILITY_ADMISSION_RESPONSE_BODY_LIMIT, + budget = isolateCaptureBudget, ): Promise { if (!body) return { body: null, kind: "captured" }; const reader = body.getReader(); - const chunks: Uint8Array[] = []; + const chunks: CapturedChunk[] = []; + let total = 0; try { while (true) { const pendingRead = reader.read(); const deadlineResult = await readBeforeDeadline(pendingRead, deadlineAt); if (deadlineResult.kind === "timeout") { return { - body: continueCapturedBody(reader, chunks, pendingRead), + body: continueCapturedBody(reader, chunks, budget, pendingRead), kind: "fallback", }; } const result = deadlineResult.value; if (result.done) { reader.releaseLock(); - return { body: replayCapturedBody(chunks), kind: "captured" }; + return { body: replayCapturedBody(chunks, budget), kind: "captured" }; + } + const nextTotal = total + result.value.byteLength; + const withinResponseLimit = nextTotal <= limit; + const reserved = withinResponseLimit && reserveChunk(budget, result.value.byteLength); + chunks.push({ reserved, value: result.value }); + total = nextTotal; + if (!reserved) { + return { body: continueCapturedBody(reader, chunks, budget), kind: "fallback" }; } - chunks.push(result.value); } } catch (error) { await reader.cancel(error).catch(() => {}); + releaseChunks(budget, chunks); reader.releaseLock(); throw error; } @@ -568,7 +642,12 @@ async function finalizeWorkerCacheabilityAdmission( let captured: CapturedAdmissionBody; try { - captured = await captureCacheabilityAdmissionBody(response.body, state.captureDeadlineAt); + captured = await captureCacheabilityAdmissionBody( + response.body, + state.captureDeadlineAt, + CACHEABILITY_ADMISSION_RESPONSE_BODY_LIMIT, + state.captureBudget ?? isolateCaptureBudget, + ); } catch { return cacheabilityEvaluationFailureResponse(state.route.pattern); } @@ -627,7 +706,12 @@ async function finalizeWorkerCacheabilityAdmission( } let captured: CapturedAdmissionBody; try { - captured = await captureCacheabilityAdmissionBody(response.body, state.captureDeadlineAt); + captured = await captureCacheabilityAdmissionBody( + response.body, + state.captureDeadlineAt, + CACHEABILITY_ADMISSION_RESPONSE_BODY_LIMIT, + state.captureBudget ?? isolateCaptureBudget, + ); } catch { return cacheabilityEvaluationFailureResponse(state.route.pattern); } @@ -648,7 +732,8 @@ async function finalizeWorkerCacheabilityAdmission( manifestRouteState === "static-candidate" && outcome?.dynamicUsage === true ) { - // The replacement 500 does not consume the captured replay stream. + // The replacement 500 does not consume the captured replay stream. Its + // cancellation releases the isolate-wide byte reservation immediately. await captured.body?.cancel().catch(() => {}); return staticToDynamicResponse(manifestRoute); } diff --git a/packages/vinext/src/shims/cacheability-classification.ts b/packages/vinext/src/shims/cacheability-classification.ts index 70127991b..ce4e4195d 100644 --- a/packages/vinext/src/shims/cacheability-classification.ts +++ b/packages/vinext/src/shims/cacheability-classification.ts @@ -27,6 +27,8 @@ export type RouteCacheabilityState = { requestKey?: string; routePathname?: string; }; + /** Optional admission budget override used by focused runtime tests. */ + captureBudget?: { maxBytes: number; reservedBytes: number }; captureDeadlineAt: number; complete?: (outcome: RouteCacheabilityOutcome) => void; completion?: Promise; @@ -104,13 +106,15 @@ export function getRouteCacheabilityDynamicReason(): string | null { return readRouteCacheabilityState()?.forcedDynamicReason ?? null; } -/** Reuse the active request's response-completion deadline when available. */ +/** Reuse the active request's bounded response-capture envelope when available. */ export function getRouteCacheabilityCaptureOptions(): Pick< RouteCacheabilityState, - "captureDeadlineAt" + "captureBudget" | "captureDeadlineAt" > | null { const state = readRouteCacheabilityState(); - return state ? { captureDeadlineAt: state.captureDeadlineAt } : null; + return state + ? { captureBudget: state.captureBudget, captureDeadlineAt: state.captureDeadlineAt } + : null; } /** Keep a completed response private without treating it as static-to-dynamic. */ diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index 6023428f5..2faed13d6 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { captureCacheabilityAdmissionBody, + createCacheabilityAdmissionCaptureBudget, createWorkerCacheabilityAdmissionContext, createWorkerCacheabilityContext, finalizeWorkerCacheabilityResponse, @@ -22,6 +23,20 @@ import { CloudflareCdnCacheAdapter } from "../packages/cloudflare/src/cache/cdn- const encoder = new TextEncoder(); describe("cacheability admission capture", () => { + it("preserves all bytes when the bounded capture limit is exceeded", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("first")); + controller.enqueue(encoder.encode("second")); + controller.close(); + }, + }); + + const captured = await captureCacheabilityAdmissionBody(body, Date.now() + 1_000, 5); + expect(captured.kind).toBe("fallback"); + await expect(new Response(captured.body).text()).resolves.toBe("firstsecond"); + }); + it("falls back to the private stream without cancelling a slow response", async () => { const body = new ReadableStream({ async start(controller) { @@ -35,6 +50,57 @@ describe("cacheability admission capture", () => { expect(captured.kind).toBe("fallback"); await expect(new Response(captured.body).text()).resolves.toBe("slow"); }); + + it("does not wait for user stream cancellation after a completion timeout", async () => { + const body = new ReadableStream({ + cancel: () => new Promise(() => {}), + pull: () => new Promise(() => {}), + }); + + const captured = await captureCacheabilityAdmissionBody(body, Date.now() + 5); + expect(captured.kind).toBe("fallback"); + await expect(captured.body?.cancel()).resolves.toBeUndefined(); + }); + + it("bounds completed captures across concurrent isolate requests", async () => { + const budget = createCacheabilityAdmissionCaptureBudget(5); + const first = await captureCacheabilityAdmissionBody( + new Response("first").body, + Date.now() + 1_000, + 10, + budget, + ); + expect(first.kind).toBe("captured"); + expect(budget.reservedBytes).toBe(5); + + const second = await captureCacheabilityAdmissionBody( + new Response("second").body, + Date.now() + 1_000, + 10, + budget, + ); + expect(second.kind).toBe("fallback"); + await expect(new Response(second.body).text()).resolves.toBe("second"); + expect(budget.reservedBytes).toBe(5); + + await expect(new Response(first.body).text()).resolves.toBe("first"); + expect(budget.reservedBytes).toBe(0); + }); + + it("releases the isolate reservation when a captured response is cancelled", async () => { + const budget = createCacheabilityAdmissionCaptureBudget(5); + const captured = await captureCacheabilityAdmissionBody( + new Response("first").body, + Date.now() + 1_000, + 10, + budget, + ); + expect(captured.kind).toBe("captured"); + expect(budget.reservedBytes).toBe(5); + + await captured.body?.cancel(); + expect(budget.reservedBytes).toBe(0); + }); }); function cacheabilityState(context: object): RouteCacheabilityState { @@ -105,7 +171,7 @@ describe("single-request cacheability admission", () => { await expect(response.text()).resolves.toBe("static"); }); - it("admits a completed static response larger than the removed 16 MiB limit", async () => { + it("admits a completed static response larger than the former 4 MiB probe limit", async () => { const context = createWorkerCacheabilityAdmissionContext( { waitUntil() {} }, request, @@ -119,7 +185,7 @@ describe("single-request cacheability admission", () => { cacheable: true, cacheControl: "s-maxage=60, stale-while-revalidate=540", }; - const body = new Uint8Array(16 * 1024 * 1024 + 1); + const body = new Uint8Array(4 * 1024 * 1024 + 1); const response = await finalizeWorkerCacheabilityResponse(new Response(body), context); @@ -421,6 +487,8 @@ describe("single-request cacheability admission", () => { true, ); const state = cacheabilityState(context); + const captureBudget = createCacheabilityAdmissionCaptureBudget(7); + state.captureBudget = captureBudget; state.route = { kind: "app-route", pattern: "/api/config-public" }; state.explicitConfigCachePolicy = true; @@ -439,6 +507,7 @@ describe("single-request cacheability admission", () => { expect(response.status).toBe(500); expect(response.headers.get("Cache-Control")).toContain("no-store"); + expect(captureBudget.reservedBytes).toBe(0); expect(response.headers.get("CDN-Cache-Control")).toBeNull(); }); @@ -650,12 +719,15 @@ describe("single-request cacheability admission", () => { "build-a", ); const state = cacheabilityState(context); + const captureBudget = createCacheabilityAdmissionCaptureBudget(7); + state.captureBudget = captureBudget; state.route = { kind: "app-page", pattern: "/page" }; state.outcome = { cacheable: false, dynamicUsage: true }; const response = await finalizeWorkerCacheabilityResponse(new Response("dynamic"), context); expect(response.status).toBe(500); expect(response.headers.get("Cache-Control")).toContain("no-store"); + expect(captureBudget.reservedBytes).toBe(0); await expect(response.text()).resolves.toContain("changed from static to dynamic"); }); From dc769c4465131c756518f7682be23cb2349d7f5d Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 15:17:38 +0100 Subject: [PATCH 17/24] fix(cloudflare): release failed admission captures --- .../vinext/src/server/cacheability-request.ts | 14 ++++++++++++-- tests/cacheability-admission.test.ts | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index bb7cba200..cab52259e 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -416,9 +416,19 @@ export async function captureCacheabilityAdmissionBody( } } } catch (error) { - await reader.cancel(error).catch(() => {}); releaseChunks(budget, chunks); - reader.releaseLock(); + const release = () => { + try { + reader.releaseLock(); + } catch {} + }; + try { + const cancellation = reader.cancel(error); + release(); + void cancellation.catch(() => {}).finally(release); + } catch { + release(); + } throw error; } } diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index 2faed13d6..7dc3af38e 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -101,6 +101,25 @@ describe("cacheability admission capture", () => { await captured.body?.cancel(); expect(budget.reservedBytes).toBe(0); }); + + it("does not wait for user stream cancellation after a read failure", async () => { + const budget = createCacheabilityAdmissionCaptureBudget(5); + let readCount = 0; + const reader = { + cancel: () => new Promise(() => {}), + read: () => + readCount++ === 0 + ? Promise.resolve({ done: false as const, value: encoder.encode("first") }) + : Promise.reject(new Error("read failed")), + releaseLock() {}, + }; + const body = { getReader: () => reader } as unknown as ReadableStream; + + await expect( + captureCacheabilityAdmissionBody(body, Date.now() + 1_000, 5, budget), + ).rejects.toThrow("read failed"); + expect(budget.reservedBytes).toBe(0); + }); }); function cacheabilityState(context: object): RouteCacheabilityState { From b9b1de50480065508f470a229c9d2c948633ca29 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 15:25:32 +0100 Subject: [PATCH 18/24] fix(cloudflare): honor staged readiness retries --- packages/cloudflare/src/cdn-warm.ts | 14 ++++++++----- packages/cloudflare/src/deploy-help.ts | 3 ++- tests/cloudflare-cdn-warm.test.ts | 27 ++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index a6a7d27ef..42dfc303b 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -890,15 +890,19 @@ export async function waitForCdnWarmTargetReadiness( options.requiredConsecutiveSuccesses ?? DEFAULT_STAGED_READINESS_SUCCESSES, ); const readinessRetries = Math.max(0, options.retries ?? DEFAULT_STAGED_READINESS_RETRIES); - const phaseTimeoutMs = Math.max( - 1, - options.phaseTimeoutMs ?? DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS, - ); - const deadlineAt = Date.now() + phaseTimeoutMs; const maxAttempts = Math.max( requiredConsecutiveSuccesses, options.maxAttempts ?? requiredConsecutiveSuccesses + readinessRetries, ); + // The default phase envelope must be large enough to honor the configured + // retry budget even when every attempt consumes its request timeout. An + // explicit phase deadline remains authoritative. + const retryBudgetMs = maxAttempts * timeoutMs + Math.max(0, maxAttempts - 1) * probeIntervalMs; + const phaseTimeoutMs = Math.max( + 1, + options.phaseTimeoutMs ?? Math.max(DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS, retryBudgetMs), + ); + const deadlineAt = Date.now() + phaseTimeoutMs; const probeId = randomUUID(); let consecutiveSuccesses = 0; let lastError = "readiness probe did not run"; diff --git a/packages/cloudflare/src/deploy-help.ts b/packages/cloudflare/src/deploy-help.ts index 4211fa0a2..cb705cf55 100644 --- a/packages/cloudflare/src/deploy-help.ts +++ b/packages/cloudflare/src/deploy-help.ts @@ -44,7 +44,8 @@ export function formatDeployHelp(): string { entries using headers only and require every planned entry to be reusable before promotion --warm-cdn-readiness-timeout - Total staged-readiness deadline (default: 120000) + Explicit total staged-readiness deadline (default: + enough for all retries, minimum 120000) --warm-cdn-readiness-retries Staged-readiness retries (default: 60) --warm-cdn-readiness-probes diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index e7fc0985b..198d431d7 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -899,6 +899,33 @@ describe("Cloudflare CDN warmup", () => { expect(fetchImpl.mock.calls.length).toBeLessThan(100); }); + it("lets the default phase envelope consume the configured retry budget", async () => { + let now = 0; + const dateNow = vi.spyOn(Date, "now").mockImplementation(() => now); + const fetchImpl = vi.fn(async () => { + now += 10_000; + throw new DOMException("timed out", "AbortError"); + }); + + try { + const readiness = await waitForCdnWarmTargetReadiness({ + expectedBuildId: "build-a", + fetchImpl: fetchImpl as typeof fetch, + plan: { loadingShellPaths: [], pagesDataPaths: [], paths: ["/"], rscPaths: [] }, + probeIntervalMs: 0, + requiredConsecutiveSuccesses: 6, + retries: 60, + targetUrl: "https://app.example.com", + timeoutMs: 10_000, + }); + + expect(readiness.ready).toBe(false); + expect(fetchImpl).toHaveBeenCalledTimes(66); + } finally { + dateNow.mockRestore(); + } + }); + it("does not skip a non-success response from a different build", async () => { const fetchImpl = vi.fn( async () => From e2615071e01336c726cd15a4bb89d5387a93513a Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 15:31:53 +0100 Subject: [PATCH 19/24] fix(cloudflare): retain probed manifest in dist --- .../cloudflare/src/cacheability-artifact.ts | 34 ++++------- packages/cloudflare/src/deploy.ts | 23 +++----- tests/cloudflare-cdn-warm-deploy.test.ts | 58 ++++++++++--------- 3 files changed, 50 insertions(+), 65 deletions(-) diff --git a/packages/cloudflare/src/cacheability-artifact.ts b/packages/cloudflare/src/cacheability-artifact.ts index 7837ac98e..ea978c5f0 100644 --- a/packages/cloudflare/src/cacheability-artifact.ts +++ b/packages/cloudflare/src/cacheability-artifact.ts @@ -194,16 +194,15 @@ function assertManifestModuleReachable(configPath: string): void { } /** - * Upload a version-specific manifest from an isolated copy of the built Worker. - * The application build already imports this stable module asset, so the final - * upload only replaces its contents and never rewrites generated JavaScript. + * 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. */ -export function withCacheabilityManifestArtifact( +export function writeCacheabilityManifestArtifact( root: string, configuredPath: string | undefined, manifest: CacheabilityManifest, - upload: (configPath: string) => T, -): T { +): string { const configPath = resolveGeneratedServerConfig(root, configuredPath); assertManifestModuleReachable(configPath); const serverDirectory = path.dirname(configPath); @@ -223,24 +222,13 @@ export function withCacheabilityManifestArtifact( throw cacheabilityManifestByteLimitError(manifestBytes); } - const isolatedDirectory = fs.mkdtempSync( - path.join(path.dirname(serverDirectory), ".vinext-cache-"), - ); + const manifestSource = `export default ${JSON.stringify(serializedManifest)};\n`; + const pendingManifestPath = `${manifestPath}.${process.pid}.tmp`; try { - fs.cpSync(serverDirectory, isolatedDirectory, { recursive: true }); - const isolatedManifestPath = path.join(isolatedDirectory, CACHEABILITY_MANIFEST_MODULE); - if (!fs.lstatSync(isolatedManifestPath).isFile()) { - throw new Error( - `Two-stage CDN warming requires ${CACHEABILITY_MANIFEST_MODULE} to be a regular Worker module.`, - ); - } - fs.writeFileSync( - isolatedManifestPath, - `export default ${JSON.stringify(serializedManifest)};\n`, - "utf8", - ); - return upload(path.relative(root, path.join(isolatedDirectory, path.basename(configPath)))); + fs.writeFileSync(pendingManifestPath, manifestSource, "utf8"); + fs.renameSync(pendingManifestPath, manifestPath); } finally { - fs.rmSync(isolatedDirectory, { recursive: true, force: true }); + if (fs.existsSync(pendingManifestPath)) fs.unlinkSync(pendingManifestPath); } + return path.relative(root, configPath); } diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index e35c3cc6c..15497bee3 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -85,7 +85,7 @@ import { parseWorkerDeploymentUrl } from "./worker-deployment-url.js"; import { PHASE_PRODUCTION_BUILD } from "vinext/shims/constants"; import { cacheabilityRoutePathname } from "vinext/internal/server/cacheability-manifest"; import { buildPrerenderKVPairs, type KVBulkPair } from "./prerender-kv-populate.js"; -import { withCacheabilityManifestArtifact } from "./cacheability-artifact.js"; +import { writeCacheabilityManifestArtifact } from "./cacheability-artifact.js"; import { DEFAULT_CACHEABILITY_PROBE_PHASE_TIMEOUT_MS, DEFAULT_CACHEABILITY_PROBE_RETRIES, @@ -1600,19 +1600,14 @@ async function deployWithCacheabilityProbe( stagedProbeDeployment, "Two-stage CDN warming stopped because Worker deployment traffic or deployment identity changed while cacheability was being probed. No final version was promoted.", ); - const finalUpload = withCacheabilityManifestArtifact( - root, - options.config, - probe.manifest, - (config) => - runWranglerVersionUpload(root, { - config, - env: options.env, - name: options.name, - preview: options.preview, - verbose: options.verbose, - }), - ); + const finalConfig = writeCacheabilityManifestArtifact(root, options.config, probe.manifest); + const finalUpload = runWranglerVersionUpload(root, { + config: finalConfig, + env: options.env, + name: options.name, + preview: options.preview, + verbose: options.verbose, + }); assertDeploymentStateUnchanged( root, options, diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 58d2c07ba..7ff6cd671 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -8,7 +8,7 @@ import { } from "../packages/vinext/src/server/app-rsc-cache-busting.js"; import { VINEXT_CDN_BUILD_ID_HEADER } from "../packages/cloudflare/src/cache/cdn-build-id.js"; import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "../packages/cloudflare/src/version-headers.js"; -import { withCacheabilityManifestArtifact } from "../packages/cloudflare/src/cacheability-artifact.js"; +import { writeCacheabilityManifestArtifact } from "../packages/cloudflare/src/cacheability-artifact.js"; import { MAX_CACHEABILITY_MANIFEST_ROUTES } from "../packages/cloudflare/src/cacheability-manifest-limits.js"; import { CACHEABILITY_MANIFEST_MODULE } from "../packages/vinext/src/server/cacheability-manifest.js"; import { VINEXT_CACHEABILITY_PROBE_HEADER } from "../packages/vinext/src/server/headers.js"; @@ -288,12 +288,11 @@ describe("Cloudflare CDN warmup deploy flow", () => { writeFile("dist/server/index.js", "export default { fetch() {} };\n"); expect(() => - withCacheabilityManifestArtifact( - tmpDir, - "dist/server/wrangler.json", - { buildId: "build-a", routes: {}, version: 1 }, - () => undefined, - ), + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", { + buildId: "build-a", + routes: {}, + version: 1, + }), ).toThrow(`Worker main module to import ${CACHEABILITY_MANIFEST_MODULE}`); }); @@ -321,12 +320,11 @@ describe("Cloudflare CDN warmup deploy flow", () => { writeFile("dist/server/index.js", mainSource); expect(() => - withCacheabilityManifestArtifact( - tmpDir, - "dist/server/wrangler.json", - { buildId: "build-a", routes: {}, version: 1 }, - () => undefined, - ), + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", { + buildId: "build-a", + routes: {}, + version: 1, + }), ).toThrow(`Worker main module to import ${CACHEABILITY_MANIFEST_MODULE}`); }); @@ -343,14 +341,16 @@ describe("Cloudflare CDN warmup deploy flow", () => { writeTwoStageWorkerArtifact(); writeFile("dist/server/index.js", mainSource); - expect(() => - withCacheabilityManifestArtifact( - tmpDir, - "dist/server/wrangler.json", - { buildId: "build-a", routes: {}, version: 1 }, - () => undefined, - ), - ).not.toThrow(); + expect( + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", { + buildId: "build-a", + routes: {}, + version: 1, + }), + ).toBe("dist/server/wrangler.json"); + expect( + fs.readFileSync(path.join(tmpDir, "dist/server", CACHEABILITY_MANIFEST_MODULE), "utf8"), + ).toBe('export default "{\\"buildId\\":\\"build-a\\",\\"routes\\":{},\\"version\\":1}";\n'); }); it("rejects a manifest with more route patterns than the deployment bound", () => { @@ -371,12 +371,11 @@ describe("Cloudflare CDN warmup deploy flow", () => { ); expect(() => - withCacheabilityManifestArtifact( - tmpDir, - "dist/server/wrangler.json", - { buildId: "build-a", routes, version: 1 }, - () => undefined, - ), + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", { + buildId: "build-a", + routes, + version: 1, + }), ).toThrow(`the limit is ${MAX_CACHEABILITY_MANIFEST_ROUTES}`); }); @@ -654,7 +653,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { ); expect( fs.readFileSync(path.join(tmpDir, "dist/server", CACHEABILITY_MANIFEST_MODULE), "utf8"), - ).toBe("export default null;\n"); + ).toBe(finalManifestSource); }); it("promotes a final Worker with an empty manifest when discovery finds no identities", async () => { @@ -1210,6 +1209,9 @@ describe("Cloudflare CDN warmup deploy flow", () => { ).rejects.toThrow("final upload failed"); expect(wrangler.uploads).toBe(2); expect(wrangler.triggerDeploys).toBe(0); + expect( + fs.readFileSync(path.join(tmpDir, "dist/server", CACHEABILITY_MANIFEST_MODULE), "utf8"), + ).not.toBe("export default null;\n"); }); it.each([ From b2d5c92e15ef3d6c6467d0b106d803157d159a5d Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 16:35:13 +0100 Subject: [PATCH 20/24] fix(cloudflare): probe staged readiness without rendering --- packages/cloudflare/src/cacheability-probe.ts | 2 +- packages/cloudflare/src/cdn-warm.ts | 28 ++++++++++----- packages/cloudflare/src/deploy-help.ts | 2 +- packages/cloudflare/src/deploy.ts | 9 +++++ .../vinext/src/server/app-router-entry.ts | 9 ++++- packages/vinext/src/server/headers.ts | 3 ++ .../vinext/src/server/pages-router-entry.ts | 5 +++ .../src/server/worker-prerender-discovery.ts | 26 ++++++++++++++ tests/app-router-worker-entry.test.ts | 14 ++++++++ tests/cloudflare-cdn-warm-deploy.test.ts | 6 ++++ tests/cloudflare-cdn-warm.test.ts | 35 +++++++++++++++++-- tests/worker-prerender-discovery.test.ts | 35 ++++++++++++++++++- 12 files changed, 159 insertions(+), 15 deletions(-) diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index b2969290a..d94549286 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -127,7 +127,7 @@ function isProbeRouteState(value: unknown): value is ProbeRouteState { return value === "static-candidate" || value === "dynamic" || value === "probe-failed"; } -function readPrerenderSecret(root: string): string { +export function readPrerenderSecret(root: string): string { const manifestPath = path.join(root, "dist", "server", "vinext-server.json"); const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as unknown; const secret = diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 42dfc303b..3df5052fd 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -24,6 +24,10 @@ import { } from "vinext/internal/server/app-rsc-cache-busting"; import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache"; import { normalizePathTrailingSlash } from "vinext/shims/url-utils"; +import { + VINEXT_PRERENDER_READINESS_PATH, + VINEXT_PRERENDER_SECRET_HEADER, +} from "vinext/internal/server/headers"; import { VINEXT_CDN_BUILD_ID_HEADER } from "./cache/cdn-build-id.js"; export type CdnWarmOptions = { @@ -845,6 +849,7 @@ export async function waitForCdnWarmTargetReadiness( plan: CdnWarmRequestPlan; maxAttempts?: number; phaseTimeoutMs?: number; + prerenderSecret?: string; probeIntervalMs?: number; requiredConsecutiveSuccesses?: number; }, @@ -864,8 +869,17 @@ export async function waitForCdnWarmTargetReadiness( } const headers = new Headers(options.headers); - const probePath = kind === "rsc" ? createCanonicalRscRequestUrl(pathname) : pathname; - if (kind === "rsc") { + const readinessSecret = options.prerenderSecret; + const useReadinessEndpoint = Boolean(readinessSecret); + const probePath = useReadinessEndpoint + ? VINEXT_PRERENDER_READINESS_PATH + : kind === "rsc" + ? createCanonicalRscRequestUrl(pathname) + : pathname; + if (readinessSecret) { + headers.set("Accept", "text/html"); + headers.set(VINEXT_PRERENDER_SECRET_HEADER, readinessSecret); + } else if (kind === "rsc") { for (const [name, value] of createCanonicalRscRequestHeaders(options.deploymentId)) { headers.set(name, value); } @@ -894,13 +908,9 @@ export async function waitForCdnWarmTargetReadiness( requiredConsecutiveSuccesses, options.maxAttempts ?? requiredConsecutiveSuccesses + readinessRetries, ); - // The default phase envelope must be large enough to honor the configured - // retry budget even when every attempt consumes its request timeout. An - // explicit phase deadline remains authoritative. - const retryBudgetMs = maxAttempts * timeoutMs + Math.max(0, maxAttempts - 1) * probeIntervalMs; const phaseTimeoutMs = Math.max( 1, - options.phaseTimeoutMs ?? Math.max(DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS, retryBudgetMs), + options.phaseTimeoutMs ?? DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS, ); const deadlineAt = Date.now() + phaseTimeoutMs; const probeId = randomUUID(); @@ -925,9 +935,9 @@ export async function waitForCdnWarmTargetReadiness( ); const validationError = validateReadinessResponse( response, - kind, + useReadinessEndpoint ? "html" : kind, options.expectedBuildId, - options.expectedRscBuildId, + useReadinessEndpoint ? undefined : options.expectedRscBuildId, ); if (process.env.VINEXT_CDN_WARM_DEBUG === "1") { console.log( diff --git a/packages/cloudflare/src/deploy-help.ts b/packages/cloudflare/src/deploy-help.ts index cb705cf55..356180b02 100644 --- a/packages/cloudflare/src/deploy-help.ts +++ b/packages/cloudflare/src/deploy-help.ts @@ -45,7 +45,7 @@ export function formatDeployHelp(): string { entry to be reusable before promotion --warm-cdn-readiness-timeout Explicit total staged-readiness deadline (default: - enough for all retries, minimum 120000) + 120000) --warm-cdn-readiness-retries Staged-readiness retries (default: 60) --warm-cdn-readiness-probes diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 15497bee3..f246f3a89 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -91,6 +91,7 @@ import { DEFAULT_CACHEABILITY_PROBE_RETRIES, DEFAULT_CACHEABILITY_PROBE_RETRY_DELAY_MS, probeStagedWorkerCacheability, + readPrerenderSecret, } from "./cacheability-probe.js"; export const DEFAULT_CDN_WARM_PROMOTION_DELAY_MS = 15_000; @@ -759,6 +760,7 @@ type CdnWarmDeployOptions = Pick< type PreparedCdnWarmDeployOptions = CdnWarmDeployOptions & { expectedDeploymentState?: WranglerDeploymentStatus; optionalWarmTargetKeys?: ReadonlySet; + prerenderSecret?: string; triggersAlreadyApplied?: boolean; triggersDeployedUrl?: string | null; uploadedVersion?: WranglerVersionUploadResult; @@ -1035,6 +1037,7 @@ async function deployUploadedVersionWithCdnWarmup( targetUrl, headers, plan: stagedWarmPlan, + prerenderSecret: options.prerenderSecret, deploymentId, expectedBuildId, expectedRscBuildId, @@ -1379,6 +1382,8 @@ async function deployWithCacheabilityProbe( ); } + const prerenderSecret = readPrerenderSecret(root); + const probeUpload = runWranglerVersionUpload(root, options); const initialDeployment = runWranglerDeploymentStatus(root, options); const probeTraffic = getZeroPercentStagingTraffic(initialDeployment, probeUpload.versionId); @@ -1421,6 +1426,7 @@ async function deployWithCacheabilityProbe( | { optionalWarmTargetKeys: ReadonlySet; plan: PrerenderWarmPlan; + prerenderSecret: string; upload: WranglerVersionUploadResult; } | undefined; @@ -1510,6 +1516,7 @@ async function deployWithCacheabilityProbe( targetUrl, headers, plan, + prerenderSecret, deploymentId: plan.deploymentId, expectedBuildId: plan.buildIdentity, expectedRscBuildId: plan.rscBuildId, @@ -1616,6 +1623,7 @@ async function deployWithCacheabilityProbe( ); prepared = { optionalWarmTargetKeys: new Set(probe.speculativeTargets.map(cdnWarmTargetKey)), + prerenderSecret, plan: finalPlan, upload: finalUpload, }; @@ -1632,6 +1640,7 @@ async function deployWithCacheabilityProbe( loadingShellPaths: prepared.plan.loadingShellPaths, optionalWarmTargetKeys: prepared.optionalWarmTargetKeys, pagesDataPaths: prepared.plan.pagesDataPaths, + prerenderSecret: prepared.prerenderSecret, routeHandlerPaths: prepared.plan.routeHandlerPaths, routePatterns: prepared.plan.routePatterns, rscPaths: prepared.plan.rscPaths, diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts index 6f3be41a8..5db9a1a69 100644 --- a/packages/vinext/src/server/app-router-entry.ts +++ b/packages/vinext/src/server/app-router-entry.ts @@ -73,7 +73,10 @@ import { } from "./http-error-responses.js"; import { assetPrefixPathname, isNextStaticPath } from "../utils/asset-prefix.js"; import { createWorkerRevalidationContext } from "./worker-revalidation-context.js"; -import { createWorkerPrerenderDiscoveryContext } from "./worker-prerender-discovery.js"; +import { + createWorkerPrerenderDiscoveryContext, + createWorkerPrerenderReadinessResponse, +} from "./worker-prerender-discovery.js"; // Precompute the path components used for `_next/static/*` 404 short-circuit // detection. Both `__basePath` and `__assetPrefix` are inlined as @@ -125,6 +128,10 @@ async function handleRequest( registerConfiguredCacheAdapters(env as Record | undefined); const cdnCacheAdapter = getCdnCacheAdapter(); let ctx = createWorkerPrerenderDiscoveryContext(requestCtx, request, __rscPrerenderSecret); + const readinessResponse = createWorkerPrerenderReadinessResponse(ctx, request); + if (readinessResponse) { + return (await validateCdnRequest(request)) ?? readinessResponse; + } let finalizeCacheabilityResponse: | ((response: Response, ctx: ExecutionContextLike) => Promise) | undefined; diff --git a/packages/vinext/src/server/headers.ts b/packages/vinext/src/server/headers.ts index fa09e0883..5c4f2733a 100644 --- a/packages/vinext/src/server/headers.ts +++ b/packages/vinext/src/server/headers.ts @@ -63,6 +63,9 @@ export const VINEXT_PRERENDER_PAGES_STATIC_PATHS_PATH = "/__vinext/prerender/pag /** Internal endpoint used to enumerate cached dynamic metadata route paths. */ export const VINEXT_PRERENDER_METADATA_ROUTES_PATH = "/__vinext/prerender/metadata-routes"; +/** Internal endpoint used to verify staged Worker version routing without rendering a route. */ +export const VINEXT_PRERENDER_READINESS_PATH = "/__vinext/prerender/readiness"; + /** TPR (Tailored Per-Request) revalidation interval in seconds. */ export const VINEXT_REVALIDATE_HEADER = "x-vinext-revalidate"; diff --git a/packages/vinext/src/server/pages-router-entry.ts b/packages/vinext/src/server/pages-router-entry.ts index 1ff6119db..807bd360a 100644 --- a/packages/vinext/src/server/pages-router-entry.ts +++ b/packages/vinext/src/server/pages-router-entry.ts @@ -48,6 +48,7 @@ import { getCdnCacheAdapter } from "vinext/shims/cdn-cache"; import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; import { createWorkerPrerenderDiscoveryContext, + createWorkerPrerenderReadinessResponse, isWorkerPrerenderDiscoveryPath, } from "./worker-prerender-discovery.js"; @@ -133,6 +134,10 @@ async function handleRequest( registerConfiguredCacheAdapters(env); const cdnCacheAdapter = getCdnCacheAdapter(); let ctx = createWorkerPrerenderDiscoveryContext(requestCtx, request, pagesEntry.prerenderSecret); + const readinessResponse = createWorkerPrerenderReadinessResponse(ctx, request); + if (readinessResponse) { + return (await validateCdnRequest(request)) ?? readinessResponse; + } let finalizeCacheabilityResponse: | ((response: Response, ctx: ExecutionContextLike) => Promise) | undefined; diff --git a/packages/vinext/src/server/worker-prerender-discovery.ts b/packages/vinext/src/server/worker-prerender-discovery.ts index ec115ac60..0a08e5dbd 100644 --- a/packages/vinext/src/server/worker-prerender-discovery.ts +++ b/packages/vinext/src/server/worker-prerender-discovery.ts @@ -1,7 +1,9 @@ import type { ExecutionContextLike } from "vinext/shims/request-context"; import { + VINEXT_EXPECTED_WORKER_VERSION_HEADER, VINEXT_PRERENDER_METADATA_ROUTES_PATH, VINEXT_PRERENDER_PAGES_STATIC_PATHS_PATH, + VINEXT_PRERENDER_READINESS_PATH, VINEXT_PRERENDER_SECRET_HEADER, VINEXT_PRERENDER_STATIC_PARAMS_PATH, } from "./headers.js"; @@ -10,6 +12,7 @@ const PRERENDER_DISCOVERY_PATHS = new Set([ VINEXT_PRERENDER_STATIC_PARAMS_PATH, VINEXT_PRERENDER_PAGES_STATIC_PATHS_PATH, VINEXT_PRERENDER_METADATA_ROUTES_PATH, + VINEXT_PRERENDER_READINESS_PATH, ]); export function isWorkerPrerenderDiscoveryPath(pathname: string): boolean { @@ -27,6 +30,29 @@ export function workerCapabilityMatches(provided: string, expected: string): boo return mismatch === 0; } +/** + * Respond to an authenticated staged-version readiness request without + * invoking middleware, routing, or rendering. The CDN adapter validates the + * expected Worker version before callers return this response. + */ +export function createWorkerPrerenderReadinessResponse( + ctx: ExecutionContextLike, + request: Request, +): Response | null { + if ( + ctx.isPrerenderPathDiscovery !== true || + request.method !== "GET" || + !request.headers.has(VINEXT_EXPECTED_WORKER_VERSION_HEADER) || + new URL(request.url).pathname !== VINEXT_PRERENDER_READINESS_PATH + ) { + return null; + } + return new Response(null, { + status: 204, + headers: { "Cache-Control": "no-store" }, + }); +} + /** * Mark only a request carrying the current build's capability as an internal * path-discovery request. The capability is compiled into the Worker entry and diff --git a/tests/app-router-worker-entry.test.ts b/tests/app-router-worker-entry.test.ts index 4cfc98fb5..952c0f238 100644 --- a/tests/app-router-worker-entry.test.ts +++ b/tests/app-router-worker-entry.test.ts @@ -104,7 +104,21 @@ describe("App Router Production server worker entry compatibility", () => { trustedRevalidateOrigin: "http://127.0.0.1:3000", }); + const readiness = await entry.default.fetch( + new Request("https://example.com/__vinext/prerender/readiness", { + headers: { + accept: "text/html", + "x-vinext-expected-worker-version": "version-a", + "x-vinext-prerender-secret": "worker-prerender-secret", + }, + }), + undefined, + { waitUntil() {} }, + ); + expect(capturedRequests).toHaveLength(2); + expect(readiness.status).toBe(204); + expect(readiness.headers.get("cache-control")).toBe("no-store"); expect(capturedRequests[0].headers.get("x-vinext-prerender-secret")).toBeNull(); expect(capturedRequests[0].headers.get("x-vinext-prerender-route-params")).toBeNull(); expect(capturedRequests[1].headers.get("x-vinext-prerender-secret")).toBeNull(); diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 7ff6cd671..1556bb38f 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -492,6 +492,12 @@ describe("Cloudflare CDN warmup deploy flow", () => { } const pathname = new URL(formatFetchUrl(input)).pathname; const isRsc = headers.get("RSC") === "1"; + if (isReadinessFetch(input)) { + expect(pathname).toBe("/__vinext/prerender/readiness"); + expect(headers.get("accept")).toBe("text/html"); + expect(headers.get("rsc")).toBeNull(); + expect(headers.get("x-vinext-prerender-secret")).toBe("test-prerender-secret"); + } const cacheKey = `${pathname}${isRsc ? "?_rsc" : ""}`; const cacheStatus = (cacheRequestCounts.get(cacheKey) ?? 0) > 1 ? "HIT" : "MISS"; if (pathname === "/about" && isRsc) { diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index 198d431d7..78888e8a0 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -815,6 +815,37 @@ describe("Cloudflare CDN warmup", () => { ).resolves.toEqual({ ready: true }); }); + it("uses the authenticated readiness endpoint instead of rendering an application route", async () => { + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : input); + const headers = new Headers(init?.headers); + expect(url.pathname).toBe("/__vinext/prerender/readiness"); + expect(url.searchParams.has("__vinext_cdn_warm_readiness")).toBe(true); + expect(headers.get("accept")).toBe("text/html"); + expect(headers.get("rsc")).toBeNull(); + expect(headers.get("x-vinext-prerender-secret")).toBe("build-secret"); + return new Response(null, { + status: 204, + headers: { [VINEXT_CDN_BUILD_ID_HEADER]: "build-a" }, + }); + }); + + await expect( + waitForCdnWarmTargetReadiness({ + expectedBuildId: "build-a", + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + maxAttempts: 1, + plan: { loadingShellPaths: [], pagesDataPaths: [], paths: [], rscPaths: ["/slow"] }, + prerenderSecret: "build-secret", + probeIntervalMs: 0, + requiredConsecutiveSuccesses: 1, + targetUrl: "https://app.example.com", + }), + ).resolves.toEqual({ ready: true }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + it("uses a Pages data identity when it is the only staged readiness target", async () => { const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { expect(new Headers(init?.headers).get("accept")).toBe("application/json"); @@ -899,7 +930,7 @@ describe("Cloudflare CDN warmup", () => { expect(fetchImpl.mock.calls.length).toBeLessThan(100); }); - it("lets the default phase envelope consume the configured retry budget", async () => { + it("keeps failed readiness bounded by the default phase deadline", async () => { let now = 0; const dateNow = vi.spyOn(Date, "now").mockImplementation(() => now); const fetchImpl = vi.fn(async () => { @@ -920,7 +951,7 @@ describe("Cloudflare CDN warmup", () => { }); expect(readiness.ready).toBe(false); - expect(fetchImpl).toHaveBeenCalledTimes(66); + expect(fetchImpl).toHaveBeenCalledTimes(12); } finally { dateNow.mockRestore(); } diff --git a/tests/worker-prerender-discovery.test.ts b/tests/worker-prerender-discovery.test.ts index 8e63387f5..64153af5b 100644 --- a/tests/worker-prerender-discovery.test.ts +++ b/tests/worker-prerender-discovery.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { createWorkerPrerenderDiscoveryContext } from "../packages/vinext/src/server/worker-prerender-discovery.js"; +import { + createWorkerPrerenderDiscoveryContext, + createWorkerPrerenderReadinessResponse, +} from "../packages/vinext/src/server/worker-prerender-discovery.js"; describe("Worker prerender path discovery authorization", () => { const base = { @@ -29,9 +32,39 @@ describe("Worker prerender path discovery authorization", () => { }), "build-secret", ); + const readiness = createWorkerPrerenderDiscoveryContext( + base, + new Request("https://example.com/__vinext/prerender/readiness", { + headers: { "x-vinext-prerender-secret": "build-secret" }, + }), + "build-secret", + ); expect(authorized.isPrerenderPathDiscovery).toBe(true); + expect(readiness.isPrerenderPathDiscovery).toBe(true); expect(wrongSecret).toBe(base); expect(ordinaryPath).toBe(base); }); + + it("answers readiness only after capability and expected-version validation", () => { + const request = new Request("https://example.com/__vinext/prerender/readiness", { + headers: { + "x-vinext-expected-worker-version": "version-a", + "x-vinext-prerender-secret": "build-secret", + }, + }); + const authorized = createWorkerPrerenderDiscoveryContext(base, request, "build-secret"); + const response = createWorkerPrerenderReadinessResponse(authorized, request); + + expect(response?.status).toBe(204); + expect(response?.headers.get("cache-control")).toBe("no-store"); + expect( + createWorkerPrerenderReadinessResponse( + base, + new Request("https://example.com/__vinext/prerender/readiness", { + headers: { "x-vinext-prerender-secret": "build-secret" }, + }), + ), + ).toBeNull(); + }); }); From 5036225e982c61faf3d11ce4ce19315984250e86 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 16:41:06 +0100 Subject: [PATCH 21/24] fix(cloudflare): verify staged readiness response --- packages/cloudflare/src/cdn-warm.ts | 33 +++++++-- packages/vinext/src/server/headers.ts | 3 + .../src/server/worker-prerender-discovery.ts | 6 +- tests/app-router-worker-entry.test.ts | 1 + tests/cloudflare-cdn-warm-deploy.test.ts | 47 ++++++++---- tests/cloudflare-cdn-warm.test.ts | 56 +++++++++++++- tests/pages-router-worker-entry.test.ts | 74 +++++++++++++++++++ tests/worker-prerender-discovery.test.ts | 1 + 8 files changed, 197 insertions(+), 24 deletions(-) create mode 100644 tests/pages-router-worker-entry.test.ts diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 3df5052fd..1a61ac861 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -25,6 +25,7 @@ import { import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache"; import { normalizePathTrailingSlash } from "vinext/shims/url-utils"; import { + VINEXT_PRERENDER_READINESS_HEADER, VINEXT_PRERENDER_READINESS_PATH, VINEXT_PRERENDER_SECRET_HEADER, } from "vinext/internal/server/headers"; @@ -829,6 +830,24 @@ function validateReadinessResponse( return null; } +function validatePrerenderReadinessResponse( + response: Response, + expectedBuildId?: string, +): string | null { + const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId); + if (buildIdentityValidation?.outcome === "failed") return buildIdentityValidation.error; + if (response.redirected) return "redirected response"; + if (response.status !== 204) return `expected readiness HTTP 204, received ${response.status}`; + if (response.headers.get(VINEXT_PRERENDER_READINESS_HEADER) !== "1") { + return `response is missing ${VINEXT_PRERENDER_READINESS_HEADER}: 1`; + } + const cacheControl = response.headers.get("Cache-Control"); + if (!cacheControl || !/(?:^|,)\s*no-store\s*(?:,|$)/i.test(cacheControl)) { + return "readiness response is missing Cache-Control: no-store"; + } + return null; +} + /** * Wait until version-override requests consistently reach the uploaded build * before any real cache key is filled. Every probe has a unique query key, so @@ -933,12 +952,14 @@ export async function waitForCdnWarmTargetReadiness( Math.min(timeoutMs, remainingMs), headers, ); - const validationError = validateReadinessResponse( - response, - useReadinessEndpoint ? "html" : kind, - options.expectedBuildId, - useReadinessEndpoint ? undefined : options.expectedRscBuildId, - ); + const validationError = useReadinessEndpoint + ? validatePrerenderReadinessResponse(response, options.expectedBuildId) + : validateReadinessResponse( + response, + kind, + options.expectedBuildId, + options.expectedRscBuildId, + ); if (process.env.VINEXT_CDN_WARM_DEBUG === "1") { console.log( ` CDN warm readiness attempt ${attempt + 1}: ` + diff --git a/packages/vinext/src/server/headers.ts b/packages/vinext/src/server/headers.ts index 5c4f2733a..7d1237006 100644 --- a/packages/vinext/src/server/headers.ts +++ b/packages/vinext/src/server/headers.ts @@ -66,6 +66,9 @@ export const VINEXT_PRERENDER_METADATA_ROUTES_PATH = "/__vinext/prerender/metada /** Internal endpoint used to verify staged Worker version routing without rendering a route. */ export const VINEXT_PRERENDER_READINESS_PATH = "/__vinext/prerender/readiness"; +/** Response marker proving the staged Worker readiness short-circuit handled the request. */ +export const VINEXT_PRERENDER_READINESS_HEADER = "X-Vinext-Prerender-Readiness"; + /** TPR (Tailored Per-Request) revalidation interval in seconds. */ export const VINEXT_REVALIDATE_HEADER = "x-vinext-revalidate"; diff --git a/packages/vinext/src/server/worker-prerender-discovery.ts b/packages/vinext/src/server/worker-prerender-discovery.ts index 0a08e5dbd..e331b64a3 100644 --- a/packages/vinext/src/server/worker-prerender-discovery.ts +++ b/packages/vinext/src/server/worker-prerender-discovery.ts @@ -3,6 +3,7 @@ import { VINEXT_EXPECTED_WORKER_VERSION_HEADER, VINEXT_PRERENDER_METADATA_ROUTES_PATH, VINEXT_PRERENDER_PAGES_STATIC_PATHS_PATH, + VINEXT_PRERENDER_READINESS_HEADER, VINEXT_PRERENDER_READINESS_PATH, VINEXT_PRERENDER_SECRET_HEADER, VINEXT_PRERENDER_STATIC_PARAMS_PATH, @@ -49,7 +50,10 @@ export function createWorkerPrerenderReadinessResponse( } return new Response(null, { status: 204, - headers: { "Cache-Control": "no-store" }, + headers: { + "Cache-Control": "no-store", + [VINEXT_PRERENDER_READINESS_HEADER]: "1", + }, }); } diff --git a/tests/app-router-worker-entry.test.ts b/tests/app-router-worker-entry.test.ts index 952c0f238..61af13863 100644 --- a/tests/app-router-worker-entry.test.ts +++ b/tests/app-router-worker-entry.test.ts @@ -119,6 +119,7 @@ describe("App Router Production server worker entry compatibility", () => { expect(capturedRequests).toHaveLength(2); expect(readiness.status).toBe(204); expect(readiness.headers.get("cache-control")).toBe("no-store"); + expect(readiness.headers.get("x-vinext-prerender-readiness")).toBe("1"); expect(capturedRequests[0].headers.get("x-vinext-prerender-secret")).toBeNull(); expect(capturedRequests[0].headers.get("x-vinext-prerender-route-params")).toBeNull(); expect(capturedRequests[1].headers.get("x-vinext-prerender-secret")).toBeNull(); diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 1556bb38f..e6cb02039 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -11,7 +11,10 @@ import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "../packages/cloudflare/sr import { writeCacheabilityManifestArtifact } from "../packages/cloudflare/src/cacheability-artifact.js"; import { MAX_CACHEABILITY_MANIFEST_ROUTES } from "../packages/cloudflare/src/cacheability-manifest-limits.js"; import { CACHEABILITY_MANIFEST_MODULE } from "../packages/vinext/src/server/cacheability-manifest.js"; -import { VINEXT_CACHEABILITY_PROBE_HEADER } from "../packages/vinext/src/server/headers.js"; +import { + VINEXT_CACHEABILITY_PROBE_HEADER, + VINEXT_PRERENDER_READINESS_HEADER, +} from "../packages/vinext/src/server/headers.js"; const execFileSyncMock = vi.hoisted(() => vi.fn()); const delayMock = vi.hoisted(() => vi.fn()); @@ -64,6 +67,17 @@ function cacheableHtml(body = "ok", cacheStatus = "MISS"): Response { }); } +function readinessResponse(buildId = "app-build-a"): Response { + return new Response(null, { + status: 204, + headers: { + "cache-control": "no-store", + [VINEXT_CDN_BUILD_ID_HEADER]: buildId, + [VINEXT_PRERENDER_READINESS_HEADER]: "1", + }, + }); +} + function cacheablePagesData(cacheStatus = "MISS"): Response { return new Response('{"pageProps":{}}', { headers: { @@ -497,6 +511,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(headers.get("accept")).toBe("text/html"); expect(headers.get("rsc")).toBeNull(); expect(headers.get("x-vinext-prerender-secret")).toBe("test-prerender-secret"); + return readinessResponse(); } const cacheKey = `${pathname}${isRsc ? "?_rsc" : ""}`; const cacheStatus = (cacheRequestCounts.get(cacheKey) ?? 0) > 1 ? "HIT" : "MISS"; @@ -762,7 +777,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { { headers: { [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a" } }, ) : isReadinessFetch(input) - ? cacheableHtml() + ? readinessResponse() : new Response("unexpected", { status: 500 }), ); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -813,7 +828,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { ); } return isReadinessFetch(input) - ? cacheableHtml() + ? readinessResponse() : new Response("unexpected warm request", { status: 500 }); }); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -862,7 +877,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { if (new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1") { return pagesPageProbeResponse(); } - if (isReadinessFetch(input)) return cacheableHtml(); + if (isReadinessFetch(input)) return readinessResponse(); return new URL(formatFetchUrl(input)).pathname.startsWith("/_next/data/") ? cacheablePagesData() : cacheableHtml(); @@ -990,7 +1005,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { if (new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1") { return appPageProbeResponse(); } - if (isReadinessFetch(input)) return cacheableHtml(); + if (isReadinessFetch(input)) return readinessResponse(); return cacheableHtml(); }); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -1031,7 +1046,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { if (new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1") { return appPageProbeResponse(); } - if (isReadinessFetch(input)) return cacheableHtml(); + if (isReadinessFetch(input)) return readinessResponse(); return new Response("failed fill", { status: 500, headers: { @@ -1112,7 +1127,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { if (new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1") { return appPageProbeResponse(); } - if (isReadinessFetch(input)) return cacheableHtml(); + if (isReadinessFetch(input)) return readinessResponse(); cacheRequestCount++; return cacheableHtml("ok", cacheRequestCount === 1 ? "MISS" : "HIT"); }); @@ -1155,7 +1170,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1" ? appPageProbeResponse("probe-failed") : isReadinessFetch(input) - ? cacheableHtml() + ? readinessResponse() : new Response("unexpected", { status: 500 }), ); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -1190,7 +1205,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1" ? appPageProbeResponse() : isReadinessFetch(input) - ? cacheableHtml() + ? readinessResponse() : new Response("unexpected", { status: 500 }), ); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -1232,7 +1247,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { if (new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1") { return appPageProbeResponse(); } - if (isReadinessFetch(input)) return cacheableHtml(); + if (isReadinessFetch(input)) return readinessResponse(); return new Response("private", { headers: { "cache-control": "no-store", @@ -1283,7 +1298,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { if (new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1") { return appPageProbeResponse(); } - if (isReadinessFetch(input)) return cacheableHtml(); + if (isReadinessFetch(input)) return readinessResponse(); fillCalls++; now += 120_001; return cacheableHtml(); @@ -1351,7 +1366,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { }); } return isReadinessFetch(input) - ? cacheableHtml() + ? readinessResponse() : new Response("unexpected", { status: 500 }); }); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -1418,7 +1433,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1" ? appPageProbeResponse() : isReadinessFetch(input) - ? cacheableHtml() + ? readinessResponse() : new Response("unexpected", { status: 500 }), ); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -1481,7 +1496,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1" ? appPageProbeResponse() : isReadinessFetch(input) - ? cacheableHtml() + ? readinessResponse() : new Response("unexpected", { status: 500 }), ); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -1549,7 +1564,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1" ? appPageProbeResponse() : isReadinessFetch(input) - ? cacheableHtml() + ? readinessResponse() : new Response("unexpected", { status: 500 }), ); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -1617,7 +1632,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1" ? appPageProbeResponse() : isReadinessFetch(input) - ? cacheableHtml() + ? readinessResponse() : new Response("unexpected", { status: 500 }), ); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index 78888e8a0..ff6564a33 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -16,6 +16,7 @@ import { VINEXT_RSC_VARY_HEADER, } from "../packages/vinext/src/server/app-rsc-cache-busting.js"; import { VINEXT_CDN_BUILD_ID_HEADER } from "../packages/cloudflare/src/cache/cdn-build-id.js"; +import { VINEXT_PRERENDER_READINESS_HEADER } from "../packages/vinext/src/server/headers.js"; let tmpDir: string; @@ -826,7 +827,11 @@ describe("Cloudflare CDN warmup", () => { expect(headers.get("x-vinext-prerender-secret")).toBe("build-secret"); return new Response(null, { status: 204, - headers: { [VINEXT_CDN_BUILD_ID_HEADER]: "build-a" }, + headers: { + "cache-control": "no-store", + [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", + [VINEXT_PRERENDER_READINESS_HEADER]: "1", + }, }); }); @@ -846,6 +851,55 @@ describe("Cloudflare CDN warmup", () => { expect(fetchImpl).toHaveBeenCalledTimes(1); }); + it.each([ + { + label: "an application response", + response: new Response("fallback", { + headers: { [VINEXT_CDN_BUILD_ID_HEADER]: "build-a" }, + }), + }, + { + label: "a same-build 404", + response: new Response("not found", { + status: 404, + headers: { [VINEXT_CDN_BUILD_ID_HEADER]: "build-a" }, + }), + }, + { + label: "an unmarked 204", + response: new Response(null, { + status: 204, + headers: { + "cache-control": "no-store", + [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", + }, + }), + }, + { + label: "a cacheable marked 204", + response: new Response(null, { + status: 204, + headers: { + [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", + [VINEXT_PRERENDER_READINESS_HEADER]: "1", + }, + }), + }, + ])("does not accept $label from the dedicated readiness path", async ({ response }) => { + const readiness = await waitForCdnWarmTargetReadiness({ + expectedBuildId: "build-a", + fetchImpl: vi.fn(async () => response.clone()) as typeof fetch, + maxAttempts: 1, + plan: { loadingShellPaths: [], pagesDataPaths: [], paths: ["/slow"], rscPaths: [] }, + prerenderSecret: "build-secret", + probeIntervalMs: 0, + requiredConsecutiveSuccesses: 1, + targetUrl: "https://app.example.com", + }); + + expect(readiness.ready).toBe(false); + }); + it("uses a Pages data identity when it is the only staged readiness target", async () => { const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { expect(new Headers(init?.headers).get("accept")).toBe("application/json"); diff --git a/tests/pages-router-worker-entry.test.ts b/tests/pages-router-worker-entry.test.ts new file mode 100644 index 000000000..15ea75e1a --- /dev/null +++ b/tests/pages-router-worker-entry.test.ts @@ -0,0 +1,74 @@ +import path from "node:path"; +import { createServer, type Plugin } from "vite"; +import { describe, expect, it } from "vite-plus/test"; + +function pagesWorkerEntryVirtualModules(): Plugin { + const modules = new Map([ + [ + "virtual:vinext-server-entry", + ` +export const prerenderSecret = "worker-prerender-secret"; +export const vinextConfig = {}; +`, + ], + ["virtual:vinext-cacheability-manifest", "export default null;"], + ["virtual:vinext-cache-adapters", "export function registerConfiguredCacheAdapters() {}"], + ["virtual:vinext-image-adapters", "export function registerConfiguredImageOptimizer() {}"], + ]); + + return { + name: "pages-router-worker-entry-test-virtual-modules", + resolveId(id) { + return modules.has(id) ? `\0${id}` : null; + }, + load(id) { + return id.startsWith("\0") ? (modules.get(id.slice(1)) ?? null) : null; + }, + }; +} + +describe("Pages Router production Worker readiness", () => { + it("answers authenticated staged readiness before routing or rendering", async () => { + // No Next.js test port applies: this is a vinext Cloudflare deployment endpoint. + const server = await createServer({ + appType: "custom", + configFile: false, + logLevel: "silent", + plugins: [pagesWorkerEntryVirtualModules()], + resolve: { + alias: { + "vinext/shims": path.resolve(import.meta.dirname, "../packages/vinext/src/shims"), + }, + }, + server: { middlewareMode: true }, + }); + + try { + const entry = (await server.ssrLoadModule( + path.resolve(import.meta.dirname, "../packages/vinext/src/server/pages-router-entry.ts"), + )) as { + default: { + fetch(request: Request, env?: unknown, ctx?: { waitUntil(): void }): Promise; + }; + }; + + const response = await entry.default.fetch( + new Request("https://example.com/__vinext/prerender/readiness", { + headers: { + accept: "text/html", + "x-vinext-expected-worker-version": "version-a", + "x-vinext-prerender-secret": "worker-prerender-secret", + }, + }), + undefined, + { waitUntil() {} }, + ); + + expect(response.status).toBe(204); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("x-vinext-prerender-readiness")).toBe("1"); + } finally { + await server.close(); + } + }); +}); diff --git a/tests/worker-prerender-discovery.test.ts b/tests/worker-prerender-discovery.test.ts index 64153af5b..2342cf125 100644 --- a/tests/worker-prerender-discovery.test.ts +++ b/tests/worker-prerender-discovery.test.ts @@ -58,6 +58,7 @@ describe("Worker prerender path discovery authorization", () => { expect(response?.status).toBe(204); expect(response?.headers.get("cache-control")).toBe("no-store"); + expect(response?.headers.get("x-vinext-prerender-readiness")).toBe("1"); expect( createWorkerPrerenderReadinessResponse( base, From 0bc7f635a620d16b9c9563913213fea0a80d141a Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 16:43:05 +0100 Subject: [PATCH 22/24] fix(cloudflare): reserve staged readiness endpoint --- .../src/server/worker-prerender-discovery.ts | 15 ++++++++++++--- tests/app-router-worker-entry.test.ts | 12 ++++++++++++ tests/pages-router-worker-entry.test.ts | 12 ++++++++++++ tests/worker-prerender-discovery.test.ts | 16 ++++++++-------- 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/server/worker-prerender-discovery.ts b/packages/vinext/src/server/worker-prerender-discovery.ts index e331b64a3..1ada7dba5 100644 --- a/packages/vinext/src/server/worker-prerender-discovery.ts +++ b/packages/vinext/src/server/worker-prerender-discovery.ts @@ -40,13 +40,22 @@ export function createWorkerPrerenderReadinessResponse( ctx: ExecutionContextLike, request: Request, ): Response | null { + // Keep the ordinary request path to one cheap string scan. Only parse the + // URL when it could be the reserved readiness endpoint. + if (!request.url.includes(VINEXT_PRERENDER_READINESS_PATH)) return null; + if (new URL(request.url).pathname !== VINEXT_PRERENDER_READINESS_PATH) return null; + if ( ctx.isPrerenderPathDiscovery !== true || request.method !== "GET" || - !request.headers.has(VINEXT_EXPECTED_WORKER_VERSION_HEADER) || - new URL(request.url).pathname !== VINEXT_PRERENDER_READINESS_PATH + !request.headers.has(VINEXT_EXPECTED_WORKER_VERSION_HEADER) ) { - return null; + // This namespace is framework-owned. Never let a failed capability check + // fall through to middleware or a user route that could spoof readiness. + return new Response(null, { + status: 404, + headers: { "Cache-Control": "no-store" }, + }); } return new Response(null, { status: 204, diff --git a/tests/app-router-worker-entry.test.ts b/tests/app-router-worker-entry.test.ts index 61af13863..30f2c5f24 100644 --- a/tests/app-router-worker-entry.test.ts +++ b/tests/app-router-worker-entry.test.ts @@ -115,11 +115,23 @@ describe("App Router Production server worker entry compatibility", () => { undefined, { waitUntil() {} }, ); + const unauthorizedReadiness = await entry.default.fetch( + new Request("https://example.com/__vinext/prerender/readiness", { + headers: { + "x-vinext-expected-worker-version": "version-a", + "x-vinext-prerender-secret": "wrong-secret", + }, + }), + undefined, + { waitUntil() {} }, + ); expect(capturedRequests).toHaveLength(2); expect(readiness.status).toBe(204); expect(readiness.headers.get("cache-control")).toBe("no-store"); expect(readiness.headers.get("x-vinext-prerender-readiness")).toBe("1"); + expect(unauthorizedReadiness.status).toBe(404); + expect(unauthorizedReadiness.headers.get("cache-control")).toBe("no-store"); expect(capturedRequests[0].headers.get("x-vinext-prerender-secret")).toBeNull(); expect(capturedRequests[0].headers.get("x-vinext-prerender-route-params")).toBeNull(); expect(capturedRequests[1].headers.get("x-vinext-prerender-secret")).toBeNull(); diff --git a/tests/pages-router-worker-entry.test.ts b/tests/pages-router-worker-entry.test.ts index 15ea75e1a..0efd622d5 100644 --- a/tests/pages-router-worker-entry.test.ts +++ b/tests/pages-router-worker-entry.test.ts @@ -63,10 +63,22 @@ describe("Pages Router production Worker readiness", () => { undefined, { waitUntil() {} }, ); + const unauthorizedResponse = await entry.default.fetch( + new Request("https://example.com/__vinext/prerender/readiness", { + headers: { + "x-vinext-expected-worker-version": "version-a", + "x-vinext-prerender-secret": "wrong-secret", + }, + }), + undefined, + { waitUntil() {} }, + ); expect(response.status).toBe(204); expect(response.headers.get("cache-control")).toBe("no-store"); expect(response.headers.get("x-vinext-prerender-readiness")).toBe("1"); + expect(unauthorizedResponse.status).toBe(404); + expect(unauthorizedResponse.headers.get("cache-control")).toBe("no-store"); } finally { await server.close(); } diff --git a/tests/worker-prerender-discovery.test.ts b/tests/worker-prerender-discovery.test.ts index 2342cf125..6c87138de 100644 --- a/tests/worker-prerender-discovery.test.ts +++ b/tests/worker-prerender-discovery.test.ts @@ -59,13 +59,13 @@ describe("Worker prerender path discovery authorization", () => { expect(response?.status).toBe(204); expect(response?.headers.get("cache-control")).toBe("no-store"); expect(response?.headers.get("x-vinext-prerender-readiness")).toBe("1"); - expect( - createWorkerPrerenderReadinessResponse( - base, - new Request("https://example.com/__vinext/prerender/readiness", { - headers: { "x-vinext-prerender-secret": "build-secret" }, - }), - ), - ).toBeNull(); + const unauthorized = createWorkerPrerenderReadinessResponse( + base, + new Request("https://example.com/__vinext/prerender/readiness", { + headers: { "x-vinext-prerender-secret": "build-secret" }, + }), + ); + expect(unauthorized?.status).toBe(404); + expect(unauthorized?.headers.get("cache-control")).toBe("no-store"); }); }); From ffa711f8eae7dc5cc87b9a60e54563567e148a65 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 1 Sep 2026 09:54:14 +0100 Subject: [PATCH 23/24] fix(cloudflare): relax cacheability manifest bounds --- .../cloudflare/src/cacheability-artifact.ts | 6 --- .../src/cacheability-manifest-limits.ts | 12 +----- packages/cloudflare/src/cacheability-probe.ts | 15 +------ tests/cloudflare-cacheability-probe.test.ts | 19 --------- tests/cloudflare-cdn-warm-deploy.test.ts | 41 +++++++++---------- 5 files changed, 22 insertions(+), 71 deletions(-) diff --git a/packages/cloudflare/src/cacheability-artifact.ts b/packages/cloudflare/src/cacheability-artifact.ts index ea978c5f0..746c20a12 100644 --- a/packages/cloudflare/src/cacheability-artifact.ts +++ b/packages/cloudflare/src/cacheability-artifact.ts @@ -7,9 +7,7 @@ import { } from "vinext/internal/server/cacheability-manifest"; import { cacheabilityManifestByteLimitError, - cacheabilityManifestRouteLimitError, MAX_CACHEABILITY_MANIFEST_BYTES, - MAX_CACHEABILITY_MANIFEST_ROUTES, } from "./cacheability-manifest-limits.js"; type JavaScriptToken = { @@ -212,10 +210,6 @@ export function writeCacheabilityManifestArtifact( `Two-stage CDN warming requires ${CACHEABILITY_MANIFEST_MODULE} in the generated Worker artifact. Rebuild the app before deploying.`, ); } - const routeCount = Object.keys(manifest.routes).length; - if (routeCount > MAX_CACHEABILITY_MANIFEST_ROUTES) { - throw cacheabilityManifestRouteLimitError(routeCount); - } const serializedManifest = JSON.stringify(manifest); const manifestBytes = Buffer.byteLength(serializedManifest); if (manifestBytes > MAX_CACHEABILITY_MANIFEST_BYTES) { diff --git a/packages/cloudflare/src/cacheability-manifest-limits.ts b/packages/cloudflare/src/cacheability-manifest-limits.ts index 89d8abc78..68ca9f17f 100644 --- a/packages/cloudflare/src/cacheability-manifest-limits.ts +++ b/packages/cloudflare/src/cacheability-manifest-limits.ts @@ -1,14 +1,4 @@ -export const MAX_CACHEABILITY_MANIFEST_BYTES = 1024 * 1024; -export const MAX_CACHEABILITY_MANIFEST_ROUTES = 10_000; - -export function cacheabilityManifestRouteLimitError( - routeCount: number, - limit = MAX_CACHEABILITY_MANIFEST_ROUTES, -): Error { - return new Error( - `Two-stage CDN warming produced ${routeCount} cacheable route patterns; the limit is ${limit}. Split the deployment before retrying.`, - ); -} +export const MAX_CACHEABILITY_MANIFEST_BYTES = 2 * 1024 * 1024; export function cacheabilityManifestByteLimitError( manifestBytes: number, diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index d94549286..250f040ac 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -21,9 +21,7 @@ import { VINEXT_CDN_BUILD_ID_HEADER } from "./cache/cdn-build-id.js"; import type { CdnWarmTarget } from "./cdn-warm.js"; import { cacheabilityManifestByteLimitError, - cacheabilityManifestRouteLimitError, MAX_CACHEABILITY_MANIFEST_BYTES, - MAX_CACHEABILITY_MANIFEST_ROUTES, } from "./cacheability-manifest-limits.js"; export const DEFAULT_CACHEABILITY_PROBE_PHASE_TIMEOUT_MS = 120_000; @@ -312,7 +310,7 @@ export async function probeStagedWorkerCacheability(options: { phaseTimeoutMs?: number; onProgress?: (progress: CacheabilityProbeProgress) => void; /** @internal Apply stricter artifact bounds for focused coordinator tests. */ - manifestLimits?: { maxBytes?: number; maxRoutes?: number }; + manifestLimits?: { maxBytes?: number }; }): Promise { const secret = readPrerenderSecret(options.root); const concurrency = Math.max(1, options.concurrency ?? 25); @@ -336,10 +334,6 @@ export async function probeStagedWorkerCacheability(options: { options.manifestLimits?.maxBytes ?? MAX_CACHEABILITY_MANIFEST_BYTES, MAX_CACHEABILITY_MANIFEST_BYTES, ); - const maxManifestRoutes = Math.min( - options.manifestLimits?.maxRoutes ?? MAX_CACHEABILITY_MANIFEST_ROUTES, - MAX_CACHEABILITY_MANIFEST_ROUTES, - ); const emptyManifest: CacheabilityManifest = { buildId: options.buildId, routes: {}, @@ -447,13 +441,6 @@ export async function probeStagedWorkerCacheability(options: { const addRouteWithinManifestLimits = (key: string, route: CacheabilityManifestRoute): boolean => { const previousBytes = routeEntryBytes.get(key); - const nextRouteCount = - previousBytes === undefined ? routeEntryBytes.size + 1 : routeEntryBytes.size; - if (nextRouteCount > maxManifestRoutes) { - limitFailure = cacheabilityManifestRouteLimitError(nextRouteCount, maxManifestRoutes); - return false; - } - // This is the exact UTF-8 contribution of the entry to JSON.stringify's // routes object. Counting entries incrementally avoids repeatedly // serializing an ever-growing manifest while preserving the artifact's diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index 9964b2015..f0381aa65 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -837,25 +837,6 @@ describe("staged Worker cacheability probes", () => { ]); }); - it("enforces the route bound after concrete-path classification", async () => { - const root = createProbeRoot(); - const fetchImpl = createStaticProbeFetch(); - - await expect( - probeStagedWorkerCacheability({ - buildId: "application-build", - concurrency: 1, - fetchImpl, - manifestLimits: { maxRoutes: 1 }, - retries: 0, - root, - targetUrl: "https://example.com", - targets: [target("/one"), target("/two"), target("/three")], - }), - ).rejects.toThrow("produced 2 cacheable route patterns; the limit is 1"); - expect(fetchImpl).toHaveBeenCalledTimes(3); - }); - it("uses the exact serialized-byte boundary and stops before later probes", async () => { const root = createProbeRoot(); const firstTarget = target("/one"); diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index e6cb02039..bcd54eb48 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { Buffer } from "node:buffer"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -9,8 +10,11 @@ import { import { VINEXT_CDN_BUILD_ID_HEADER } from "../packages/cloudflare/src/cache/cdn-build-id.js"; import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "../packages/cloudflare/src/version-headers.js"; import { writeCacheabilityManifestArtifact } from "../packages/cloudflare/src/cacheability-artifact.js"; -import { MAX_CACHEABILITY_MANIFEST_ROUTES } from "../packages/cloudflare/src/cacheability-manifest-limits.js"; -import { CACHEABILITY_MANIFEST_MODULE } from "../packages/vinext/src/server/cacheability-manifest.js"; +import { + CACHEABILITY_MANIFEST_MODULE, + cacheabilityManifestRouteKey, + type CacheabilityManifest, +} from "../packages/vinext/src/server/cacheability-manifest.js"; import { VINEXT_CACHEABILITY_PROBE_HEADER, VINEXT_PRERENDER_READINESS_HEADER, @@ -367,30 +371,25 @@ describe("Cloudflare CDN warmup deploy flow", () => { ).toBe('export default "{\\"buildId\\":\\"build-a\\",\\"routes\\":{},\\"version\\":1}";\n'); }); - it("rejects a manifest with more route patterns than the deployment bound", () => { + it("accepts a manifest over one MiB with more than 10,000 route patterns", () => { writeTwoStageWorkerArtifact(); - const route = { - kind: "app-page" as const, - pattern: "/page", - representation: "html" as const, - requestKey: "/page", - state: "static-candidate" as const, - status: 200, - }; const routes = Object.fromEntries( - Array.from({ length: MAX_CACHEABILITY_MANIFEST_ROUTES + 1 }, (_, index) => [ - `route-${index}`, - route, - ]), + Array.from({ length: 10_001 }, (_, index) => { + const pattern = `/route-${index}-${"x".repeat(30)}`; + return [ + cacheabilityManifestRouteKey("app-page", pattern), + { kind: "app-page" as const, pattern, state: "runtime-check" as const }, + ]; + }), ); + const manifest: CacheabilityManifest = { buildId: "build-a", routes, version: 1 }; + const manifestBytes = Buffer.byteLength(JSON.stringify(manifest)); + expect(manifestBytes).toBeGreaterThan(1024 * 1024); + expect(manifestBytes).toBeLessThan(2 * 1024 * 1024); expect(() => - writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", { - buildId: "build-a", - routes, - version: 1, - }), - ).toThrow(`the limit is ${MAX_CACHEABILITY_MANIFEST_ROUTES}`); + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", manifest), + ).not.toThrow(); }); it("warms concrete static paths while tolerating a private paired representation", async () => { From 7baa9e976be630f4cc3ccb18e3d03378f78a5262 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 1 Sep 2026 15:47:24 +0100 Subject: [PATCH 24/24] perf(cloudflare): avoid duplicate deployment status check --- packages/cloudflare/src/deploy.ts | 11 +++-------- tests/cloudflare-cdn-warm-deploy.test.ts | 15 +++++++-------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index f246f3a89..ee8bb28bc 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -1599,8 +1599,9 @@ async function deployWithCacheabilityProbe( .filter((target) => target.kind === "rsc-full") .map((target) => target.sourcePathname), }; - // A concurrent deployment invalidates the probe. Check both before and - // after the final upload; uploading a version does not itself change traffic. + // A concurrent deployment invalidates the probe. Avoid creating an orphan + // final version when the probe is already stale. The final deployment path + // checks this state again immediately before it stages the uploaded version. assertDeploymentStateUnchanged( root, options, @@ -1615,12 +1616,6 @@ async function deployWithCacheabilityProbe( preview: options.preview, verbose: options.verbose, }); - assertDeploymentStateUnchanged( - root, - options, - stagedProbeDeployment, - "Two-stage CDN warming stopped because Worker deployment traffic or deployment identity changed before the final version could be staged. No final version was promoted.", - ); prepared = { optionalWarmTargetKeys: new Set(probe.speculativeTargets.map(cdnWarmTargetKey)), prerenderSecret, diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index bcd54eb48..416e3dfc5 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -189,7 +189,7 @@ function mockTwoStageWrangler( if (args.includes("status")) { state.statusCount++; const replaceFinalStage = - state.finalStaged && state.statusCount >= 7 && options.replaceFinalStageBeforeHandoff; + state.finalStaged && state.statusCount >= 6 && options.replaceFinalStageBeforeHandoff; return JSON.stringify({ id: state.statusCount === 1 @@ -591,7 +591,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(deployedUrl).toBe("https://my-worker.example.workers.dev"); expect(uploadCount).toBe(2); - expect(statusCount).toBe(7); + expect(statusCount).toBe(6); expect(Array.from(cacheRequestCounts.entries())).toEqual([ ["/about?_rsc", 1], ["/dynamic?_rsc", 1], @@ -615,9 +615,8 @@ describe("Cloudflare CDN warmup deploy flow", () => { "status-3", "upload-final", "status-4", - "status-5", "stage-final", - "status-6", + "status-5", "triggers", "readiness", "warm:/about?_rsc", @@ -626,7 +625,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "warm:/api/data", "warm:/about", "warm:/pages-about", - "status-7", + "status-6", "promote-final", ]); expect(finalConfig).toEqual({ main: "index.js", name: "my-worker", workers_dev: true }); @@ -1094,9 +1093,9 @@ describe("Cloudflare CDN warmup deploy flow", () => { const versions = statusCount === 1 ? [{ version_id: OLD_VERSION, percentage: 100 }] - : statusCount === 7 + : statusCount === 6 ? [{ version_id: "44444444-4444-4444-8444-444444444444", percentage: 100 }] - : statusCount === 6 + : statusCount === 5 ? [ { version_id: OLD_VERSION, percentage: 100 }, { version_id: FINAL_VERSION, percentage: 0 }, @@ -1154,7 +1153,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { ).rejects.toThrow( "deployment traffic or deployment identity changed before the final version could be promoted", ); - expect(statusCount).toBe(7); + expect(statusCount).toBe(6); expect( (execFileSyncMock.mock.calls as Array<[string, string[]]>).some(([, args]) => args.includes(`${FINAL_VERSION}@100%`),