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/cacheability-artifact.ts b/packages/cloudflare/src/cacheability-artifact.ts index 7837ac98e..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 = { @@ -194,16 +192,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); @@ -213,34 +210,19 @@ export function withCacheabilityManifestArtifact( `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) { 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/cacheability-manifest-limits.ts b/packages/cloudflare/src/cacheability-manifest-limits.ts index b793bcfde..68ca9f17f 100644 --- a/packages/cloudflare/src/cacheability-manifest-limits.ts +++ b/packages/cloudflare/src/cacheability-manifest-limits.ts @@ -1,20 +1,10 @@ -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 identities; the limit is ${limit}. Narrow prerender discovery or split the deployment before retrying.`, - ); -} +export const MAX_CACHEABILITY_MANIFEST_BYTES = 2 * 1024 * 1024; export function cacheabilityManifestByteLimitError( manifestBytes: number, 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 70610b02f..250f040ac 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, @@ -18,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; @@ -33,24 +34,98 @@ type ProbePayload = { kind?: string; pattern?: string; reason?: string; + rendererStatic?: boolean; state?: string; 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; + /** 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"; } -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 = @@ -111,7 +186,7 @@ async function probeTarget(options: { headers?: HeadersInit; retries: number; retryDelayMs: number; - deadlineAt: number; + getDeadlineAt: () => number; phaseTimeoutMs: number; secret: string; target: CdnWarmTarget; @@ -128,17 +203,17 @@ 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(); - 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); @@ -171,21 +246,35 @@ 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.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 (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); @@ -194,7 +283,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); } @@ -206,6 +298,7 @@ export async function probeStagedWorkerCacheability(options: { buildId: string; concurrency?: number; expectedResponseBuildId?: string; + fallbackRoutePatterns?: readonly PrerenderRoutePattern[]; fetchImpl?: typeof fetch; headers?: HeadersInit; retries?: number; @@ -215,8 +308,9 @@ 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 }; + manifestLimits?: { maxBytes?: number }; }): Promise { const secret = readPrerenderSecret(options.root); const concurrency = Math.max(1, options.concurrency ?? 25); @@ -230,18 +324,16 @@ 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 speculativeTargets: CdnWarmTarget[] = []; const failures: string[] = []; const maxManifestBytes = Math.min( 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: {}, @@ -251,17 +343,104 @@ export async function probeStagedWorkerCacheability(options: { const routeEntryBytes = new Map(); let limitFailure: Error | null = null; let phaseTimedOut = false; - let nextIndex = 0; + let probed = 0; + let completedPathCount = 0; + let skippedPathCount = 0; + let staticPathCount = 0; + let dynamicPathCount = 0; - 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; + 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 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: completedPathCount + (missingRouteMetadata > 0 ? 1 : 0), + dynamic: dynamicPathCount, + failed: failures.length, + probed, + skipped: skippedPathCount, + static: staticPathCount, + total: groups.length + (missingRouteMetadata > 0 ? 1 : 0), + }); + }; + + const addRouteWithinManifestLimits = (key: string, route: CacheabilityManifestRoute): boolean => { + const previousBytes = routeEntryBytes.get(key); // 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 @@ -282,86 +461,271 @@ export async function probeStagedWorkerCacheability(options: { return true; }; - 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`); - continue; - } + const classifyConcretePath = async (group: ConcretePathGroup): Promise => { + if (group.pattern.pruned) { + skippedPathCount += 1; + completedPathCount += 1; + reportProgress(); + 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; + } - 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 ( - 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 - ) { - failures.push(`${target.label}: ${result.reason ?? "probe returned an invalid envelope"}`); - continue; - } - if (result.state === "probe-failed") { - failures.push(`${target.label}: ${result.reason ?? "probe failed"}`); - } + 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; + if (result.phaseTimedOut) { + phaseTimedOut = true; + return; + } + 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") || + (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; + } + 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; + } - // 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 patternIsDefinitelyDynamic = + result.state === "dynamic" && result.scope === "pattern" && group.pattern.canPrune; + if (patternIsDefinitelyDynamic) { + group.pattern.pruned = true; + dynamicPathCount += 1; + completedPathCount += 1; + reportProgress(); + return; + } - 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); + group.pattern.results.set(group.routePathname, { + rendererStatic: result.rendererStatic === true, + representation: target.kind, + state: result.state, + }); + if (result.state === "static-candidate") { + staticPathCount += 1; + } else { + dynamicPathCount += 1; } + completedPathCount += 1; + reportProgress(); }; - await Promise.all( - Array.from({ length: Math.min(concurrency, options.targets.length) }, () => worker()), - ); + const runGroups = async (scheduledGroups: ConcretePathGroup[]): Promise => { + let nextIndex = 0; + const worker = async (): Promise => { + while (!limitFailure && !phaseTimedOut && nextIndex < scheduledGroups.length) { + await classifyConcretePath(scheduledGroups[nextIndex++]); + } + }; + await Promise.all( + Array.from({ length: Math.min(concurrency, scheduledGroups.length) }, () => worker()), + ); + }; + + reportProgress(); + 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; + const fallbackRoutes = new Map(); + for (const fallbackRoute of options.fallbackRoutePatterns ?? []) { + const key = cacheabilityManifestRouteKey(fallbackRoute.kind, fallbackRoute.pattern); + 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, + pattern: fallbackRoute.pattern, + state: "static-candidate", + }) + ) { + break; + } + classified += 1; + } if (limitFailure) throw limitFailure; - if (phaseTimedOut || Date.now() >= deadlineAt) { - throw new Error(`cacheability probing exceeded its ${phaseTimeoutMs}ms phase deadline`); + // 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 hasStaticFallback = fallbackRoutes.has(pattern.key); + 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", + ...(hasStaticFallback || 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)), ); @@ -370,10 +734,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, + dynamic, failures, manifest: { buildId: options.buildId, routes: sortedRoutes, version: 1 }, - probed: options.targets.length, + probed, + skipped, + speculativeTargets, }; } diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 70ec76e53..1a61ac861 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, @@ -23,6 +24,11 @@ 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_HEADER, + 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 = { @@ -32,6 +38,7 @@ export type CdnWarmOptions = { pagesDataPaths?: readonly string[]; /** Statically eligible App Route Handler request identities. */ routeHandlerPaths?: readonly string[]; + 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. */ @@ -77,6 +84,7 @@ export type CdnWarmResult = { skipped: number; failed: number; failures: Array<{ path: string; error: string }>; + skippedTargets: CdnWarmTarget[]; warmedPlan: CdnWarmRequestPlan; retryPlan: CdnWarmRequestPlan; }; @@ -87,6 +95,7 @@ export type CdnWarmRequestPlan = { paths: string[]; rscPaths: string[]; routeHandlerPaths?: string[]; + routePatterns?: Record; }; export type CdnWarmReadinessResult = { ready: true } | { error: string; ready: false }; @@ -96,11 +105,13 @@ export type PrerenderWarmPlan = { buildId?: string; buildIdentity?: string; deploymentId?: string; + fallbackRoutePatterns?: PrerenderRoutePattern[]; loadingShellPaths: string[]; pagesDataPaths?: string[]; pagesPaths?: string[]; paths: string[]; routeHandlerPaths?: string[]; + routePatterns?: Record; rscBuildId?: string; rscPaths: string[]; }; @@ -151,12 +162,49 @@ 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" || + route.kind === "app-route" || + route.kind === "pages-page") && + typeof route.pattern === "string" && + route.pattern.startsWith("/"), + ))) || (manifest.rscPaths !== undefined && (!Array.isArray(manifest.rscPaths) || !manifest.rscPaths.every((pathname) => typeof pathname === "string"))) || (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("/") && + (route.cacheabilityProbe === undefined || + (route.cacheabilityProbe !== null && + typeof route.cacheabilityProbe === "object" && + !Array.isArray(route.cacheabilityProbe) && + typeof route.cacheabilityProbe.canPrunePattern === "boolean" && + (route.cacheabilityProbe.concretePathname === undefined || + (typeof route.cacheabilityProbe.concretePathname === "string" && + route.cacheabilityProbe.concretePathname.startsWith("/"))))), + ))) || (manifest.loadingShellPaths !== undefined && (!Array.isArray(manifest.loadingShellPaths) || !manifest.loadingShellPaths.every((pathname) => typeof pathname === "string"))) || @@ -224,6 +272,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]) => [ + pathname.includes("/_next/data/") ? pathname : applyConfig(pathname), + route, + ]), + ) + : undefined; let htmlPaths = pathPlan.paths; if (options?.includeFallbackShells === true) { const prerenderManifest = readPrerenderManifest( @@ -253,6 +309,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) : [], @@ -264,6 +323,7 @@ export function readPrerenderWarmPlan( ...(manifest.routeHandlerPaths ? { routeHandlerPaths: manifest.routeHandlerPaths.map(applyConfig) } : {}), + ...(routePatterns ? { routePatterns } : {}), }; } @@ -378,6 +438,7 @@ export type CdnWarmTarget = { label: string; pathname: string; sourcePathname: string; + route?: PrerenderRoutePattern; }; export async function createCdnWarmTargets( @@ -389,6 +450,7 @@ export async function createCdnWarmTargets( | "pagesDataPaths" | "paths" | "routeHandlerPaths" + | "routePatterns" | "rscPaths" >, ): Promise { @@ -408,6 +470,7 @@ export async function createCdnWarmTargets( label: `${pathname} (RSC full)`, pathname: createCanonicalRscRequestUrl(pathname), sourcePathname: pathname, + route: options.routePatterns?.[pathname], }); } @@ -424,6 +487,7 @@ export async function createCdnWarmTargets( label: `${pathname} (RSC loading shell)`, pathname: await createRscRequestUrl(pathname, loadingHeaders), sourcePathname: pathname, + route: options.routePatterns?.[pathname], }); } } @@ -437,6 +501,7 @@ export async function createCdnWarmTargets( label: pathname, pathname, sourcePathname: pathname, + route: options.routePatterns?.[pathname], }); } for (const pathname of new Set(options.pagesDataPaths ?? [])) { @@ -448,6 +513,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 +525,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; @@ -694,10 +761,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" }; } @@ -719,13 +782,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" }; } @@ -774,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 @@ -794,6 +868,7 @@ export async function waitForCdnWarmTargetReadiness( plan: CdnWarmRequestPlan; maxAttempts?: number; phaseTimeoutMs?: number; + prerenderSecret?: string; probeIntervalMs?: number; requiredConsecutiveSuccesses?: number; }, @@ -813,8 +888,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); } @@ -839,15 +923,15 @@ export async function waitForCdnWarmTargetReadiness( options.requiredConsecutiveSuccesses ?? DEFAULT_STAGED_READINESS_SUCCESSES, ); const readinessRetries = Math.max(0, options.retries ?? DEFAULT_STAGED_READINESS_RETRIES); + const maxAttempts = Math.max( + requiredConsecutiveSuccesses, + options.maxAttempts ?? requiredConsecutiveSuccesses + readinessRetries, + ); 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, - ); const probeId = randomUUID(); let consecutiveSuccesses = 0; let lastError = "readiness probe did not run"; @@ -868,12 +952,14 @@ export async function waitForCdnWarmTargetReadiness( Math.min(timeoutMs, remainingMs), headers, ); - const validationError = validateReadinessResponse( - response, - kind, - options.expectedBuildId, - 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}: ` + @@ -1151,6 +1237,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; @@ -1300,6 +1391,7 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise target.kind === "rsc-loading-shell") diff --git a/packages/cloudflare/src/cli.ts b/packages/cloudflare/src/cli.ts index 2c578dda4..6af09888b 100644 --- a/packages/cloudflare/src/cli.ts +++ b/packages/cloudflare/src/cli.ts @@ -47,6 +47,7 @@ async function deployCommand(): 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..356180b02 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,14 +36,16 @@ 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 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: + 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 2f4ea2c5f..f246f3a89 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -54,12 +54,13 @@ import { parseWranglerConfig, runTPR } from "./tpr.js"; import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "./version-headers.js"; import { createCdnWarmTargets, - DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS, + CdnOperationProgress, readPrerenderWarmPlan, waitForCdnWarmTargetReadiness, warmCdnCache, type CdnWarmOptions, type CdnWarmRequestPlan, + type CdnWarmTarget, type PrerenderWarmPlan, } from "./cdn-warm.js"; import { @@ -82,13 +83,15 @@ 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 { writeCacheabilityManifestArtifact } from "./cacheability-artifact.js"; import { DEFAULT_CACHEABILITY_PROBE_PHASE_TIMEOUT_MS, 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; @@ -110,6 +113,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 +131,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 +228,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 +279,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 +721,7 @@ type CdnWarmDeployOptions = Pick< | "env" | "name" | "config" + | "verbose" | "warmCdnConcurrency" | "warmCdnTimeout" | "warmCdnRetries" @@ -738,6 +746,7 @@ type CdnWarmDeployOptions = Pick< | "loadingShellPaths" | "pagesDataPaths" | "routeHandlerPaths" + | "routePatterns" | "rscPaths" > & { /** Probe a staged Worker and upload the resulting manifest as a second version. */ @@ -750,11 +759,17 @@ type CdnWarmDeployOptions = Pick< type PreparedCdnWarmDeployOptions = CdnWarmDeployOptions & { expectedDeploymentState?: WranglerDeploymentStatus; + optionalWarmTargetKeys?: ReadonlySet; + prerenderSecret?: string; 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[], @@ -825,6 +840,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 +888,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 +914,7 @@ async function deployUploadedVersionWithCdnWarmup( pagesDataPaths: remainingWarmPlan.pagesDataPaths, paths: remainingWarmPlan.paths, routeHandlerPaths: remainingWarmPlan.routeHandlerPaths, + routePatterns: remainingWarmPlan.routePatterns, rscPaths: remainingWarmPlan.rscPaths, }, requireCacheHit = false, @@ -912,9 +930,9 @@ 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, timeoutMs: options.warmCdnTimeout, retries: options.warmCdnRetries, requireCacheHit, @@ -1019,6 +1037,7 @@ async function deployUploadedVersionWithCdnWarmup( targetUrl, headers, plan: stagedWarmPlan, + prerenderSecret: options.prerenderSecret, deploymentId, expectedBuildId, expectedRscBuildId, @@ -1043,22 +1062,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, @@ -1352,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); @@ -1392,7 +1424,9 @@ async function deployWithCacheabilityProbe( } let prepared: | { + optionalWarmTargetKeys: ReadonlySet; plan: PrerenderWarmPlan; + prerenderSecret: string; upload: WranglerVersionUploadResult; } | undefined; @@ -1431,11 +1465,15 @@ 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, paths: [...discovered.paths], routeHandlerPaths: [...(discovered.routeHandlerPaths ?? [])], + routePatterns: discovered.routePatterns ? { ...discovered.routePatterns } : undefined, rscPaths: [...discovered.rscPaths], }; if (!plan.appPaths && !plan.pagesPaths) { @@ -1452,14 +1490,33 @@ async function deployWithCacheabilityProbe( pagesDataPaths: plan.pagesDataPaths, paths: plan.paths, routeHandlerPaths: plan.routeHandlerPaths, + 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({ targetUrl, headers, plan, + prerenderSecret, deploymentId: plan.deploymentId, expectedBuildId: plan.buildIdentity, expectedRscBuildId: plan.rscBuildId, @@ -1476,29 +1533,52 @@ async function deployWithCacheabilityProbe( } console.log( - ` CDN warmup: probing ${targets.length} exact request identit${targets.length === 1 ? "y" : "ies"}...`, + ` 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 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, + fallbackRoutePatterns: plan.fallbackRoutePatterns, + 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} 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}/${probe.probed} request(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 = { @@ -1527,25 +1607,26 @@ 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, - }), - ); + 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, 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)), + prerenderSecret, + plan: finalPlan, + upload: finalUpload, + }; } catch (error) { throw withStagedProbeVersionCleanupNote(error); } @@ -1557,8 +1638,11 @@ async function deployWithCacheabilityProbe( expectedRscBuildId: prepared.plan.rscBuildId, expectedDeploymentState: stagedProbeDeployment, 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, uploadedVersion: prepared.upload, }); @@ -1857,6 +1941,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..c27cadb37 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"; @@ -32,11 +33,24 @@ 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 { resolveAppPageDynamicConfig } from "../server/app-segment-config.js"; import { extractLocaleFromUrl, normalizeDefaultLocalePathname } from "../server/pages-i18n.js"; import { normalizePathTrailingSlash } from "vinext/shims/url-utils"; import { buildPagesDataHref } from "vinext/shims/internal/pages-data-url"; +import { CACHEABILITY_POLICY_HEADERS } from "vinext/shims/cacheability-classification"; + +export type PrerenderRoutePattern = { + kind: "app-page" | "app-route" | "pages-page"; + pattern: string; + /** Closed-world safety facts for probe coordinator optimizations. */ + cacheabilityProbe?: { + canPrunePattern: boolean; + /** HTML pathname shared by alternate representations of this route. */ + concretePathname?: string; + }; +}; export type PrerenderPathManifest = { /** App Page HTML paths after hybrid route ownership has been resolved. */ @@ -61,6 +75,10 @@ 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; paths: string[]; }; @@ -483,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), @@ -493,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; @@ -542,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; @@ -583,7 +609,7 @@ async function collectPagesPaths(options: { } } - return { dataPaths, paths }; + return { dataPaths, fallbackRoutePatterns, paths }; } async function excludePagesApiWarmPaths(options: { @@ -602,6 +628,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, @@ -644,10 +690,16 @@ function extractPagesStaticPathLocale( async function collectAppPaths(options: { appDir: string; baseUrl: string | null; + cacheComponents: boolean; 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(); @@ -655,43 +707,56 @@ async function collectAppPaths(options: { const seenLoadingShellPaths = new Set(); const routeHandlerPaths: string[] = []; 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}.`); - } - 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() { @@ -728,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; @@ -761,7 +829,54 @@ async function collectAppPaths(options: { } } - if (!paramSets?.length) continue; + 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, + ...(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; @@ -772,7 +887,7 @@ async function collectAppPaths(options: { } } - return { loadingShellPaths, paths, routeHandlerPaths }; + return { fallbackRoutePatterns, loadingShellPaths, paths, routeHandlerPaths }; } async function resolveAppWarmPaths(options: { @@ -788,6 +903,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 +926,7 @@ async function resolveAppWarmPaths(options: { const htmlPaths: string[] = []; const loadingShellPaths: string[] = []; const pagesPaths: 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 @@ -831,6 +948,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 +962,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 +979,155 @@ 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, + }; +} + +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, + }); + let sourceMatched = false; + matchHeaders( + matchPathname, + [rule], + { + cookies: {}, + headers: new Headers(), + host: hostname ?? "", + query: new URLSearchParams(), + }, + { basePath: config.basePath, hadBasePath: true }, + () => { + sourceMatched = true; + }, + ); + return sourceMatched; + }); +} + +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 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, + config: Pick, +): Record { + const cachePolicyRules = config.headers.filter((rule) => + rule.headers.some((header) => CACHEABILITY_POLICY_HEADER_NAMES.has(header.key.toLowerCase())), + ); + const matchingPolicyRules = new Map( + Object.keys(routePatterns).map((pathname) => [ + pathname, + new Set( + 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); + } + const canPrunePatterns = new Map(); + for (const [patternKey, patternPaths] of pathsByPattern) { + 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, + relevantRules.every( + (rule) => + !rule.has?.length && + !rule.missing?.length && + 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; + return [ + pathname, + { + ...route, + cacheabilityProbe: { canPrunePattern }, + }, + ]; + }), + ); } function configuredRouteAffectsWarmPath( @@ -954,6 +1217,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({ @@ -1014,6 +1278,7 @@ export async function emitPrerenderPathManifest( const appPathResult = await collectAppPaths({ appDir, baseUrl, + cacheComponents: config.cacheComponents, pageExtensions: config.pageExtensions, retryOptions: pathDiscoveryRetryOptions, secretHeaders, @@ -1028,6 +1293,7 @@ export async function emitPrerenderPathManifest( for (const pathname of appPathResult.routeHandlerPaths) { addPath(discoveredRouteHandlerPaths, seenRouteHandlerPaths, pathname); } + fallbackRoutePatterns.push(...appPathResult.fallbackRoutePatterns); } if (pagesDir) { @@ -1046,6 +1312,7 @@ export async function emitPrerenderPathManifest( for (const pathname of pagesPathResult.dataPaths) { addPath(discoveredPagesDataPaths, seenPagesDataPaths, pathname); } + fallbackRoutePatterns.push(...pagesPathResult.fallbackRoutePatterns); } } finally { if (prodServer) { @@ -1092,6 +1359,14 @@ export async function emitPrerenderPathManifest( htmlPaths: discoveredAppPaths, loadingShellPaths: discoveredLoadingShellPaths, pagesPaths: resolvedPagesWarmPaths, + routePatterns: pagesDir + ? await resolvePagesWarmRoutePatterns({ + i18n: config.i18n, + pagesDir, + pageExtensions: config.pageExtensions, + paths: resolvedPagesWarmPaths, + }) + : {}, rscPaths: discoveredAppPaths, }; const warmPaths = appDir ? appOwnedWarmPaths.htmlPaths : resolvedPagesWarmPaths; @@ -1107,6 +1382,23 @@ 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 } : {}), @@ -1123,9 +1415,11 @@ 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 } : {}), + ...(Object.keys(routePatterns).length > 0 ? { 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..da5a5add4 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; @@ -643,13 +644,23 @@ 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; 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/app-route-handler-execution.ts b/packages/vinext/src/server/app-route-handler-execution.ts index 0028f829c..afeffe3e6 100644 --- a/packages/vinext/src/server/app-route-handler-execution.ts +++ b/packages/vinext/src/server/app-route-handler-execution.ts @@ -50,7 +50,7 @@ import { markRouteCacheabilityResponseBodyComplete, } from "vinext/shims/cacheability-classification"; import { - CACHEABILITY_PROBE_BODY_LIMIT, + CACHEABILITY_ADMISSION_RESPONSE_BODY_LIMIT, CACHEABILITY_PROBE_TIMEOUT_MS, } from "./cacheability-limits.js"; @@ -131,15 +131,14 @@ 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 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_PROBE_BODY_LIMIT, + CACHEABILITY_ADMISSION_RESPONSE_BODY_LIMIT, captureOptions?.captureBudget, ); const completed = new Response(captured.body, { diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts index 0acfa3c91..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 @@ -123,7 +126,12 @@ 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); + const readinessResponse = createWorkerPrerenderReadinessResponse(ctx, request); + if (readinessResponse) { + return (await validateCdnRequest(request)) ?? readinessResponse; + } let finalizeCacheabilityResponse: | ((response: Response, ctx: ExecutionContextLike) => Promise) | undefined; @@ -135,6 +143,7 @@ async function handleRequest( ctx, request, __rscPrerenderSecret, + cdnCacheAdapter.responseVary, ); if (probeContext !== ctx) { ctx = probeContext; @@ -151,7 +160,7 @@ async function handleRequest( } } const requiresCompletedResponseAdmission = - getCdnCacheAdapter().requiresCompletedResponseAdmission === true; + cdnCacheAdapter.requiresCompletedResponseAdmission === true; if ( !finalizeCacheabilityResponse && (__rscCacheabilityManifest || requiresCompletedResponseAdmission) && @@ -164,6 +173,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/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-limits.ts b/packages/vinext/src/server/cacheability-limits.ts index 9ce869152..1a13cb683 100644 --- a/packages/vinext/src/server/cacheability-limits.ts +++ b/packages/vinext/src/server/cacheability-limits.ts @@ -1,8 +1,8 @@ -/** Maximum response body that an authenticated cacheability probe will drain. */ -export const CACHEABILITY_PROBE_BODY_LIMIT = 4 * 1024 * 1024; +/** 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 = 16 * 1024 * 1024; +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-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 ba6c99235..cab52259e 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -20,16 +20,18 @@ import { import { workerCapabilityMatches } from "./worker-prerender-discovery.js"; import { CACHEABILITY_ADMISSION_ISOLATE_BODY_LIMIT, - CACHEABILITY_PROBE_BODY_LIMIT, + CACHEABILITY_ADMISSION_RESPONSE_BODY_LIMIT, 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,26 +45,38 @@ 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; 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 { - return (headers.get("Vary") ?? "").split(",").some((name) => { - const normalized = name.trim().toLowerCase(); - return normalized.length > 0 && !SUPPORTED_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( 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; @@ -79,6 +93,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, @@ -104,14 +119,25 @@ export function createWorkerCacheabilityAdmissionContext( rawManifest: string | null | undefined, buildId: string | null | undefined, requiresCompletedResponseAdmission = rawManifest != null, + responseVary?: "verbatim", ): ExecutionContextLike { const identity = cacheabilityRequestIdentity(request); 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", + responseVary, }; return Object.assign(Object.create(Object.getPrototypeOf(base)), base, { [CACHEABILITY_REQUEST_STATE]: state, @@ -122,9 +148,20 @@ 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", + responseVary, }; return Object.assign(Object.create(Object.getPrototypeOf(base)), base, { [CACHEABILITY_REQUEST_STATE]: state, @@ -142,12 +179,17 @@ 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) } + : {}), state: routeState, status, version: 1, @@ -162,7 +204,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) { @@ -182,17 +223,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 {} } } @@ -270,7 +312,9 @@ function continueCapturedBody( const release = () => { if (released) return; released = true; - reader.releaseLock(); + try { + reader.releaseLock(); + } catch {} }; return new ReadableStream( { @@ -295,11 +339,16 @@ function continueCapturedBody( controller.error(error); } }, - async cancel(reason) { + cancel(reason) { + releaseChunks(budget, captured, index); try { - await reader.cancel(reason); - } finally { - releaseChunks(budget, captured, index); + // 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(); } }, @@ -335,7 +384,7 @@ function replayCapturedBody( export async function captureCacheabilityAdmissionBody( body: ReadableStream | null, deadlineAt: number, - limit = CACHEABILITY_PROBE_BODY_LIMIT, + limit = CACHEABILITY_ADMISSION_RESPONSE_BODY_LIMIT, budget = isolateCaptureBudget, ): Promise { if (!body) return { body: null, kind: "captured" }; @@ -367,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; } } @@ -461,13 +520,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" }; } - if (hasUnsupportedCacheabilityVary(response.headers)) { - return { cacheable: false, reason: "response has unsupported Vary fields" }; - } return inferPagesPageCacheability(response); } if (state.route?.kind === "app-page") { @@ -540,12 +598,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 ( @@ -558,39 +616,30 @@ 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 || state.forcedDynamicReason || hasStrictFinalResponseVeto(response, state) || - hasUnsupportedCacheabilityVary(response.headers) + cacheabilityVaryRejectionReason(response.headers, state) !== null ) { return responseWithCachePolicy(response, response.body, null); } @@ -606,7 +655,7 @@ async function finalizeWorkerCacheabilityAdmission( captured = await captureCacheabilityAdmissionBody( response.body, state.captureDeadlineAt, - CACHEABILITY_PROBE_BODY_LIMIT, + CACHEABILITY_ADMISSION_RESPONSE_BODY_LIMIT, state.captureBudget ?? isolateCaptureBudget, ); } catch { @@ -639,20 +688,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); } } @@ -663,16 +711,15 @@ async function finalizeWorkerCacheabilityAdmission( if (hasStrictFinalResponseVeto(response, state)) { return responseWithCachePolicy(response, response.body, null); } - if (hasUnsupportedCacheabilityVary(response.headers)) { + if (cacheabilityVaryRejectionReason(response.headers, state) !== null) { return responseWithCachePolicy(response, response.body, null); } - let captured: CapturedAdmissionBody; try { captured = await captureCacheabilityAdmissionBody( response.body, state.captureDeadlineAt, - CACHEABILITY_PROBE_BODY_LIMIT, + CACHEABILITY_ADMISSION_RESPONSE_BODY_LIMIT, state.captureBudget ?? isolateCaptureBudget, ); } catch { @@ -690,7 +737,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(() => {}); @@ -752,6 +803,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( @@ -781,5 +847,6 @@ export async function finalizeWorkerCacheabilityResponse( : "dynamic", outcome, response.status, + rendererOutcome?.cacheable === true && rendererOutcome.dynamicUsage !== true, ); } diff --git a/packages/vinext/src/server/headers.ts b/packages/vinext/src/server/headers.ts index fa09e0883..7d1237006 100644 --- a/packages/vinext/src/server/headers.ts +++ b/packages/vinext/src/server/headers.ts @@ -63,6 +63,12 @@ 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"; + +/** 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/pages-router-entry.ts b/packages/vinext/src/server/pages-router-entry.ts index f34e0aed3..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"; @@ -131,7 +132,12 @@ 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); + const readinessResponse = createWorkerPrerenderReadinessResponse(ctx, request); + if (readinessResponse) { + return (await validateCdnRequest(request)) ?? readinessResponse; + } let finalizeCacheabilityResponse: | ((response: Response, ctx: ExecutionContextLike) => Promise) | undefined; @@ -141,6 +147,7 @@ async function handleRequest( ctx, request, pagesEntry.prerenderSecret, + cdnCacheAdapter.responseVary, ); if (probeContext !== ctx) { ctx = probeContext; @@ -153,7 +160,7 @@ async function handleRequest( } } const requiresCompletedResponseAdmission = - getCdnCacheAdapter().requiresCompletedResponseAdmission === true; + cdnCacheAdapter.requiresCompletedResponseAdmission === true; if ( !finalizeCacheabilityResponse && (__cacheabilityManifest || requiresCompletedResponseAdmission) @@ -165,6 +172,7 @@ async function handleRequest( __cacheabilityManifest, pagesEntry.buildId, requiresCompletedResponseAdmission, + cdnCacheAdapter.responseVary, ); if (admissionContext !== ctx) { ctx = admissionContext; diff --git a/packages/vinext/src/server/worker-prerender-discovery.ts b/packages/vinext/src/server/worker-prerender-discovery.ts index ec115ac60..1ada7dba5 100644 --- a/packages/vinext/src/server/worker-prerender-discovery.ts +++ b/packages/vinext/src/server/worker-prerender-discovery.ts @@ -1,7 +1,10 @@ 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_HEADER, + VINEXT_PRERENDER_READINESS_PATH, VINEXT_PRERENDER_SECRET_HEADER, VINEXT_PRERENDER_STATIC_PARAMS_PATH, } from "./headers.js"; @@ -10,6 +13,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 +31,41 @@ 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 { + // 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) + ) { + // 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, + headers: { + "Cache-Control": "no-store", + [VINEXT_PRERENDER_READINESS_HEADER]: "1", + }, + }); +} + /** * 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/packages/vinext/src/shims/cacheability-classification.ts b/packages/vinext/src/shims/cacheability-classification.ts index c45c67d19..ce4e4195d 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 }; @@ -36,10 +37,14 @@ 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; preserveResponseCachePolicy?: boolean; + /** Cache-key behavior declared by the active CDN adapter. */ + responseVary?: "verbatim"; probeBailout?: { kind: "private-cache"; outcome: RouteCacheabilityOutcome; @@ -89,6 +94,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/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/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-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/app-router-worker-entry.test.ts b/tests/app-router-worker-entry.test.ts index 4cfc98fb5..30f2c5f24 100644 --- a/tests/app-router-worker-entry.test.ts +++ b/tests/app-router-worker-entry.test.ts @@ -104,7 +104,34 @@ 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() {} }, + ); + 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/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..7dc3af38e 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -51,6 +51,17 @@ describe("cacheability admission capture", () => { 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( @@ -90,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 { @@ -100,17 +130,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 +143,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 +156,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, @@ -184,6 +190,28 @@ describe("single-request cacheability admission", () => { await expect(response.text()).resolves.toBe("static"); }); + it("admits a completed static response larger than the former 4 MiB probe 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(4 * 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 @@ -281,6 +309,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) => { @@ -300,6 +386,7 @@ describe("single-request cacheability admission", () => { policy: "runtime", representation: "app-route", requestKey: "/page", + routePathname: "/page", }); }, ); @@ -419,6 +506,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; @@ -437,31 +526,40 @@ 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(); }); it.each(["*/*", "text/html"])( - "admits only an exact manifest-backed Route Handler identity 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"); }, ); - 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 +581,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 +601,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 () => { @@ -525,13 +623,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" }; @@ -545,10 +650,60 @@ 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("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, @@ -595,6 +750,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 +1024,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 @@ -706,6 +1071,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", { @@ -747,6 +1169,82 @@ 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, + 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, @@ -801,11 +1299,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), ); @@ -821,10 +1322,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", @@ -832,14 +1353,35 @@ 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: "response cache does not support custom Vary fields", + 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, + reason: "response uses Vary: *", state: "dynamic", }); }); 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 e52a40e12..f0381aa65 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -6,8 +6,8 @@ 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, - cacheabilityRequestIdentity, type CacheabilityManifestRoute, } from "../packages/vinext/src/server/cacheability-manifest.js"; import { @@ -35,15 +35,23 @@ describe("staged Worker cacheability probes", () => { kind: "html" as const, label: pathname, pathname, + route: optimizableRoute(pathname), sourcePathname: pathname, }); + const optimizableRoute = (pattern: string) => ({ + cacheabilityProbe: { canPrunePattern: 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; return Response.json({ kind: "app-page", pattern: pathname, + rendererStatic: true, state: "static-candidate", status: 200, version: 1, @@ -86,6 +94,7 @@ describe("staged Worker cacheability probes", () => { { kind: "app-page", pattern: "/cached/:slug", + rendererStatic: true, state: "static-candidate", status: 200, version: 1, @@ -99,6 +108,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({ @@ -127,21 +137,24 @@ 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]); }); - 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 +169,589 @@ 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, 40)); + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + return Response.json({ + kind: "app-page", + pattern: pathname, + rendererStatic: true, + state: "static-candidate", + status: 200, + version: 1, + }); + }, + onProgress(update) { + progress.push(update.completed); + }, + phaseTimeoutMs: 250, + retries: 0, + root, + targetUrl: "https://example.com", + 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, + rendererStatic: true, + 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 }); + }); + + 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 }; + 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, + 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: [rsc, html], + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ classified: 1, probed: 1, skipped: 0 }); + expect(result.cacheableTargets).toEqual([html, rsc]); + 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("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 }; + 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, + rendererStatic: true, + 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(1); + expect(result).toMatchObject({ classified: 1, probed: 1, skipped: 0 }); + expect(Object.values(result.manifest.routes)).toEqual([ + 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"), route: optimizableRoute("/posts/:slug") }; + 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", + 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: [rsc, html], + }); + + 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 () => { + const root = createProbeRoot(); + const route = optimizableRoute("/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", + 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: 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 }, + 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, + rendererStatic: !isOrdinary, + scope: isOrdinary ? "pattern" : undefined, + state: isOrdinary ? "dynamic" : "static-candidate", + status: 200, + version: 1, + }); + }); + + const special = { ...target("/posts/a-special"), route }; + const ordinary = { ...target("/posts/z-ordinary"), route }; + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 1, + fetchImpl, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [ordinary, special], + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + 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("does not duplicate a concrete-path probe for conditional RSC policy", async () => { + const root = createProbeRoot(); + const route = { + cacheabilityProbe: { canPrunePattern: 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, + rendererStatic: !isRsc, + 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(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 () => { + 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(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("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) => { + const pathname = `/docs/${index}`; + return { + ...target(pathname), + route: optimizableRoute("/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 (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", + rendererStatic: !isDynamic, + scope: isDynamic ? "identity" : undefined, + state: isDynamic ? "dynamic" : "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: 1, + dynamic: 1, + probed: pathCount, + skipped: 0, + }); + 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 () => { const root = createProbeRoot(); let cancelled = false; @@ -187,7 +778,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 }); @@ -201,6 +792,7 @@ describe("staged Worker cacheability probes", () => { kind: "html" as const, label: "/static", pathname: "/static", + route: optimizableRoute("/static"), sourcePathname: "/static", }, { @@ -208,6 +800,7 @@ describe("staged Worker cacheability probes", () => { kind: "html" as const, label: "/dynamic", pathname: "/dynamic", + route: optimizableRoute("/dynamic"), sourcePathname: "/dynamic", }, ]; @@ -218,6 +811,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, @@ -231,51 +825,28 @@ 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" }), - ]); - }); - - it("stops launching probes when another cacheable identity exceeds the route bound", 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")], + expect.objectContaining({ + pattern: "/dynamic", + state: "runtime-check", }), - ).rejects.toThrow("produced 2 cacheable identities; the limit is 1"); - expect(fetchImpl).toHaveBeenCalledTimes(2); + expect.objectContaining({ + pattern: "/static", + state: "runtime-check", + staticRepresentation: "html", + }), + ]); }); 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", @@ -312,7 +883,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 () => { @@ -328,6 +899,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({ @@ -336,6 +912,7 @@ describe("staged Worker cacheability probes", () => { Response.json({ kind: "pages-page", pattern: "/posts/:slug", + rendererStatic: true, state: "static-candidate", status: 200, version: 1, @@ -348,13 +925,207 @@ 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 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" }, + { kind: "app-route", pattern: "/api/posts/:slug" }, + { kind: "pages-page", pattern: "/legacy/:slug" }, + ], + root, + targetUrl: "https://example.com", + targets: [], + }); + + expect(result).toMatchObject({ classified: 3, probed: 0 }); + expect(Object.values(result.manifest.routes)).toEqual([ + { + kind: "app-page", pattern: "/posts/:slug", - requestKey: "/posts/one", + state: "static-candidate", + }, + { + kind: "app-route", + pattern: "/api/posts/:slug", + state: "static-candidate", + }, + { + kind: "pages-page", + pattern: "/legacy/:slug", + state: "static-candidate", + }, + ]); + }); + + 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"); + 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 = { @@ -362,6 +1133,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({ @@ -384,8 +1160,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 c4e4ec0a4..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"; @@ -8,10 +9,16 @@ 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 { 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 { writeCacheabilityManifestArtifact } from "../packages/cloudflare/src/cacheability-artifact.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, +} from "../packages/vinext/src/server/headers.js"; const execFileSyncMock = vi.hoisted(() => vi.fn()); const delayMock = vi.hoisted(() => vi.fn()); @@ -64,6 +71,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: { @@ -105,12 +123,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 +146,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 +240,7 @@ function pagesPageProbeResponse() { { kind: "pages-page", pattern: "/pages-about", + rendererStatic: true, state: "static-candidate", status: 200, version: 1, @@ -270,12 +306,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}`); }); @@ -303,12 +338,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}`); }); @@ -325,44 +359,40 @@ 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 exact identities 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(() => - withCacheabilityManifestArtifact( - tmpDir, - "dist/server/wrangler.json", - { buildId: "build-a", routes, version: 1 }, - () => undefined, - ), - ).toThrow(`the limit is ${MAX_CACHEABILITY_MANIFEST_ROUTES}`); + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", manifest), + ).not.toThrow(); }); - 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; @@ -429,7 +459,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" @@ -440,7 +471,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, @@ -460,20 +492,53 @@ 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 { 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"; + 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"); + return readinessResponse(); + } + 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/") + ? cacheablePagesData(cacheStatus) + : cacheableHtml("ok", cacheStatus); }); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -489,7 +554,34 @@ describe("Cloudflare CDN warmup deploy flow", () => { pagesPaths: ["/pages-about"], paths: ["/about", "/dynamic", "/pages-about"], routeHandlerPaths: ["/api/data"], - rscPaths: [], + routePatterns: { + "/about": { + cacheabilityProbe: { canPrunePattern: true }, + kind: "app-page", + pattern: "/:slug", + }, + "/api/data": { + cacheabilityProbe: { canPrunePattern: true }, + kind: "app-route", + pattern: "/api/data", + }, + "/dynamic": { + cacheabilityProbe: { canPrunePattern: true }, + kind: "app-page", + 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", + }, + }, + rscPaths: ["/about", "/dynamic"], }), warmCdnConcurrency: 1, warmCdnPromotionDelay: 0, @@ -501,6 +593,8 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(uploadCount).toBe(2); 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], @@ -513,10 +607,11 @@ describe("Cloudflare CDN warmup deploy flow", () => { "status-2", "readiness", "probe:/about", - "probe:/dynamic", "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", @@ -525,6 +620,8 @@ describe("Cloudflare CDN warmup deploy flow", () => { "status-6", "triggers", "readiness", + "warm:/about?_rsc", + "warm:/dynamic?_rsc", "warm:/_next/data/app-build-a/pages-about.json", "warm:/api/data", "warm:/about", @@ -538,39 +635,45 @@ 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", - 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", }), ]), ); 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 () => { @@ -602,7 +705,61 @@ 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 zero-path static fallbacks", 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" }, + { kind: "app-route", pattern: "/api/posts/:slug" }, + { kind: "pages-page", pattern: "/legacy/: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", + }, + '["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, + }); + }); + + it("promotes an empty manifest when every discovered pattern is dynamic", async () => { writeTwoStageWorkerArtifact(); const wrangler = mockTwoStageWrangler(); vi.mocked(fetch).mockImplementation(async (input, init) => @@ -611,6 +768,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { { kind: "app-page", pattern: "/dynamic", + scope: "pattern", state: "dynamic", status: 200, version: 1, @@ -618,7 +776,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"); @@ -633,6 +791,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/dynamic"], + routePatterns: appPageRoutePatterns(["/dynamic"], "/dynamic"), rscPaths: [], }), warmCdnReadinessProbes: 1, @@ -649,6 +808,128 @@ 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) + ? readinessResponse() + : 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(); + vi.mocked(fetch).mockImplementation(async (input, init) => { + if (new Headers(init?.headers).get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1") { + return pagesPageProbeResponse(); + } + if (isReadinessFetch(input)) return readinessResponse(); + 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(); @@ -723,7 +1004,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"); @@ -738,6 +1019,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), dangerouslyPromoteOnCdnWarmError: true, @@ -763,7 +1045,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: { @@ -784,6 +1066,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), dangerouslyPromoteOnCdnWarmError: true, @@ -843,7 +1126,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"); }); @@ -859,6 +1142,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnCertify: true, @@ -878,14 +1162,14 @@ 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) => 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"); @@ -900,6 +1184,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -919,7 +1204,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"); @@ -934,6 +1219,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -943,6 +1229,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([ @@ -957,7 +1246,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", @@ -978,6 +1267,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -997,7 +1287,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); @@ -1007,9 +1297,9 @@ 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; + now += 120_001; return cacheableHtml(); }); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -1024,6 +1314,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/first", "/queued"], + routePatterns: appPageRoutePatterns(["/first", "/queued"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -1031,9 +1322,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 () => { @@ -1074,7 +1365,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"); @@ -1089,6 +1380,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -1140,7 +1432,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"); @@ -1155,6 +1447,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -1202,7 +1495,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"); @@ -1217,6 +1510,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -1269,7 +1563,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"); @@ -1284,6 +1578,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildIdentity: "app-build-a", loadingShellPaths: [], paths: ["/about"], + routePatterns: appPageRoutePatterns(["/about"]), rscPaths: [], }), warmCdnConcurrency: 1, @@ -1336,7 +1631,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"); @@ -1351,6 +1646,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..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; @@ -100,6 +101,11 @@ describe("Cloudflare CDN warmup", () => { buildId: "build-a", buildIdentity: "rsc-build-a", deploymentId: "dpl_123", + 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"], @@ -119,6 +125,11 @@ describe("Cloudflare CDN warmup", () => { buildId: "build-a", buildIdentity: "rsc-build-a", deploymentId: "dpl_123", + 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/"], @@ -233,6 +244,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 +339,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 +356,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 () => { @@ -734,7 +746,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`); @@ -751,7 +763,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 () => { @@ -804,6 +816,90 @@ 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: { + "cache-control": "no-store", + [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", + [VINEXT_PRERENDER_READINESS_HEADER]: "1", + }, + }); + }); + + 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.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"); @@ -888,6 +984,33 @@ describe("Cloudflare CDN warmup", () => { expect(fetchImpl.mock.calls.length).toBeLessThan(100); }); + 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 () => { + 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(12); + } finally { + dateNow.mockRestore(); + } + }); + it("does not skip a non-success response from a different build", async () => { const fetchImpl = vi.fn( async () => @@ -925,7 +1048,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`); @@ -940,7 +1063,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 () => { @@ -1031,7 +1154,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; }); @@ -1048,7 +1171,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/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-admission.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts index e45c927a3..87f27bb87 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" }, }); @@ -95,13 +186,32 @@ test("admits only exact manifest-backed App Page responses after clean EOF", asy 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 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", + ); + 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", ); 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/cacheability-probe.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts index 928ea28a9..2611f15a1 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", { @@ -89,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: "*/*" }, }); @@ -101,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/e2e/ppr-impact-demo/pages-cacheability.spec.ts b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts index 00400d8e8..8e27d8f0c 100644 --- a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts +++ b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts @@ -119,8 +119,15 @@ 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", + "/cacheability-pages/posts/unknown", + ]) { 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,7 +135,9 @@ 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`, + `/_next/data/${buildId}/cacheability-pages/posts/unknown.json`, ]) { const response = await request.get(pathname, { headers: { Accept: "application/json" } }); expect(response.status(), pathname).toBe(200); @@ -150,11 +159,7 @@ 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, { headers: { Accept: pathname.includes("/_next/data/") ? "application/json" : "text/html" }, 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/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/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/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/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/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" } }); } 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..9695b7d4d 100644 --- a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json +++ b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json @@ -1,205 +1,109 @@ { "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 - }, - "[\"app-page\",\"/cacheability/static\",\"html\",\"/cacheability/static?late-policy=cache-control\"]": { - "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 + "staticPaths": { + "html": ["/cacheability/pattern-runtime-dynamic/static"] + } }, - "[\"app-page\",\"/cacheability/static\",\"html\",\"/cacheability/static?late-policy=cloudflare-cdn-cache-control\"]": { + "[\"app-page\",\"/cacheability/pattern-runtime-static/:slug\"]": { + "allowUnknown": true, "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-large\"]": { + "kind": "app-route", + "pattern": "/cacheability/route-handler-large", + "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 - }, - "[\"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 + "state": "static-candidate" }, - "[\"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 + "[\"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\",\"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 - }, - "[\"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 + "state": "static-candidate" }, - "[\"pages-page\",\"/cacheability-pages/gssp-public\",\"html\",\"/cacheability-pages/gssp-public\"]": { + "[\"pages-page\",\"/cacheability-pages/gssp-public\"]": { "kind": "pages-page", "pattern": "/cacheability-pages/gssp-public", - "representation": "html", - "requestKey": "/cacheability-pages/gssp-public", - "state": "static-candidate", - "status": 200 + "state": "static-candidate" }, - "[\"pages-page\",\"/cacheability-pages/gssp-public\",\"pages-data\",\"/_next/data/ppr-impact-demo-cacheability/cacheability-pages/gssp-public.json\"]": { + "[\"pages-page\",\"/cacheability-pages/isr\"]": { "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 - }, - "[\"pages-page\",\"/cacheability-pages/get-initial-props\",\"html\",\"/cacheability-pages/get-initial-props\"]": { - "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 65add0b37..50f3fed13 100644 --- a/tests/fixtures/ppr-impact-demo/next.config.ts +++ b/tests/fixtures/ppr-impact-demo/next.config.ts @@ -24,8 +24,18 @@ export default { headers: async () => [ { source: "/cacheability/config-public-dynamic", + missing: [{ type: "query", key: "preview" }], 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/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 } }) { 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", } diff --git a/tests/pages-router-worker-entry.test.ts b/tests/pages-router-worker-entry.test.ts new file mode 100644 index 000000000..0efd622d5 --- /dev/null +++ b/tests/pages-router-worker-entry.test.ts @@ -0,0 +1,86 @@ +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() {} }, + ); + 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/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 853647700..88c74c4ac 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -38,6 +38,18 @@ 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") === "/unlisted/:slug" + ) { + return Response.json([{ slug: "known" }]); + } if ( url.pathname === "/__vinext/prerender/static-params" && url.searchParams.get("pattern") === "/:path+" @@ -120,6 +132,28 @@ describe("prerender path manifest", () => { loadingShellPaths: ["/cached/intro", "/cached/featured"], rscBuildId: "rsc-build-a", responseVary: "verbatim", + routePatterns: { + "/": { + cacheabilityProbe: { canPrunePattern: true }, + kind: "app-page", + pattern: "/", + }, + "/cached/featured": { + cacheabilityProbe: { canPrunePattern: true }, + kind: "app-page", + pattern: "/cached/:slug", + }, + "/cached/intro": { + cacheabilityProbe: { canPrunePattern: true }, + kind: "app-page", + pattern: "/cached/:slug", + }, + "/dynamic": { + cacheabilityProbe: { canPrunePattern: true }, + kind: "app-page", + pattern: "/dynamic", + }, + }, rscPaths: ["/", "/dynamic", "/cached/intro", "/cached/featured"], trailingSlash: false, paths: ["/", "/dynamic", "/cached/intro", "/cached/featured"], @@ -143,6 +177,82 @@ 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("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", + [ + "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' }] },", + " { 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' }] },", + " ],", + "};", + ].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 }, + }, + "/cookie": { + cacheabilityProbe: { canPrunePattern: true }, + }, + "/policy/ordinary": { + cacheabilityProbe: { canPrunePattern: false }, + }, + "/policy/special": { + cacheabilityProbe: { canPrunePattern: false }, + }, + "/unlisted/known": { + cacheabilityProbe: { canPrunePattern: false }, + }, + "/wildcard/path": { + cacheabilityProbe: { canPrunePattern: false }, + }, + }); + }); + it("discovers only Next.js-static Route Handler GET identities", async () => { // Ported from Next.js static eligibility and dynamic Route Handler params: // packages/next/src/server/route-modules/app-route/helpers/is-static-gen-enabled.ts @@ -796,6 +906,271 @@ 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("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" })); + 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/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"); + + 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", + [ + "export function generateStaticParams() { return []; }", + "export function GET() { return Response.json({ ok: true }); }", + ].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", + [ + 'export const dynamic = "force-static";', + "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, 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" }); + + 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"); + 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"); @@ -1113,6 +1488,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 () => { @@ -1221,6 +1603,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), @@ -1394,6 +1780,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 () => { diff --git a/tests/worker-prerender-discovery.test.ts b/tests/worker-prerender-discovery.test.ts index 8e63387f5..6c87138de 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,40 @@ 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(response?.headers.get("x-vinext-prerender-readiness")).toBe("1"); + 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"); + }); });