From 69173f582271ab35bfd534f09d62a99ce7c65eba Mon Sep 17 00:00:00 2001 From: James Date: Wed, 26 Aug 2026 16:36:31 +0100 Subject: [PATCH 01/14] feat(cache): probe Pages Router cacheability --- packages/cloudflare/src/cacheability-probe.ts | 4 +- packages/cloudflare/src/cdn-warm.ts | 2 + packages/cloudflare/src/deploy.ts | 11 +-- packages/vinext/src/index.ts | 40 +++++++-- .../src/server/cacheability-manifest.ts | 15 ++-- .../vinext/src/server/cacheability-request.ts | 56 +++++++++--- .../vinext/src/server/pages-page-handler.ts | 52 +++++++++-- .../vinext/src/server/pages-router-entry.ts | 74 +++++++++++---- .../src/shims/cacheability-classification.ts | 7 +- tests/cacheability-manifest.test.ts | 28 +++++- tests/cloudflare-cacheability-probe.test.ts | 40 +++++++++ tests/cloudflare-cdn-warm-deploy.test.ts | 90 +++++++++++++------ .../cacheability-probe.spec.ts | 64 +++++++++++++ .../pages-cacheability.spec.ts | 64 +++++++++++++ .../cacheability-manifest.json | 32 +++++++ .../cacheability-pages/get-initial-props.tsx | 7 ++ .../pages/cacheability-pages/gssp.tsx | 7 ++ .../pages/cacheability-pages/isr.tsx | 7 ++ .../pages/cacheability-pages/posts/[slug].tsx | 11 +++ 19 files changed, 524 insertions(+), 87 deletions(-) create mode 100644 tests/e2e/cloudflare-pages-router/cacheability-probe.spec.ts create mode 100644 tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts create mode 100644 tests/fixtures/ppr-impact-demo/pages/cacheability-pages/get-initial-props.tsx create mode 100644 tests/fixtures/ppr-impact-demo/pages/cacheability-pages/gssp.tsx create mode 100644 tests/fixtures/ppr-impact-demo/pages/cacheability-pages/isr.tsx create mode 100644 tests/fixtures/ppr-impact-demo/pages/cacheability-pages/posts/[slug].tsx diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index 077fe5c0d5..7959917901 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -314,7 +314,7 @@ export async function probeStagedWorkerCacheability(options: { } if ( result.version !== 1 || - result.kind !== "app-page" || + (result.kind !== "app-page" && result.kind !== "pages-page") || typeof result.pattern !== "string" || !result.pattern.startsWith("/") || !isProbeRouteState(result.state) || @@ -335,7 +335,7 @@ export async function probeStagedWorkerCacheability(options: { if (result.state !== "static-candidate") continue; const route: CacheabilityManifestRoute = { - kind: "app-page", + kind: result.kind, pattern: result.pattern, representation: identity.representation, requestKey: identity.requestKey, diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index ab31de1b7d..ea74198faa 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -91,6 +91,7 @@ export type PrerenderWarmPlan = { buildIdentity?: string; deploymentId?: string; loadingShellPaths: string[]; + pagesPaths?: string[]; paths: string[]; rscBuildId?: string; rscPaths: string[]; @@ -241,6 +242,7 @@ export function readPrerenderWarmPlan( loadingShellPaths: supportsCanonicalRsc ? (manifest.loadingShellPaths ?? []).map(applyConfig) : [], + ...(manifest.pagesPaths ? { pagesPaths: manifest.pagesPaths.map(applyConfig) } : {}), paths: htmlPaths, ...(supportsCanonicalRsc ? { rscBuildId: manifest.rscBuildId } : {}), rscPaths: supportsCanonicalRsc ? manifest.rscPaths!.map(applyConfig) : [], diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 1cf3a0685b..1b47a36511 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -1372,16 +1372,17 @@ async function deployWithCacheabilityProbe( ...discovered, appPaths: discovered.appPaths ? [...discovered.appPaths] : undefined, loadingShellPaths: [...discovered.loadingShellPaths], + pagesPaths: discovered.pagesPaths ? [...discovered.pagesPaths] : undefined, paths: [...discovered.paths], rscPaths: [...discovered.rscPaths], }; - if (!plan.appPaths) { + if (!plan.appPaths && !plan.pagesPaths) { throw new Error( - "Two-stage CDN warming requires staged discovery to report App Page route ownership.", + "Two-stage CDN warming requires staged discovery to report App or Pages route ownership.", ); } - const appPathSet = new Set(plan.appPaths); - plan.paths = plan.paths.filter((pathname) => appPathSet.has(pathname)); + const ownedHtmlPaths = new Set([...(plan.appPaths ?? []), ...(plan.pagesPaths ?? [])]); + plan.paths = plan.paths.filter((pathname) => ownedHtmlPaths.has(pathname)); const targets = await createCdnWarmTargets({ deploymentId: plan.deploymentId, headers, @@ -1415,7 +1416,7 @@ async function deployWithCacheabilityProbe( ); } else { console.log( - " CDN warmup: no App Page request identities were discovered; embedding an empty fail-closed cacheability manifest.", + " CDN warmup: no page request identities were discovered; embedding an empty fail-closed cacheability manifest.", ); } const probe = await probeStagedWorkerCacheability({ diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 353d3804c8..6be3578268 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -288,6 +288,7 @@ import { getPagesPreviewModeId } from "./server/pages-preview.js"; import commonjs from "vite-plugin-commonjs"; import { createIgnoreDynamicRequestsPlugin } from "./plugins/ignore-dynamic-requests.js"; import { createTransformCache } from "./plugins/transform-cache.js"; +import { isServerEnvironment } from "./plugins/environment.js"; import { isPathInside, isPathInsideOrEqual, @@ -3905,7 +3906,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // App Router virtual modules if (cleanId === VIRTUAL_RSC_ENTRY) return RESOLVED_RSC_ENTRY; if (cleanId === VIRTUAL_CACHEABILITY_MANIFEST) { - if (this.environment?.name === "rsc" && this.environment.config?.command === "build") { + const isWorkerBuildEnvironment = hasAppDir + ? this.environment?.name === "rsc" + : this.environment !== undefined && isServerEnvironment(this.environment); + if (isWorkerBuildEnvironment && this.environment.config?.command === "build") { return { id: `./${CACHEABILITY_MANIFEST_MODULE}`, external: true }; } return RESOLVED_CACHEABILITY_MANIFEST; @@ -4410,7 +4414,10 @@ export const loadServerActionClient = ${ apply: "build", generateBundle() { - if (this.environment?.name !== "rsc") return; + const isWorkerBuildEnvironment = hasAppDir + ? this.environment?.name === "rsc" + : this.environment !== undefined && isServerEnvironment(this.environment); + if (!isWorkerBuildEnvironment) return; this.emitFile({ type: "asset", fileName: CACHEABILITY_MANIFEST_MODULE, @@ -6753,16 +6760,35 @@ export const loadServerActionClient = ${ sequential: true, order: "post" as const, handler(options: { dir?: string }) { - const envName = this.environment?.name; - // Fire for App Router RSC builds (rsc env) and Pages Router SSR builds - // (ssr env). Skip client and other environments. - if (envName !== "rsc" && envName !== "ssr") return; + const environment = this.environment; + // App Router metadata belongs to its RSC build. Pages Router may use + // Vite's `ssr` environment or a platform-owned server environment + // such as the one created by the Cloudflare Vite plugin. + if ( + !environment || + (hasAppDir ? environment.name !== "rsc" : !isServerEnvironment(environment)) + ) { + return; + } const outDir = options.dir; if (!outDir) return; const manifest = { prerenderSecret }; - fs.writeFileSync(path.join(outDir, "vinext-server.json"), JSON.stringify(manifest)); + const source = JSON.stringify(manifest); + fs.writeFileSync(path.join(outDir, "vinext-server.json"), source); + + // Staged discovery and cacheability probing deliberately read build + // metadata from the platform-independent server directory. A Pages + // Worker bundle may live in a platform-named output directory, so + // retain the adjacent copy above and also publish the canonical copy. + if (!hasAppDir) { + const canonicalServerDir = path.join(root, "dist", "server"); + if (path.resolve(outDir) !== canonicalServerDir) { + fs.mkdirSync(canonicalServerDir, { recursive: true }); + fs.writeFileSync(path.join(canonicalServerDir, "vinext-server.json"), source); + } + } }, }, }, diff --git a/packages/vinext/src/server/cacheability-manifest.ts b/packages/vinext/src/server/cacheability-manifest.ts index 08d2144a10..2c5b2d7241 100644 --- a/packages/vinext/src/server/cacheability-manifest.ts +++ b/packages/vinext/src/server/cacheability-manifest.ts @@ -16,6 +16,7 @@ import { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL } from "./app-rsc-render-mod export const CACHEABILITY_MANIFEST_MODULE = "__vinext_cacheability_manifest.js"; +export type CacheabilityRouteKind = "app-page" | "pages-page"; export type CacheabilityRepresentation = "html" | "rsc-full" | "rsc-loading-shell"; type CacheabilityManifestRouteState = | "static-candidate" @@ -24,7 +25,7 @@ type CacheabilityManifestRouteState = | "probe-failed"; export type CacheabilityManifestRoute = { - kind: "app-page"; + kind: CacheabilityRouteKind; pattern: string; representation: CacheabilityRepresentation; requestKey: string; @@ -64,7 +65,7 @@ function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | nu if (!value || typeof value !== "object" || Array.isArray(value)) return null; const route = value as Record; if ( - route.kind !== "app-page" || + (route.kind !== "app-page" && route.kind !== "pages-page") || typeof route.pattern !== "string" || !route.pattern.startsWith("/") || !isRepresentation(route.representation) || @@ -78,7 +79,7 @@ function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | nu return null; } const parsed: CacheabilityManifestRoute = { - kind: "app-page", + kind: route.kind, pattern: route.pattern, representation: route.representation, requestKey: route.requestKey, @@ -175,17 +176,13 @@ export function cacheabilityRequestIdentity(request: Request): { export function findCacheabilityManifestRoute( manifest: CacheabilityManifest, + kind: CacheabilityRouteKind, pattern: string, identity: { representation: CacheabilityRepresentation; requestKey: string }, ): CacheabilityManifestRoute | null { return ( manifest.routes[ - cacheabilityManifestRouteKey( - "app-page", - pattern, - identity.representation, - identity.requestKey, - ) + cacheabilityManifestRouteKey(kind, pattern, identity.representation, identity.requestKey) ] ?? null ); } diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index c860b511ee..d2da2ebcaa 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -7,6 +7,7 @@ import { } from "vinext/shims/cacheability-classification"; import { applyCdnResponseHeaders, + hasExplicitNonCacheableResponsePolicy, isNonCacheableCacheControl, NO_STORE_CACHE_CONTROL, } from "./cache-control.js"; @@ -37,7 +38,7 @@ type CacheabilityProbeRouteState = type CacheabilityProbeResult = { cacheControl?: string; - kind?: "app-page" | "app-route"; + kind?: "app-page" | "app-route" | "pages-page"; pattern?: string; reason?: string; state: CacheabilityProbeRouteState; @@ -422,6 +423,42 @@ function inferFinalAppPageCacheability( }; } +function inferPagesPageCacheability(response: Response): RouteCacheabilityOutcome { + const cacheControl = + response.headers.get("Cloudflare-CDN-Cache-Control") ?? + response.headers.get("CDN-Cache-Control") ?? + response.headers.get("Cache-Control"); + if (!cacheControl || isNonCacheableCacheControl(cacheControl)) { + return { cacheable: false }; + } + const cacheTag = response.headers.get("Cache-Tag"); + return { + cacheable: true, + cacheControl, + ...(cacheTag + ? { + tags: cacheTag + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean), + } + : {}), + }; +} + +function completedRouteOutcome( + response: Response, + state: RouteCacheabilityState, + rendererOutcome: RouteCacheabilityOutcome | null = state.outcome ?? null, +): RouteCacheabilityOutcome | null { + if (state.route?.kind === "app-page") { + return inferFinalAppPageCacheability(response, state) ?? rendererOutcome; + } + if (state.route?.kind !== "pages-page") return rendererOutcome; + if (hasExplicitNonCacheableResponsePolicy(response.headers)) return { cacheable: false }; + return rendererOutcome ?? inferPagesPageCacheability(response); +} + function staticToDynamicResponse(route: CacheabilityManifestRoute): Response { const headers = new Headers({ "Content-Type": "text/plain; charset=utf-8" }); applyCdnResponseHeaders(headers, { cacheControl: NO_STORE_CACHE_CONTROL }); @@ -496,10 +533,10 @@ async function finalizeWorkerCacheabilityAdmission( let manifestRoute: CacheabilityManifestRoute | null = null; if (admission.policy === "manifest") { const manifest = admission.manifest as CacheabilityManifest; - manifestRoute = findCacheabilityManifestRoute(manifest, state.route.pattern, { + manifestRoute = findCacheabilityManifestRoute(manifest, state.route.kind, state.route.pattern, { representation: admission.representation as Parameters< typeof findCacheabilityManifestRoute - >[2]["representation"], + >[3]["representation"], requestKey: admission.requestKey, }); if ( @@ -537,10 +574,8 @@ async function finalizeWorkerCacheabilityAdmission( return responseWithCachePolicy(response, captured.body, null); } - let outcome = state.completion ? await state.completion : (state.outcome ?? null); - if (state.route.kind === "app-page") { - outcome = inferFinalAppPageCacheability(response, state) ?? outcome; - } + const rendererOutcome = state.completion ? await state.completion : (state.outcome ?? null); + const outcome = completedRouteOutcome(response, state, rendererOutcome); if (outcome?.cacheable !== true || !outcome.cacheControl) { // Next.js throws a static-to-dynamic error only when the runtime render // actually observed dynamic usage. An absent outcome can also mean the @@ -576,7 +611,7 @@ export async function finalizeWorkerCacheabilityResponse( state.route ? "runtime-check" : "probe-failed", state.route ? { cacheable: false } - : { cacheable: false, reason: "request did not resolve to a probeable App Page" }, + : { cacheable: false, reason: "request did not resolve to a probeable page route" }, response.status, ); } @@ -586,7 +621,7 @@ export async function finalizeWorkerCacheabilityResponse( return probeResponse( state, "probe-failed", - { cacheable: false, reason: "request did not resolve to a probeable App Page" }, + { cacheable: false, reason: "request did not resolve to a probeable page route" }, response.status, ); } @@ -619,7 +654,8 @@ export async function finalizeWorkerCacheabilityResponse( ); } - const outcome = state.completion ? await state.completion : state.outcome; + const rendererOutcome = state.completion ? await state.completion : (state.outcome ?? null); + const outcome = completedRouteOutcome(response, state, rendererOutcome); if (!outcome) { return probeResponse( state, diff --git a/packages/vinext/src/server/pages-page-handler.ts b/packages/vinext/src/server/pages-page-handler.ts index 86c60b3666..dff7257831 100644 --- a/packages/vinext/src/server/pages-page-handler.ts +++ b/packages/vinext/src/server/pages-page-handler.ts @@ -33,6 +33,7 @@ import type { PagesI18nRenderContext } from "./pages-page-response.js"; import type { RenderPageEnhancers } from "./pages-document-initial-props.js"; import { BROWSER_REVALIDATE_CACHE_CONTROL, + STATIC_CACHE_CONTROL, applyCdnResponseHeaders, hasExplicitNonCacheableResponsePolicy, shouldUseNextDeployCacheControl, @@ -64,6 +65,12 @@ import { } from "vinext/shims/unified-request-context"; import { getRequestExecutionContext } from "vinext/shims/request-context"; import { ensureFetchPatch } from "vinext/shims/fetch-cache"; +import { + beginRouteCacheability, + isRouteCacheabilityIdentityProbe, + isRouteCacheabilityProbe, + recordRouteCacheability, +} from "vinext/shims/cacheability-classification"; import { collectAssetTags, resolveClientModuleUrl } from "./pages-asset-tags.js"; import { NEXTJS_CACHE_HEADER, @@ -554,6 +561,39 @@ export function createPagesPageHandler( const { route, params } = match; const pageModule = route.module; const isStaticPropsRoute = typeof pageModule.getStaticProps === "function"; + const pagesReadiness = buildPagesReadinessNextData({ + pageModule, + appComponent: AppComponent as { getInitialProps?: unknown; origGetInitialProps?: unknown }, + hasRewrites, + }); + const isCacheabilityProbe = isRouteCacheabilityProbe(); + const isTopLevelPageRoute = + !isDataReq && !isRouteMissErrorRender && options?.__forcedRoute === undefined; + const hasRequestTimeData = + pagesReadiness.gssp === true || pagesReadiness.gip === true || pagesReadiness.appGip === true; + + if (isTopLevelPageRoute) { + beginRouteCacheability("pages-page", route.pattern); + if (isRouteCacheabilityIdentityProbe()) { + return new Response(null, { status: 204 }); + } + if (hasRequestTimeData) { + recordRouteCacheability({ cacheable: false, dynamicUsage: true }); + // Next.js never executes request-time Pages data functions while + // deciding which routes can be prerendered. The staged probe can make + // the same decision from the matched module contract. + if (isCacheabilityProbe) return new Response(null, { status: 204 }); + } else if (!isStaticPropsRoute) { + // Automatic Static Optimization is the Pages Router equivalent of a + // `getStaticProps` page with no revalidation window. It has no origin + // ISR entry to copy policy from, so carry Next.js's static policy into + // the probe/admission result explicitly. + recordRouteCacheability({ cacheable: true, cacheControl: STATIC_CACHE_CONTROL }); + } + } + + const routeIsrGet = isCacheabilityProbe ? async () => null : isrGet; + const routeIsrSet = isCacheabilityProbe ? async () => {} : isrSet; const isStaticPropsRender = isStaticPropsRoute && typeof pageModule.getServerSideProps !== "function"; const shouldCoalesceOnDemand = @@ -648,11 +688,7 @@ export function createPagesPageHandler( : ({ data: false, shouldClear: false } satisfies PagesPreviewState); const previewData = preview.data; const pagesNextData = { - ...buildPagesReadinessNextData({ - pageModule, - appComponent: AppComponent as { getInitialProps?: unknown } | null, - hasRewrites, - }), + ...pagesReadiness, ...(previewData === false ? {} : { isPreview: true as const }), }; // Match Next.js's ServerRouter: SSG renders are not ready on the @@ -811,8 +847,8 @@ export function createPagesPageHandler( fontLinkHeader, i18n: buildI18nRenderContext(i18nConfig, locale, currentDefaultLocale, domainLocales), isrCacheKey: pageIsrCacheKey, - isrGet, - isrSet, + isrGet: routeIsrGet, + isrSet: routeIsrSet, expireSeconds: vinextConfig.expireTime, isBuildTimePrerendering: typeof process !== "undefined" && process.env && process.env.VINEXT_PRERENDER === "1", @@ -1076,7 +1112,7 @@ export function createPagesPageHandler( isrRevalidateSeconds, isOnDemandRevalidate, isStaticPropsRoute, - isrSet, + isrSet: routeIsrSet, i18n: buildI18nRenderContext(i18nConfig, locale, currentDefaultLocale, domainLocales), isFallback: isFallbackRender, pageProps, diff --git a/packages/vinext/src/server/pages-router-entry.ts b/packages/vinext/src/server/pages-router-entry.ts index ef849b0ee7..7382130c9a 100644 --- a/packages/vinext/src/server/pages-router-entry.ts +++ b/packages/vinext/src/server/pages-router-entry.ts @@ -37,7 +37,12 @@ import { finalizeMissingStaticAssetResponse } from "./worker-utils.js"; import { assetPrefixPathname, isNextStaticPath } from "../utils/asset-prefix.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; import { createWorkerRevalidationContext } from "./worker-revalidation-context.js"; -import { VINEXT_PRERENDER_SECRET_HEADER, VINEXT_REVALIDATE_HOST_HEADER } from "./headers.js"; +import { + VINEXT_CACHEABILITY_PROBE_HEADER, + VINEXT_CACHEABILITY_PROBE_QUERY_PARAM, + VINEXT_PRERENDER_SECRET_HEADER, + VINEXT_REVALIDATE_HOST_HEADER, +} from "./headers.js"; import { runWithExecutionContext, type ExecutionContextLike } from "vinext/shims/request-context"; import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; import { @@ -52,6 +57,8 @@ import { applyCdnResponseIdentityHeaders, validateCdnRequest } from "./cache-con import { registerConfiguredImageOptimizer } from "virtual:vinext-image-adapters"; // @ts-expect-error -- virtual module resolved by vinext at build time import * as pagesEntry from "virtual:vinext-server-entry"; +// @ts-expect-error -- virtual module resolved by vinext at build time +import __cacheabilityManifest from "virtual:vinext-cacheability-manifest"; type AssetFetcher = { fetch(request: Request): Promise | Response; @@ -119,11 +126,44 @@ async function handleRequest( const requestCtx = createWorkerRevalidationContext(platformCtx, (internalRequest, internalCtx) => handleRequest(internalRequest, env, internalCtx), ); - const ctx = createWorkerPrerenderDiscoveryContext( - requestCtx, - request, - pagesEntry.prerenderSecret, - ); + let ctx = createWorkerPrerenderDiscoveryContext(requestCtx, request, pagesEntry.prerenderSecret); + let finalizeCacheabilityResponse: + | ((response: Response, ctx: ExecutionContextLike) => Promise) + | undefined; + if (request.headers.has(VINEXT_CACHEABILITY_PROBE_HEADER)) { + const cacheability = await import("./cacheability-request.js"); + const probeContext = cacheability.createWorkerCacheabilityContext( + ctx, + request, + pagesEntry.prerenderSecret, + ); + if (probeContext !== ctx) { + ctx = probeContext; + finalizeCacheabilityResponse = cacheability.finalizeWorkerCacheabilityResponse; + const probeUrl = new URL(request.url); + if (probeUrl.searchParams.has(VINEXT_CACHEABILITY_PROBE_QUERY_PARAM)) { + probeUrl.searchParams.delete(VINEXT_CACHEABILITY_PROBE_QUERY_PARAM); + request = new Request(probeUrl, request); + } + } + } + if (!finalizeCacheabilityResponse && __cacheabilityManifest) { + const cacheability = await import("./cacheability-request.js"); + const admissionContext = cacheability.createWorkerCacheabilityAdmissionContext( + ctx, + request, + __cacheabilityManifest, + pagesEntry.buildId, + ); + if (admissionContext !== ctx) { + ctx = admissionContext; + finalizeCacheabilityResponse = cacheability.finalizeWorkerCacheabilityResponse; + } + } + const finalize = (response: Response): Promise => + finalizeCacheabilityResponse + ? finalizeCacheabilityResponse(response, ctx) + : Promise.resolve(response); // Pass the Worker env so binding-backed adapters (for example KV and Images) // can resolve their configured bindings before request handling begins. @@ -132,7 +172,7 @@ async function handleRequest( try { const cdnValidationResponse = await validateCdnRequest(request); - if (cdnValidationResponse) return cdnValidationResponse; + if (cdnValidationResponse) return finalize(cdnValidationResponse); const url = new URL(request.url); let pathname = url.pathname; @@ -149,7 +189,7 @@ async function handleRequest( staticParamsMap: {}, }), ); - if (response) return response; + if (response) return finalize(response); } // Block protocol-relative URL open redirects in all shapes: @@ -159,12 +199,12 @@ async function handleRequest( // Location headers, so encoded variants must be rejected before any // downstream redirect can echo them. if (isOpenRedirectShaped(pathname)) { - return new Response("This page could not be found", { status: 404 }); + return finalize(new Response("This page could not be found", { status: 404 })); } try { normalizePathnameForRouteMatchStrict(pathname); } catch { - return new Response("Bad Request", { status: 400 }); + return finalize(new Response("Bad Request", { status: 400 })); } // Valid assets are served by Cloudflare's ASSETS binding before the worker @@ -197,7 +237,7 @@ async function handleRequest( const middlewareRequest = request; const dataNorm = normalizeDataRequest(request); if (dataNorm.notFoundResponse && !vinextConfig?.skipProxyUrlNormalize) { - return dataNorm.notFoundResponse; + return finalize(dataNorm.notFoundResponse); } const isDataReq = dataNorm.isDataReq; if (isDataReq && dataNorm.normalizedPathname) { @@ -267,16 +307,18 @@ async function handleRequest( const result = await runPagesRequest(request, deps); if (result.type === "response") { - return finalizeMissingStaticAssetResponse(result.response, missingBuildAsset); + return finalize(finalizeMissingStaticAssetResponse(result.response, missingBuildAsset)); } // Should not reach here for a production Worker because all callbacks are // supplied by virtual:vinext-server-entry. - return missingBuildAsset - ? notFoundStaticAssetResponse() - : new Response("This page could not be found", { status: 404 }); + return finalize( + missingBuildAsset + ? notFoundStaticAssetResponse() + : new Response("This page could not be found", { status: 404 }), + ); } catch (error) { console.error("[vinext] Worker error:", error); - return new Response("Internal Server Error", { status: 500 }); + return finalize(new Response("Internal Server Error", { status: 500 })); } } diff --git a/packages/vinext/src/shims/cacheability-classification.ts b/packages/vinext/src/shims/cacheability-classification.ts index b2ea284f89..8ae452f646 100644 --- a/packages/vinext/src/shims/cacheability-classification.ts +++ b/packages/vinext/src/shims/cacheability-classification.ts @@ -42,7 +42,7 @@ export type RouteCacheabilityState = { outcome: RouteCacheabilityOutcome; }; route?: { - kind: "app-page" | "app-route"; + kind: "app-page" | "app-route" | "pages-page"; pattern: string; }; }; @@ -62,7 +62,10 @@ export function readRouteCacheabilityState(): RouteCacheabilityState | null { ); } -export function beginRouteCacheability(kind: "app-page" | "app-route", pattern: string): boolean { +export function beginRouteCacheability( + kind: "app-page" | "app-route" | "pages-page", + pattern: string, +): boolean { const state = readRouteCacheabilityState(); if (!state) return false; state.route = { kind, pattern }; diff --git a/tests/cacheability-manifest.test.ts b/tests/cacheability-manifest.test.ts index 4301547cb1..de46585f2c 100644 --- a/tests/cacheability-manifest.test.ts +++ b/tests/cacheability-manifest.test.ts @@ -28,13 +28,13 @@ describe("cacheability manifest", () => { const manifest = parseCacheabilityManifest(raw, "build-a"); expect(manifest).not.toBeNull(); expect( - findCacheabilityManifestRoute(manifest!, "/products/:id", { + findCacheabilityManifestRoute(manifest!, "app-page", "/products/:id", { representation: "html", requestKey: "/products/one?currency=gbp", }), ).toEqual(route); expect( - findCacheabilityManifestRoute(manifest!, "/products/:id", { + findCacheabilityManifestRoute(manifest!, "app-page", "/products/:id", { representation: "html", requestKey: "/products/two?currency=gbp", }), @@ -42,6 +42,30 @@ describe("cacheability manifest", () => { 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 manifest = parseCacheabilityManifest( + JSON.stringify({ + buildId: "build-a", + routes: { [key]: route, [pagesKey]: pagesRoute }, + version: 1, + }), + "build-a", + ); + expect( + findCacheabilityManifestRoute(manifest!, "pages-page", "/products/:id", { + representation: "html", + requestKey: "/products/one?currency=gbp", + }), + ).toEqual(pagesRoute); + }); + it("rejects malformed routes instead of partially trusting a manifest", () => { expect( parseCacheabilityManifest( diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index b7435dc177..92b26572a0 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -314,4 +314,44 @@ describe("staged Worker cacheability probes", () => { ).rejects.toThrow(`the limit is ${exactBytes} bytes`); expect(overflowFetch).toHaveBeenCalledTimes(2); }); + + it("records Pages Router probe envelopes without changing request identity", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-cacheability-probe-pages-")); + roots.push(root); + fs.mkdirSync(path.join(root, "dist", "server"), { recursive: true }); + fs.writeFileSync( + path.join(root, "dist", "server", "vinext-server.json"), + JSON.stringify({ prerenderSecret: "probe-secret" }), + ); + const target = { + headers: { Accept: "text/html" }, + kind: "html" as const, + label: "/posts/one", + pathname: "/posts/one", + sourcePathname: "/posts/one", + }; + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async () => + Response.json({ + kind: "pages-page", + pattern: "/posts/:slug", + state: "static-candidate", + status: 200, + version: 1, + }), + root, + targetUrl: "https://example.com", + targets: [target], + }); + + expect(result.failures).toEqual([]); + expect(Object.values(result.manifest.routes)).toEqual([ + expect.objectContaining({ + kind: "pages-page", + pattern: "/posts/:slug", + requestKey: "/posts/one", + }), + ]); + }); }); diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 9c5a8dc6f4..3f609247af 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -190,6 +190,24 @@ function mockTwoStageWrangler( return state; } +function pagesPageProbeResponse() { + return Response.json( + { + kind: "pages-page", + pattern: "/pages-about", + state: "static-candidate", + status: 200, + version: 1, + }, + { + headers: { + "Cache-Control": "no-store", + [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a", + }, + }, + ); +} + describe("Cloudflare CDN warmup deploy flow", () => { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-cdn-warm-deploy-test-")); @@ -332,7 +350,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { let uploadCount = 0; let statusCount = 0; let finalStaged = false; - let cacheRequestCount = 0; + const cacheRequestCounts = new Map(); let finalManifestSource = ""; let finalConfig: unknown; @@ -393,26 +411,31 @@ 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}`); - return pathname === "/dynamic" - ? Response.json( - { - kind: "app-page", - pattern: "/dynamic", - state: "dynamic", - status: 200, - version: 1, - }, - { headers: { [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a" } }, - ) - : appPageProbeResponse(); + events.push(`probe:${pathname}`); + if (pathname === "/pages-about") return pagesPageProbeResponse(); + if (pathname === "/dynamic") { + return Response.json( + { + kind: "app-page", + pattern: "/dynamic", + state: "dynamic", + status: 200, + version: 1, + }, + { headers: { [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a" } }, + ); + } + return appPageProbeResponse(); } if (isReadinessFetch(input)) events.push("readiness"); else { - cacheRequestCount++; - events.push(cacheRequestCount === 1 ? "warm" : "unexpected-second-request"); + 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}`); } - return cacheableHtml("ok", cacheRequestCount > 1 ? "HIT" : "MISS"); + const pathname = new URL(formatFetchUrl(input)).pathname; + return cacheableHtml("ok", (cacheRequestCounts.get(pathname) ?? 0) > 1 ? "HIT" : "MISS"); }); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -424,8 +447,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildId: "app-build-a", buildIdentity: "app-build-a", loadingShellPaths: [], - // The discovery manifest can contain a mixed App/Pages HTML plan. - // This stack only certifies App Pages; the Pages path stays private. + pagesPaths: ["/pages-about"], paths: ["/about", "/dynamic", "/pages-about"], rscPaths: [], }), @@ -438,15 +460,19 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(deployedUrl).toBe("https://my-worker.example.workers.dev"); expect(uploadCount).toBe(2); expect(statusCount).toBe(7); - expect(cacheRequestCount).toBe(1); + expect(Array.from(cacheRequestCounts.entries())).toEqual([ + ["/about", 1], + ["/pages-about", 1], + ]); expect(events).toEqual([ "upload-probe", "status-1", "stage-probe", "status-2", "readiness", - "probe-/about", - "probe-/dynamic", + "probe:/about", + "probe:/dynamic", + "probe:/pages-about", "status-3", "upload-final", "status-4", @@ -455,7 +481,8 @@ describe("Cloudflare CDN warmup deploy flow", () => { "status-6", "triggers", "readiness", - "warm", + "warm:/about", + "warm:/pages-about", "status-7", "promote-final", ]); @@ -468,9 +495,20 @@ describe("Cloudflare CDN warmup deploy flow", () => { routes: Record; }; expect(manifest.buildId).toBe("app-build-a"); - expect(Object.values(manifest.routes)).toEqual([ - expect.objectContaining({ pattern: "/about", state: "static-candidate" }), - ]); + expect(Object.values(manifest.routes)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "app-page", + pattern: "/about", + state: "static-candidate", + }), + expect.objectContaining({ + kind: "pages-page", + pattern: "/pages-about", + state: "static-candidate", + }), + ]), + ); expect( fs.readFileSync(path.join(tmpDir, "dist/server", CACHEABILITY_MANIFEST_MODULE), "utf8"), ).toBe("export default null;\n"); diff --git a/tests/e2e/cloudflare-pages-router/cacheability-probe.spec.ts b/tests/e2e/cloudflare-pages-router/cacheability-probe.spec.ts new file mode 100644 index 0000000000..eae6875fb2 --- /dev/null +++ b/tests/e2e/cloudflare-pages-router/cacheability-probe.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const BASE = "http://localhost:4177"; +const probeHeader = "X-Vinext-Cacheability-Probe"; +const secretHeader = "X-Vinext-Prerender-Secret"; + +function readPrerenderSecret(): string { + const manifest = JSON.parse( + fs.readFileSync("examples/pages-router-cloudflare/dist/server/vinext-server.json", "utf8"), + ) as { prerenderSecret: string }; + return manifest.prerenderSecret; +} + +test("emits the cacheability manifest as part of the Pages Worker artifact", () => { + const wranglerPath = + "examples/pages-router-cloudflare/dist/pages_router_cloudflare/wrangler.json"; + const wrangler = JSON.parse(fs.readFileSync(wranglerPath, "utf8")) as { main: string }; + expect( + fs.existsSync( + path.join( + path.dirname(wranglerPath), + path.dirname(wrangler.main), + "__vinext_cacheability_manifest.js", + ), + ), + ).toBe(true); +}); + +test("classifies Pages data contracts inside the staged Worker", async ({ request }) => { + const headers = { + [probeHeader]: "1", + [secretHeader]: readPrerenderSecret(), + }; + + // Ported from Next.js: test/e2e/prerender.test.ts and + // test/e2e/getserversideprops/test/index.test.ts. + // https://github.com/vercel/next.js/blob/canary/test/e2e/prerender.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/getserversideprops/test/index.test.ts + for (const pathname of ["/about", "/revalidate-target"]) { + const response = await request.get(`${BASE}${pathname}`, { headers }); + expect(response.ok(), pathname).toBe(true); + await expect(response.json(), pathname).resolves.toMatchObject({ + kind: "pages-page", + pattern: pathname, + state: "static-candidate", + status: 200, + version: 1, + }); + } + + for (const pathname of ["/", "/ssr"]) { + const response = await request.get(`${BASE}${pathname}`, { headers }); + expect(response.ok(), pathname).toBe(true); + await expect(response.json(), pathname).resolves.toMatchObject({ + kind: "pages-page", + pattern: pathname, + state: "dynamic", + status: 204, + version: 1, + }); + } +}); diff --git a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts new file mode 100644 index 0000000000..2fc91b1f36 --- /dev/null +++ b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; + +const probeHeader = "X-Vinext-Cacheability-Probe"; +const secretHeader = "X-Vinext-Prerender-Secret"; + +function prerenderSecret(): string { + const manifest = JSON.parse( + fs.readFileSync("tests/fixtures/ppr-impact-demo/dist/server/vinext-server.json", "utf8"), + ) as { prerenderSecret: string }; + return manifest.prerenderSecret; +} + +test("classifies Pages Router data contracts inside the staged Worker", async ({ request }) => { + const headers = { [probeHeader]: "1", [secretHeader]: prerenderSecret() }; + + // Ported from Next.js: test/e2e/prerender.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/prerender.test.ts + for (const [pathname, pattern] of [ + ["/cacheability-pages/isr", "/cacheability-pages/isr"], + ["/cacheability-pages/posts/known", "/cacheability-pages/posts/:slug"], + ] as const) { + const response = await request.get(pathname, { headers }); + expect(response.ok(), pathname).toBe(true); + await expect(response.json(), pathname).resolves.toMatchObject({ + kind: "pages-page", + pattern, + state: "static-candidate", + status: 200, + version: 1, + }); + } + + for (const pathname of ["/cacheability-pages/gssp", "/cacheability-pages/get-initial-props"]) { + const response = await request.get(pathname, { headers }); + expect(response.ok(), pathname).toBe(true); + await expect(response.json(), pathname).resolves.toMatchObject({ + kind: "pages-page", + pattern: pathname, + state: "dynamic", + status: 204, + version: 1, + }); + } +}); + +test("admits only exact manifest-backed Pages Router responses", async ({ request }) => { + for (const pathname of ["/cacheability-pages/isr", "/cacheability-pages/posts/known"]) { + const response = await request.get(pathname, { headers: { Accept: "text/html" } }); + expect(response.status(), pathname).toBe(200); + expect(response.headers()["cdn-cache-control"], pathname).toContain("max-age=60"); + } + + for (const pathname of [ + "/cacheability-pages/gssp", + "/cacheability-pages/get-initial-props", + "/cacheability-pages/isr?unlisted=1", + "/cacheability-pages/posts/unknown", + ]) { + const response = await request.get(pathname, { headers: { Accept: "text/html" } }); + expect(response.headers()["cache-control"], pathname).toContain("no-store"); + expect(response.headers()["cdn-cache-control"], pathname).toBeUndefined(); + } +}); diff --git a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json index 2fc3cdd2c5..f8abb83544 100644 --- a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json +++ b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json @@ -80,6 +80,38 @@ "requestKey": "/cacheability/static-to-dynamic/runtime", "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/gssp\",\"html\",\"/cacheability-pages/gssp\"]": { + "kind": "pages-page", + "pattern": "/cacheability-pages/gssp", + "representation": "html", + "requestKey": "/cacheability-pages/gssp", + "state": "dynamic", + "status": 204 + }, + "[\"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": 204 + }, + "[\"pages-page\",\"/cacheability-pages/posts/:slug\",\"html\",\"/cacheability-pages/posts/known\"]": { + "kind": "pages-page", + "pattern": "/cacheability-pages/posts/:slug", + "representation": "html", + "requestKey": "/cacheability-pages/posts/known", + "state": "static-candidate", + "status": 200 } }, "version": 1 diff --git a/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/get-initial-props.tsx b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/get-initial-props.tsx new file mode 100644 index 0000000000..39bfd4d487 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/get-initial-props.tsx @@ -0,0 +1,7 @@ +function PagesGetInitialProps({ value }: { value: string }) { + return

{value}

; +} + +PagesGetInitialProps.getInitialProps = async () => ({ value: "pages-get-initial-props" }); + +export default PagesGetInitialProps; diff --git a/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/gssp.tsx b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/gssp.tsx new file mode 100644 index 0000000000..f075e4ed91 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/gssp.tsx @@ -0,0 +1,7 @@ +export async function getServerSideProps() { + return { props: { value: "pages-gssp" } }; +} + +export default function PagesGssp({ value }: { value: string }) { + return

{value}

; +} diff --git a/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/isr.tsx b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/isr.tsx new file mode 100644 index 0000000000..c7144c8c55 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/isr.tsx @@ -0,0 +1,7 @@ +export async function getStaticProps() { + return { props: { value: "pages-isr" }, revalidate: 60 }; +} + +export default function PagesIsr({ value }: { value: string }) { + return

{value}

; +} 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 new file mode 100644 index 0000000000..cd69e3b793 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/posts/[slug].tsx @@ -0,0 +1,11 @@ +export async function getStaticPaths() { + return { fallback: false, paths: [{ params: { slug: "known" } }] }; +} + +export async function getStaticProps({ params }: { params: { slug: string } }) { + return { props: { slug: params.slug }, revalidate: 60 }; +} + +export default function PagesPost({ slug }: { slug: string }) { + return

pages-post-{slug}

; +} From 36a10d0f67fb05f9877bdb1b86c253ab974ab305 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 26 Aug 2026 19:26:24 +0100 Subject: [PATCH 02/14] test(cache): prove Pages prewarm reuse --- tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts b/tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts index 4e30a28e5c..9e935f9090 100644 --- a/tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts +++ b/tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts @@ -162,7 +162,7 @@ async function waitForStablePromotion({ ); } -test("deploy-prewarmed App HTML and RSC variants are reused", async ({ +test("deploy-prewarmed App, Pages, and RSC variants are reused", async ({ baseURL, browser, playwright, @@ -224,10 +224,11 @@ test("deploy-prewarmed App HTML and RSC variants are reused", async ({ expect(pagesResponse.ok(), JSON.stringify(pagesResponseHeaders)).toBe(true); expect(pagesResponseHeaders["content-type"]).toContain("text/html"); expect(pagesResponseHeaders["x-vinext-build-id"]).toBe(rscBuildId); - // This layer only probes App Pages. Pages Router keeps its existing - // route-owned cache policy. Existing path discovery may already have filled - // this entry, so its current CDN residency is not part of this assertion. expect(pagesResponseHeaders["cache-control"]).toContain("public"); + expect( + pagesResponseHeaders["cf-cache-status"], + `Pages response headers: ${JSON.stringify(pagesResponseHeaders)}`, + ).toBe("HIT"); expect(await pagesResponse.text()).toContain("Pages prewarm target"); const appHtmlResponse = await getResponseAfterPromotion( From 3fb4ffc096bebfc112ce3149adfd6aa081031dd1 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 26 Aug 2026 20:04:41 +0100 Subject: [PATCH 03/14] fix(cache): fail closed for Pages request variants --- packages/cloudflare/src/deploy.ts | 16 +++++- .../vinext/src/server/cacheability-request.ts | 7 ++- .../src/server/pages-request-pipeline.ts | 7 +++ tests/cacheability-admission.test.ts | 53 ++++++++++++++++++ tests/deploy.test.ts | 27 +++++++++ .../pages-cacheability.spec.ts | 55 +++++++++++++++++++ .../cacheability-manifest.json | 16 ++++++ tests/fixtures/ppr-impact-demo/next.config.ts | 5 ++ .../cacheability-pages/config-header.tsx | 7 +++ .../pages/cacheability-pages/middleware.tsx | 7 +++ tests/fixtures/ppr-impact-demo/proxy.ts | 4 ++ tests/pages-request-pipeline.test.ts | 29 ++++++++++ tests/shims.test.ts | 14 +++++ 13 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/ppr-impact-demo/pages/cacheability-pages/config-header.tsx create mode 100644 tests/fixtures/ppr-impact-demo/pages/cacheability-pages/middleware.tsx diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 1b47a36511..05ee3ed227 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -689,6 +689,16 @@ export function hasCdnWarmRequests(plan: CdnWarmRequestPlan): boolean { return plan.paths.length + plan.rscPaths.length + plan.loadingShellPaths.length > 0; } +export function projectRequiresRouteCacheabilityProbeManifest( + project: Pick, + cacheConfig: VinextCacheConfig | null, +): boolean { + return ( + (project.isAppRouter || project.isPagesRouter) && + requiresRouteCacheabilityProbeManifest(cacheConfig) + ); +} + type CdnWarmDeployOptions = Pick< DeployOptions, | "preview" @@ -1708,8 +1718,10 @@ export async function deploy(options: DeployOptions): Promise { }); const hasStrictResponseVary = hasVerbatimResponseVary(viteConfigMetadata.cacheConfig); const hasBuildIdentityHeader = hasBuildIdentityResponseHeader(viteConfigMetadata.cacheConfig); - const needsCacheabilityProbeManifest = - info.isAppRouter && requiresRouteCacheabilityProbeManifest(viteConfigMetadata.cacheConfig); + const needsCacheabilityProbeManifest = projectRequiresRouteCacheabilityProbeManifest( + info, + viteConfigMetadata.cacheConfig, + ); const shouldEmitPrerenderPathManifest = !options.skipBuild && prerenderDecision; // Step 5: Build diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index d2da2ebcaa..910b8b9212 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -455,7 +455,12 @@ function completedRouteOutcome( return inferFinalAppPageCacheability(response, state) ?? rendererOutcome; } if (state.route?.kind !== "pages-page") return rendererOutcome; - if (hasExplicitNonCacheableResponsePolicy(response.headers)) return { cacheable: false }; + if ( + response.headers.has("set-cookie") || + hasExplicitNonCacheableResponsePolicy(response.headers) + ) { + return { cacheable: false }; + } return rendererOutcome ?? inferPagesPageCacheability(response); } diff --git a/packages/vinext/src/server/pages-request-pipeline.ts b/packages/vinext/src/server/pages-request-pipeline.ts index 1bd4b3976f..ba55c4c383 100644 --- a/packages/vinext/src/server/pages-request-pipeline.ts +++ b/packages/vinext/src/server/pages-request-pipeline.ts @@ -43,6 +43,7 @@ import { methodNotAllowedResponse, sanitizeMethodNotAllowedHeaders, } from "./http-error-responses.js"; +import { markRouteCacheabilityDynamic } from "vinext/shims/cacheability-classification"; // All "render options" that are passed through to the renderPage callback export type PagesRenderOptions = { @@ -101,6 +102,8 @@ export async function fetchWorkerFilesystemRoute( export type MiddlewareResult = { continue: boolean; + /** The pathname matches middleware, irrespective of request `has`/`missing` conditions. */ + pathnameEligible?: boolean; redirectUrl?: string; redirectStatus?: number; rewriteUrl?: string; @@ -413,6 +416,10 @@ export async function runPagesRequest( isDataRequest, }); + if (result.pathnameEligible) { + markRouteCacheabilityDynamic("middleware can match this pathname"); + } + // Bubble waitUntil promises if (result.waitUntilPromises && result.waitUntilPromises.length > 0) { const ctx = deps.ctx as { waitUntil?: (p: Promise) => void } | null | undefined; diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index 4652ddf592..63b991c075 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -111,6 +111,27 @@ function staticManifestRoute(): { raw: string; route: CacheabilityManifestRoute }; } +function staticPagesManifestRoute(): { raw: string; route: CacheabilityManifestRoute } { + 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, + ); + return { + raw: JSON.stringify({ buildId: "build-a", routes: { [key]: route }, version: 1 }), + route, + }; +} + describe("single-request cacheability admission", () => { const request = new Request("https://example.com/page", { headers: { Accept: "text/html" }, @@ -413,6 +434,38 @@ describe("single-request cacheability admission", () => { expect(response.headers.get("Cache-Control")).toContain("no-store"); await expect(response.text()).resolves.toBe("pages"); }); + + 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", { + headers: { Accept: "text/html" }, + }); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + pagesRequest, + raw, + "build-a", + ); + const state = cacheabilityState(context); + state.route = { kind: "pages-page", pattern: "/pages-route" }; + state.outcome = { + cacheable: true, + cacheControl: "public, s-maxage=60, stale-while-revalidate=540", + }; + const setCookie = "__prerender_bypass=; Max-Age=0; Path=/"; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("pages", { headers: { "Set-Cookie": setCookie } }), + context, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + expect(response.headers.get("CDN-Cache-Control")).toBeNull(); + expect(response.headers.get("Cloudflare-CDN-Cache-Control")).toBeNull(); + expect(response.headers.get("Set-Cookie")).toBe(setCookie); + await expect(response.text()).resolves.toBe("pages"); + }); }); describe("cacheability probe finalization", () => { diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index c4b81b8d71..fb6eb2d99a 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -13,6 +13,7 @@ import { buildWranglerDeployArgs, getZeroPercentStagingTraffic, parseDeployArgs, + projectRequiresRouteCacheabilityProbeManifest, resolveWorkerNameForVersionOverride, resolveWranglerBin, runWranglerKVBulkPut, @@ -1086,6 +1087,32 @@ describe("detectProject", () => { }); }); +describe("route cacheability probe manifest deployment", () => { + const cacheConfig = { + cdn: { + adapter: "cloudflare-cdn-adapter", + capabilities: { routeCacheability: "probe-manifest" as const }, + }, + }; + + it.each([ + [{ isAppRouter: true, isPagesRouter: false }, true], + [{ isAppRouter: false, isPagesRouter: true }, true], + [{ isAppRouter: false, isPagesRouter: false }, false], + ])("requires the two-stage flow for router project %#", (project, expected) => { + expect(projectRequiresRouteCacheabilityProbeManifest(project, cacheConfig)).toBe(expected); + }); + + it("does not require probing without a manifest-capable CDN adapter", () => { + expect( + projectRequiresRouteCacheabilityProbeManifest( + { isAppRouter: false, isPagesRouter: true }, + null, + ), + ).toBe(false); + }); +}); + // ─── generateWranglerConfig ───────────────────────────────────────────────── describe("generateWranglerConfig", () => { diff --git a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts index 2fc91b1f36..9ed7e4c559 100644 --- a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts +++ b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts @@ -62,3 +62,58 @@ test("admits only exact manifest-backed Pages Router responses", async ({ reques expect(response.headers()["cdn-cache-control"], pathname).toBeUndefined(); } }); + +test("keeps middleware cookie variants private", async ({ request }) => { + // Next.js middleware matcher conditions are request-specific, but the route + // cacheability decision must be stable for the pathname. + // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-custom-matchers/test/index.test.ts + const middlewarePublic = await request.get("/cacheability-pages/middleware", { + headers: { Accept: "text/html" }, + }); + expect(middlewarePublic.status()).toBe(200); + expect(middlewarePublic.headers()["x-cacheability-middleware"]).toBeUndefined(); + expect(middlewarePublic.headers()["cache-control"]).toContain("no-store"); + expect(middlewarePublic.headers()["cdn-cache-control"]).toBeUndefined(); + + const middlewarePrivate = await request.get("/cacheability-pages/middleware", { + headers: { Accept: "text/html", Cookie: "variant=private" }, + }); + expect(middlewarePrivate.status()).toBe(200); + expect(middlewarePrivate.headers()["x-cacheability-middleware"]).toBe("matched"); + expect(middlewarePrivate.headers()["cache-control"]).toContain("no-store"); + expect(middlewarePrivate.headers()["cdn-cache-control"]).toBeUndefined(); +}); + +test("keeps config-header cookie variants private", async ({ request }) => { + const configPublic = await request.get("/cacheability-pages/config-header", { + headers: { Accept: "text/html" }, + }); + expect(configPublic.status()).toBe(200); + expect(configPublic.headers()["x-cacheability-config"]).toBeUndefined(); + expect(configPublic.headers()["cache-control"]).toContain("no-store"); + expect(configPublic.headers()["cdn-cache-control"]).toBeUndefined(); + + const configPrivate = await request.get("/cacheability-pages/config-header", { + headers: { Accept: "text/html", Cookie: "variant=private" }, + }); + expect(configPrivate.status()).toBe(200); + expect(configPrivate.headers()["x-cacheability-config"]).toBe("private"); + expect(configPrivate.headers()["cache-control"]).toContain("no-store"); + expect(configPrivate.headers()["cdn-cache-control"]).toBeUndefined(); +}); + +test("keeps invalid preview-cookie cleanup private", async ({ request }) => { + // Ported from Next.js: test/e2e/prerender-preview/prerender-preview.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/prerender-preview/prerender-preview.test.ts + const response = await request.get("/cacheability-pages/isr", { + headers: { + Accept: "text/html", + Cookie: "__prerender_bypass=invalid; __next_preview_data=invalid", + }, + }); + + expect(response.status()).toBe(200); + expect(response.headers()["set-cookie"]).toBeDefined(); + expect(response.headers()["cache-control"]).toContain("no-store"); + expect(response.headers()["cdn-cache-control"]).toBeUndefined(); +}); diff --git a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json index f8abb83544..8efa9f2db1 100644 --- a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json +++ b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json @@ -89,6 +89,22 @@ "state": "static-candidate", "status": 200 }, + "[\"pages-page\",\"/cacheability-pages/middleware\",\"html\",\"/cacheability-pages/middleware\"]": { + "kind": "pages-page", + "pattern": "/cacheability-pages/middleware", + "representation": "html", + "requestKey": "/cacheability-pages/middleware", + "state": "static-candidate", + "status": 200 + }, + "[\"pages-page\",\"/cacheability-pages/config-header\",\"html\",\"/cacheability-pages/config-header\"]": { + "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", diff --git a/tests/fixtures/ppr-impact-demo/next.config.ts b/tests/fixtures/ppr-impact-demo/next.config.ts index 576ce19e38..5e7e95607f 100644 --- a/tests/fixtures/ppr-impact-demo/next.config.ts +++ b/tests/fixtures/ppr-impact-demo/next.config.ts @@ -23,5 +23,10 @@ export default { has: [{ type: "query", key: "late-policy", value: "cloudflare-cdn-cache-control" }], headers: [{ key: "Cloudflare-CDN-Cache-Control", value: "private" }], }, + { + source: "/cacheability-pages/config-header", + has: [{ type: "cookie" as const, key: "variant", value: "private" }], + headers: [{ key: "X-Cacheability-Config", value: "private" }], + }, ], } satisfies NextConfig; diff --git a/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/config-header.tsx b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/config-header.tsx new file mode 100644 index 0000000000..95984f2f7f --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/config-header.tsx @@ -0,0 +1,7 @@ +export async function getStaticProps() { + return { props: { value: "pages-config-header" }, revalidate: 60 }; +} + +export default function PagesConfigHeader({ value }: { value: string }) { + return

{value}

; +} diff --git a/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/middleware.tsx b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/middleware.tsx new file mode 100644 index 0000000000..0eb67891f2 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/middleware.tsx @@ -0,0 +1,7 @@ +export async function getStaticProps() { + return { props: { value: "pages-middleware" }, revalidate: 60 }; +} + +export default function PagesMiddleware({ value }: { value: string }) { + return

{value}

; +} diff --git a/tests/fixtures/ppr-impact-demo/proxy.ts b/tests/fixtures/ppr-impact-demo/proxy.ts index e6f452e97d..72b9cb33ed 100644 --- a/tests/fixtures/ppr-impact-demo/proxy.ts +++ b/tests/fixtures/ppr-impact-demo/proxy.ts @@ -17,5 +17,9 @@ export const config = { source: "/cacheability/conditional-middleware-header", has: [{ type: "header", key: "x-cacheability-middleware", value: "enabled" }], }, + { + source: "/cacheability-pages/middleware", + has: [{ type: "cookie", key: "variant", value: "private" }], + }, ], }; diff --git a/tests/pages-request-pipeline.test.ts b/tests/pages-request-pipeline.test.ts index b236930475..a151397fa4 100644 --- a/tests/pages-request-pipeline.test.ts +++ b/tests/pages-request-pipeline.test.ts @@ -8,6 +8,11 @@ import { } from "../packages/vinext/src/server/pages-request-pipeline.js"; import { MIDDLEWARE_SKIP_HEADER } from "../packages/vinext/src/server/headers.js"; import { PRERENDER_REVALIDATE_HEADER } from "../packages/vinext/src/utils/protocol-headers.js"; +import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js"; +import { + CACHEABILITY_REQUEST_STATE, + type RouteCacheabilityState, +} from "../packages/vinext/src/shims/cacheability-classification.js"; // Helpers @@ -237,6 +242,30 @@ describe("config redirects", () => { // 4. Middleware redirect short-circuit → {type:"response"} status 307 describe("middleware", () => { + it("fails cacheability closed for a middleware-eligible pathname", async () => { + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + mode: "admit", + }; + const context = { + [CACHEABILITY_REQUEST_STATE]: state, + waitUntil() {}, + }; + + await runWithExecutionContext(context, () => + runPagesRequest( + makeRequest("/conditional"), + baseDeps({ + hasMiddleware: true, + renderPage: makeRenderPage(), + runMiddleware: makeMiddleware({ continue: true, pathnameEligible: true }), + }), + ), + ); + + expect(state.forcedDynamicReason).toBe("middleware can match this pathname"); + }); + it("can present the raw data URL to middleware while routing the normalized page", async () => { // Ported from Next.js: packages/next/src/server/next-server.ts // (`skipProxyUrlNormalize` selects request meta `initURL` for middleware). diff --git a/tests/shims.test.ts b/tests/shims.test.ts index c19bf6ff8b..029145056e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -13551,6 +13551,20 @@ describe("matchHeaders", () => { expect(matched).toEqual([]); }); + it("reports pathname eligibility before request conditions are evaluated", async () => { + const { matchHeaders } = await import("../packages/vinext/src/config/config-matchers.js"); + const rule: any = { + source: "/about", + has: [{ type: "cookie", key: "variant", value: "private" }], + headers: [{ key: "x-variant", value: "private" }], + }; + const onRulePathnameMatch = vi.fn(); + + expect(matchHeaders("/about", [rule], makeCtx(), undefined, onRulePathnameMatch)).toEqual([]); + expect(onRulePathnameMatch).toHaveBeenCalledOnce(); + expect(onRulePathnameMatch).toHaveBeenCalledWith(rule); + }); + // Regression for #1331: under `trailingSlash: true` the incoming pathname // arrives as `/about/`, but header source patterns are written without a // trailing slash. `matchHeaders` must strip the slash before matching. From e54c4185f2d449f77669c4a19b81c1defce32ba7 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 26 Aug 2026 20:13:26 +0100 Subject: [PATCH 04/14] test(cache): assert Pages response finalization --- tests/deploy.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index fb6eb2d99a..236c55efe1 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -1826,8 +1826,9 @@ describe("readPagesRouterEntrySource", () => { // now called inside runPagesRequest. The worker delegates to the pipeline. expect(content).toContain("runPagesRequest(request, deps)"); expect(content).toContain('result.type === "response"'); + expect(content).toContain("return finalize("); expect(content).toContain( - "return finalizeMissingStaticAssetResponse(result.response, missingBuildAsset)", + "finalizeMissingStaticAssetResponse(result.response, missingBuildAsset)", ); }); From 31f3510c8bf539217bd8bda02e2f5aaed00a8b3d Mon Sep 17 00:00:00 2001 From: James Date: Wed, 26 Aug 2026 23:22:18 +0100 Subject: [PATCH 05/14] fix(cache): transfer hybrid Pages admission ownership --- packages/vinext/src/server/app-pages-bridge.ts | 7 +++++++ packages/vinext/src/shims/cacheability-classification.ts | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/packages/vinext/src/server/app-pages-bridge.ts b/packages/vinext/src/server/app-pages-bridge.ts index e320e1edda..3f5d4f30a7 100644 --- a/packages/vinext/src/server/app-pages-bridge.ts +++ b/packages/vinext/src/server/app-pages-bridge.ts @@ -1,5 +1,6 @@ import type { AppMiddlewareContext } from "./app-middleware.js"; import type { EdgeApiExecutionRuntime } from "./edge-api-runtime.js"; +import { beginRouteCacheability } from "vinext/shims/cacheability-classification"; import { getRequestExecutionContext } from "vinext/shims/request-context"; import { pagesRouteHasPriorityOverAppRoute } from "./hybrid-route-priority.js"; import { cloneRequestWithHeaders, cloneRequestWithUrl } from "./request-pipeline.js"; @@ -195,6 +196,12 @@ export async function renderPagesFallback( ) { return null; } + if (pageMatch !== null) { + // The bridge runs in the App request environment, while the Pages renderer + // can use a separate module graph. Register ownership here so the outer + // admission finalizer can apply the Pages manifest decision. + beginRouteCacheability("pages-page", pageMatch.route.pattern); + } const renderRequest = pagesDataRequest ? cloneRequestWithUrl(pagesRequest, pagesDataRequest.url) : pagesRequest; diff --git a/packages/vinext/src/shims/cacheability-classification.ts b/packages/vinext/src/shims/cacheability-classification.ts index 8ae452f646..0ad642d216 100644 --- a/packages/vinext/src/shims/cacheability-classification.ts +++ b/packages/vinext/src/shims/cacheability-classification.ts @@ -51,6 +51,7 @@ export type RouteCacheabilityState = { export function preserveRouteCacheabilityResponsePolicy(): void { const state = readRouteCacheabilityState(); if (!state || state.mode !== "admit") return; + if (state.route?.kind === "pages-page") return; state.preserveResponseCachePolicy = true; } @@ -69,6 +70,12 @@ export function beginRouteCacheability( const state = readRouteCacheabilityState(); if (!state) return false; state.route = { kind, pattern }; + if (kind === "pages-page") { + // App routing preserves an independently handled Pages response while the + // request is in transit. Once Pages classification begins, this layer owns + // admission and must fail unlisted or request-specific identities closed. + state.preserveResponseCachePolicy = false; + } return true; } From 9aa96a9ab60987d75788ceb7a097c79b5dcc139a Mon Sep 17 00:00:00 2001 From: James Date: Wed, 26 Aug 2026 23:50:08 +0100 Subject: [PATCH 06/14] fix(cloudflare): warm Pages Router data routes --- packages/cloudflare/src/cdn-warm.ts | 90 ++++++++++++----- packages/cloudflare/src/deploy.ts | 56 +++++++++-- packages/vinext/src/build/prerender-paths.ts | 35 +++++-- packages/vinext/src/build/report.ts | 5 +- packages/vinext/src/server/cache-control.ts | 7 +- .../src/server/cacheability-manifest.ts | 12 ++- .../vinext/src/server/pages-page-handler.ts | 3 +- tests/build-report.test.ts | 6 +- tests/cacheability-manifest.test.ts | 10 ++ tests/cloudflare-cdn-cache.test.ts | 8 ++ tests/cloudflare-cdn-warm-deploy.test.ts | 33 ++++++- tests/cloudflare-cdn-warm.test.ts | 85 +++++++++++++--- .../cacheability-probe.spec.ts | 27 +++++ .../pages-cacheability.spec.ts | 98 ++++++++++++++++--- .../cacheability-manifest.json | 16 +++ tests/prerender-paths.test.ts | 10 ++ 16 files changed, 430 insertions(+), 71 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index ea74198faa..0944270812 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -28,6 +28,8 @@ import { VINEXT_CDN_BUILD_ID_HEADER } from "./cache/cdn-build-id.js"; export type CdnWarmOptions = { targetUrl: string; paths: readonly string[]; + /** Pages Router JSON data identities used by client navigation. */ + pagesDataPaths?: readonly string[]; /** App Router ISR paths whose definitive client-navigation payload is warmed. */ rscPaths?: readonly string[]; /** App Router paths whose deterministic loading-boundary payload is warmed. */ @@ -79,6 +81,7 @@ export type CdnWarmResult = { export type CdnWarmRequestPlan = { loadingShellPaths: string[]; + pagesDataPaths: string[]; paths: string[]; rscPaths: string[]; }; @@ -91,6 +94,7 @@ export type PrerenderWarmPlan = { buildIdentity?: string; deploymentId?: string; loadingShellPaths: string[]; + pagesDataPaths?: string[]; pagesPaths?: string[]; paths: string[]; rscBuildId?: string; @@ -137,6 +141,9 @@ function readPrerenderPathManifest(manifestPath: string): PrerenderPathManifest (manifest.pagesPaths !== undefined && (!Array.isArray(manifest.pagesPaths) || !manifest.pagesPaths.every((pathname) => typeof pathname === "string"))) || + (manifest.pagesDataPaths !== undefined && + (!Array.isArray(manifest.pagesDataPaths) || + !manifest.pagesDataPaths.every((pathname) => typeof pathname === "string"))) || (manifest.excludedWarmPaths !== undefined && (!Array.isArray(manifest.excludedWarmPaths) || !manifest.excludedWarmPaths.every((pathname) => typeof pathname === "string"))) || @@ -242,6 +249,7 @@ export function readPrerenderWarmPlan( loadingShellPaths: supportsCanonicalRsc ? (manifest.loadingShellPaths ?? []).map(applyConfig) : [], + ...(manifest.pagesDataPaths ? { pagesDataPaths: manifest.pagesDataPaths } : {}), ...(manifest.pagesPaths ? { pagesPaths: manifest.pagesPaths.map(applyConfig) } : {}), paths: htmlPaths, ...(supportsCanonicalRsc ? { rscBuildId: manifest.rscBuildId } : {}), @@ -356,7 +364,7 @@ async function fetchHeadersWithTimeout( export type CdnWarmTarget = { headers?: HeadersInit; - kind: "html" | "rsc-full" | "rsc-loading-shell"; + kind: "html" | "pages-data" | "rsc-full" | "rsc-loading-shell"; label: string; pathname: string; sourcePathname: string; @@ -365,7 +373,7 @@ export type CdnWarmTarget = { export async function createCdnWarmTargets( options: Pick< CdnWarmOptions, - "deploymentId" | "headers" | "loadingShellPaths" | "paths" | "rscPaths" + "deploymentId" | "headers" | "loadingShellPaths" | "pagesDataPaths" | "paths" | "rscPaths" >, ): Promise { const requests: CdnWarmTarget[] = []; @@ -415,6 +423,17 @@ export async function createCdnWarmTargets( sourcePathname: pathname, }); } + for (const pathname of new Set(options.pagesDataPaths ?? [])) { + const dataHeaders = new Headers(commonHeaders); + dataHeaders.set("Accept", "application/json"); + requests.push({ + headers: dataHeaders, + kind: "pages-data", + label: `${pathname} (Pages data)`, + pathname, + sourcePathname: pathname, + }); + } return requests; } @@ -683,9 +702,22 @@ function validateHtmlWarmResponse( return { outcome: "warmed" }; } +function validatePagesDataWarmResponse( + response: Response, + expectedBuildId?: string, + requireCacheHit = false, +): WarmValidation { + const validation = validateHtmlWarmResponse(response, expectedBuildId, requireCacheHit); + if (validation.outcome !== "warmed") return validation; + if (!response.headers.get("Content-Type")?.toLowerCase().startsWith("application/json")) { + return { outcome: "failed", error: "expected application/json response" }; + } + return validation; +} + function validateReadinessResponse( response: Response, - kind: "html" | "rsc", + kind: "html" | "pages-data" | "rsc", expectedBuildId?: string, expectedRscBuildId?: string, ): string | null { @@ -700,13 +732,14 @@ function validateReadinessResponse( } if (response.redirected) return "redirected response"; if (response.status >= 500) return `HTTP ${response.status}`; - if ( - response.status >= 200 && - response.status < 300 && - kind === "rsc" && - !response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE) - ) { - return `expected ${VINEXT_RSC_CONTENT_TYPE} response`; + if (response.status >= 200 && response.status < 300) { + const contentType = response.headers.get("Content-Type")?.toLowerCase(); + if (kind === "rsc" && !contentType?.startsWith(VINEXT_RSC_CONTENT_TYPE)) { + return `expected ${VINEXT_RSC_CONTENT_TYPE} response`; + } + if (kind === "pages-data" && !contentType?.startsWith("application/json")) { + return "expected application/json response"; + } } // Readiness proves only that version overrides consistently reach the // uploaded build. The real warm pass validates status, representation, and @@ -740,8 +773,9 @@ export async function waitForCdnWarmTargetReadiness( ): Promise { const rscPath = options.plan.rscPaths[0] ?? options.plan.loadingShellPaths[0]; const htmlPath = options.plan.paths[0]; - const kind = rscPath ? "rsc" : "html"; - const pathname = rscPath ?? htmlPath; + const pagesDataPath = options.plan.pagesDataPaths[0]; + const kind = rscPath ? "rsc" : htmlPath ? "html" : "pages-data"; + const pathname = rscPath ?? htmlPath ?? pagesDataPath; if (!pathname) return { ready: true }; if (options.expectedBuildId === undefined && options.expectedRscBuildId === undefined) { return { @@ -756,8 +790,10 @@ export async function waitForCdnWarmTargetReadiness( for (const [name, value] of createCanonicalRscRequestHeaders(options.deploymentId)) { headers.set(name, value); } - } else { + } else if (kind === "html") { headers.set("Accept", "text/html"); + } else { + headers.set("Accept", "application/json"); } headers.set("Cache-Control", "no-cache"); headers.set("Pragma", "no-cache"); @@ -871,7 +907,7 @@ function shouldRetryValidationFailure( options.expectedBuildId === undefined ? null : response.headers.get(VINEXT_CDN_BUILD_ID_HEADER) === options.expectedBuildId, - target.kind === "html" || options.expectedRscBuildId === undefined + !target.kind.startsWith("rsc-") || options.expectedRscBuildId === undefined ? null : response.headers.get(VINEXT_RSC_BUILD_ID_HEADER) === options.expectedRscBuildId, ].filter((matches): matches is boolean => matches !== null); @@ -966,7 +1002,7 @@ async function warmOnePath( ); } - if (target.kind !== "html") { + if (target.kind.startsWith("rsc-")) { const validation = validateRscWarmResponse( response, options.expectedBuildId, @@ -994,11 +1030,14 @@ async function warmOnePath( continue; } - const validation = validateHtmlWarmResponse( - response, - options.expectedBuildId, - options.requireCacheHit, - ); + const validation = + target.kind === "pages-data" + ? validatePagesDataWarmResponse( + response, + options.expectedBuildId, + options.requireCacheHit, + ) + : validateHtmlWarmResponse(response, options.expectedBuildId, options.requireCacheHit); if (validation.outcome === "warmed") { return { path: target.label, ok: true, skipped: false }; } @@ -1082,8 +1121,8 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise target.kind === "rsc-loading-shell") .map((target) => target.sourcePathname), + pagesDataPaths: warmedRequests + .filter((target) => target.kind === "pages-data") + .map((target) => target.sourcePathname), paths: warmedRequests .filter((target) => target.kind === "html") .map((target) => target.sourcePathname), @@ -1240,6 +1282,9 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise target.kind === "rsc-loading-shell") .map(({ target }) => target.sourcePathname), + pagesDataPaths: failedRequests + .filter(({ target }) => target.kind === "pages-data") + .map(({ target }) => target.sourcePathname), paths: failedRequests .filter(({ target }) => target.kind === "html") .map(({ target }) => target.sourcePathname), @@ -1269,6 +1314,7 @@ export async function warmCdnCacheFromPrerender( const warmPlan = { deploymentId: plan.deploymentId, loadingShellPaths: plan.loadingShellPaths, + pagesDataPaths: plan.pagesDataPaths, paths: plan.paths, rscPaths: plan.rscPaths, }; diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 05ee3ed227..2da5720f10 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -685,8 +685,16 @@ export async function runWranglerDeploy( return deployedUrl ?? "(URL not detected in wrangler output)"; } -export function hasCdnWarmRequests(plan: CdnWarmRequestPlan): boolean { - return plan.paths.length + plan.rscPaths.length + plan.loadingShellPaths.length > 0; +export function hasCdnWarmRequests( + plan: Omit & { pagesDataPaths?: readonly string[] }, +): boolean { + return ( + plan.paths.length + + (plan.pagesDataPaths?.length ?? 0) + + plan.rscPaths.length + + plan.loadingShellPaths.length > + 0 + ); } export function projectRequiresRouteCacheabilityProbeManifest( @@ -723,7 +731,12 @@ type CdnWarmDeployOptions = Pick< > & Pick< CdnWarmOptions, - "deploymentId" | "expectedBuildId" | "expectedRscBuildId" | "loadingShellPaths" | "rscPaths" + | "deploymentId" + | "expectedBuildId" + | "expectedRscBuildId" + | "loadingShellPaths" + | "pagesDataPaths" + | "rscPaths" > & { /** Probe a staged Worker and upload the resulting manifest as a second version. */ cacheabilityProbe?: boolean; @@ -807,26 +820,34 @@ async function deployUploadedVersionWithCdnWarmup( let warmPlanDiscovered = hasPreparedWarmPlan || options.discoverWarmPlan === undefined; let remainingWarmPlan: CdnWarmRequestPlan = { loadingShellPaths: [...(options.loadingShellPaths ?? [])], + pagesDataPaths: [...(options.pagesDataPaths ?? [])], paths: [...paths], rscPaths: [...(options.rscPaths ?? [])], }; let discoveredWarmRequests = remainingWarmPlan.paths.length + + remainingWarmPlan.pagesDataPaths.length + remainingWarmPlan.rscPaths.length + remainingWarmPlan.loadingShellPaths.length; const prepareWarmPlan = (plan: CdnWarmRequestPlan): CdnWarmRequestPlan => { - if (plan.paths.length === 0 || expectedBuildId !== undefined) return plan; + if ( + (plan.paths.length === 0 && plan.pagesDataPaths.length === 0) || + expectedBuildId !== undefined + ) { + return plan; + } if (!allowUnverifiedPromotion) { + const warmupKind = plan.paths.length > 0 ? "CDN HTML warmup" : "CDN Pages data warmup"; throw new Error( - "CDN HTML warmup requires a CDN adapter that declares build-identity response headers. " + + `${warmupKind} requires a CDN adapter that declares build-identity response headers. ` + "Configure that adapter capability or deploy without --experimental-warm-cdn-cache.", ); } console.warn( - ` CDN warmup: skipping ${plan.paths.length} HTML request(s) because the CDN adapter does not declare build-identity response headers.`, + ` CDN warmup: skipping ${plan.paths.length} HTML and ${plan.pagesDataPaths.length} Pages data request(s) because the CDN adapter does not declare build-identity response headers.`, ); - return { ...plan, paths: [] }; + return { ...plan, pagesDataPaths: [], paths: [] }; }; const discoverWarmPlan = async (targetUrl: string, headers?: HeadersInit): Promise => { @@ -837,11 +858,13 @@ async function deployUploadedVersionWithCdnWarmup( expectedRscBuildId = plan.rscBuildId; remainingWarmPlan = { loadingShellPaths: [...plan.loadingShellPaths], + pagesDataPaths: [...(plan.pagesDataPaths ?? [])], paths: [...plan.paths], rscPaths: [...plan.rscPaths], }; discoveredWarmRequests = remainingWarmPlan.paths.length + + remainingWarmPlan.pagesDataPaths.length + remainingWarmPlan.rscPaths.length + remainingWarmPlan.loadingShellPaths.length; warmPlanDiscovered = true; @@ -858,6 +881,7 @@ async function deployUploadedVersionWithCdnWarmup( propagatingTarget = false, plan: CdnWarmRequestPlan = { loadingShellPaths: remainingWarmPlan.loadingShellPaths, + pagesDataPaths: remainingWarmPlan.pagesDataPaths, paths: remainingWarmPlan.paths, rscPaths: remainingWarmPlan.rscPaths, }, @@ -872,6 +896,7 @@ async function deployUploadedVersionWithCdnWarmup( expectedBuildId, expectedRscBuildId, loadingShellPaths: plan.loadingShellPaths, + pagesDataPaths: plan.pagesDataPaths, rscPaths: plan.rscPaths, concurrency: options.warmCdnConcurrency, phaseTimeoutMs: hasPreparedWarmPlan ? DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS : undefined, @@ -904,6 +929,7 @@ async function deployUploadedVersionWithCdnWarmup( const initialWarmRequests = options.discoverWarmPlan === undefined || hasPreparedWarmPlan ? remainingWarmPlan.paths.length + + remainingWarmPlan.pagesDataPaths.length + remainingWarmPlan.rscPaths.length + remainingWarmPlan.loadingShellPaths.length : 1; @@ -955,16 +981,18 @@ async function deployUploadedVersionWithCdnWarmup( await discoverWarmPlan(targetUrl, headers); remainingWarmPlan = prepareWarmPlan(remainingWarmPlan); console.log( - ` CDN warmup: discovered ${remainingWarmPlan.paths.length} HTML, ${remainingWarmPlan.rscPaths.length} RSC, and ${remainingWarmPlan.loadingShellPaths.length} loading-shell request(s).`, + ` CDN warmup: discovered ${remainingWarmPlan.paths.length} HTML, ${remainingWarmPlan.pagesDataPaths.length} Pages data, ${remainingWarmPlan.rscPaths.length} RSC, and ${remainingWarmPlan.loadingShellPaths.length} loading-shell request(s).`, ); } const stagedWarmPlan: CdnWarmRequestPlan = { loadingShellPaths: remainingWarmPlan.loadingShellPaths, + pagesDataPaths: remainingWarmPlan.pagesDataPaths, paths: remainingWarmPlan.paths, rscPaths: remainingWarmPlan.rscPaths, }; const stagedWarmRequests = stagedWarmPlan.paths.length + + stagedWarmPlan.pagesDataPaths.length + stagedWarmPlan.rscPaths.length + stagedWarmPlan.loadingShellPaths.length; if (stagedWarmRequests > 0) { @@ -1015,6 +1043,7 @@ async function deployUploadedVersionWithCdnWarmup( } remainingWarmPlan = { loadingShellPaths: warmResult.retryPlan.loadingShellPaths, + pagesDataPaths: warmResult.retryPlan.pagesDataPaths, paths: warmResult.retryPlan.paths, rscPaths: warmResult.retryPlan.rscPaths, }; @@ -1068,6 +1097,7 @@ async function deployUploadedVersionWithCdnWarmup( const countRemainingWarmRequests = (): number => remainingWarmPlan.paths.length + + remainingWarmPlan.pagesDataPaths.length + remainingWarmPlan.rscPaths.length + remainingWarmPlan.loadingShellPaths.length; @@ -1378,10 +1408,11 @@ async function deployWithCacheabilityProbe( "Two-stage CDN warming requires a CDN adapter that exposes the application build identity.", ); } - const plan: PrerenderWarmPlan = { + const plan: PrerenderWarmPlan & CdnWarmRequestPlan = { ...discovered, appPaths: discovered.appPaths ? [...discovered.appPaths] : undefined, loadingShellPaths: [...discovered.loadingShellPaths], + pagesDataPaths: [...(discovered.pagesDataPaths ?? [])], pagesPaths: discovered.pagesPaths ? [...discovered.pagesPaths] : undefined, paths: [...discovered.paths], rscPaths: [...discovered.rscPaths], @@ -1397,6 +1428,7 @@ async function deployWithCacheabilityProbe( deploymentId: plan.deploymentId, headers, loadingShellPaths: plan.loadingShellPaths, + pagesDataPaths: plan.pagesDataPaths, paths: plan.paths, rscPaths: plan.rscPaths, }); @@ -1447,11 +1479,14 @@ async function deployWithCacheabilityProbe( `Two-stage CDN warming failed to classify ${probe.failures.length}/${probe.probed} request(s). First failure: ${probe.failures[0]}`, ); } - const finalPlan: PrerenderWarmPlan = { + const finalPlan: PrerenderWarmPlan & CdnWarmRequestPlan = { ...plan, loadingShellPaths: probe.cacheableTargets .filter((target) => target.kind === "rsc-loading-shell") .map((target) => target.sourcePathname), + pagesDataPaths: probe.cacheableTargets + .filter((target) => target.kind === "pages-data") + .map((target) => target.sourcePathname), paths: probe.cacheableTargets .filter((target) => target.kind === "html") .map((target) => target.sourcePathname), @@ -1497,6 +1532,7 @@ async function deployWithCacheabilityProbe( expectedRscBuildId: prepared.plan.rscBuildId, expectedDeploymentState: stagedProbeDeployment, loadingShellPaths: prepared.plan.loadingShellPaths, + pagesDataPaths: prepared.plan.pagesDataPaths, rscPaths: prepared.plan.rscPaths, uploadedVersion: prepared.upload, }); diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index cc27ebab56..3c25a890d9 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -31,6 +31,7 @@ import { matchesRewriteSource } from "../config/config-matchers.js"; import { pagesRouteHasPriorityOverAppRoute } from "../server/hybrid-route-priority.js"; import { extractLocaleFromUrl, normalizeDefaultLocalePathname } from "../server/pages-i18n.js"; import { normalizePathTrailingSlash } from "vinext/shims/url-utils"; +import { buildPagesDataHref } from "vinext/shims/internal/pages-data-url"; export type PrerenderPathManifest = { /** App Page HTML paths after hybrid route ownership has been resolved. */ @@ -49,6 +50,8 @@ export type PrerenderPathManifest = { loadingShellPaths?: string[]; /** Pages Router paths selected by the existing HTML warm discovery pass. */ pagesPaths?: string[]; + /** Pages Router JSON data identities corresponding to discovered static paths. */ + pagesDataPaths?: string[]; /** Public paths omitted because configured routes can replace their page response. */ excludedWarmPaths?: string[]; trailingSlash?: boolean; @@ -473,7 +476,7 @@ async function collectPagesPaths(options: { pageExtensions: readonly string[]; retryOptions?: PathDiscoveryRetryOptions; secretHeaders: Record; -}): Promise { +}): Promise<{ dataPaths: string[]; paths: string[] }> { const [pageRoutes, apiRoutes] = await Promise.all([ pagesRouter(options.pagesDir, options.pageExtensions), apiRouter(options.pagesDir, options.pageExtensions), @@ -481,6 +484,8 @@ async function collectPagesPaths(options: { const apiPatterns = new Set(apiRoutes.map((route) => route.pattern)); const paths: string[] = []; const seen = new Set(); + const dataPaths: string[] = []; + const seenDataPaths = new Set(); for (const route of pageRoutes) { if (apiPatterns.has(route.pattern)) continue; @@ -489,16 +494,19 @@ async function collectPagesPaths(options: { continue; } - const { type } = classifyPagesRoute(route.filePath); + const { hasStaticProps, type } = classifyPagesRoute(route.filePath); if (type === "api" || type === "ssr") continue; if (!route.isDynamic) { if (options.i18n) { for (const locale of options.i18n.locales) { - addPath(paths, seen, localizePagesPath(route.pattern, locale, options.i18n)); + const pathname = localizePagesPath(route.pattern, locale, options.i18n); + addPath(paths, seen, pathname); + if (hasStaticProps) addPath(dataPaths, seenDataPaths, pathname); } } else { addPath(paths, seen, route.pattern); + if (hasStaticProps) addPath(dataPaths, seenDataPaths, route.pattern); } continue; } @@ -555,13 +563,14 @@ async function collectPagesPaths(options: { options.i18n, ); addPath(paths, seen, pathname); + if (hasStaticProps) addPath(dataPaths, seenDataPaths, pathname); } } catch (error) { throwDiscoveryFailure(route.pattern, error); } } - return paths; + return { dataPaths, paths }; } async function excludePagesApiWarmPaths(options: { @@ -878,6 +887,8 @@ export async function emitPrerenderPathManifest( const seen = new Set(); const discoveredPagesPaths: string[] = []; const seenPagesPaths = new Set(); + const discoveredPagesDataPaths: string[] = []; + const seenPagesDataPaths = new Set(); const discoveredAppPaths: string[] = []; const seenAppPaths = new Set(); const discoveredLoadingShellPaths: string[] = []; @@ -956,17 +967,21 @@ export async function emitPrerenderPathManifest( } if (pagesDir) { - for (const pathname of await collectPagesPaths({ + const pagesPathResult = await collectPagesPaths({ baseUrl, i18n: config.i18n, pagesDir, pageExtensions: config.pageExtensions, retryOptions: pathDiscoveryRetryOptions, secretHeaders, - })) { + }); + for (const pathname of pagesPathResult.paths) { addPath(paths, seen, pathname); addPath(discoveredPagesPaths, seenPagesPaths, pathname); } + for (const pathname of pagesPathResult.dataPaths) { + addPath(discoveredPagesDataPaths, seenPagesDataPaths, pathname); + } } } finally { if (prodServer) { @@ -992,6 +1007,10 @@ export async function emitPrerenderPathManifest( paths: configuredPagesWarmPaths, }) : configuredPagesWarmPaths; + const discoveredPagesDataPathSet = new Set(discoveredPagesDataPaths); + const resolvedPagesDataWarmPaths = resolvedPagesWarmPaths.filter((pathname) => + discoveredPagesDataPathSet.has(pathname), + ); const configuredCandidatePaths = paths.filter((pathname) => !excludedWarmPathSet.has(pathname)); const appOwnedWarmPaths = appDir ? await resolveAppWarmPaths({ @@ -1008,6 +1027,9 @@ export async function emitPrerenderPathManifest( rscPaths: discoveredAppPaths, }; const warmPaths = appDir ? appOwnedWarmPaths.htmlPaths : resolvedPagesWarmPaths; + const pagesDataPaths = resolvedPagesDataWarmPaths.map((pathname) => + buildPagesDataHref(config.basePath, config.buildId, pathname, ""), + ); const manifest: PrerenderPathManifest = { ...(appDir ? { appPaths: appOwnedWarmPaths.appPaths } : {}), @@ -1019,6 +1041,7 @@ export async function emitPrerenderPathManifest( ...(config.deploymentId ? { deploymentId: config.deploymentId } : {}), ...(pagesDir ? { + pagesDataPaths, pagesPaths: resolvedPagesWarmPaths, } : {}), diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index db187927f8..2a657c896e 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -659,6 +659,7 @@ export function classifyLayoutSegmentConfig(code: string): LayoutBuildClassifica * API routes (files under pages/api/) are always `api`. */ export function classifyPagesRoute(filePath: string): { + hasStaticProps?: boolean; type: RouteType; revalidate?: number; } { @@ -685,13 +686,13 @@ export function classifyPagesRoute(filePath: string): { const revalidate = extractGetStaticPropsRevalidateFromProgram(program, code); if (revalidate === null || revalidate === false || revalidate === Infinity) { - return { type: "static" }; + return { hasStaticProps: true, type: "static" }; } if (revalidate === 0) { return { type: "ssr" }; } // Positive number → ISR - return { type: "isr", revalidate }; + return { hasStaticProps: true, type: "isr", revalidate }; } return { type: "static" }; diff --git a/packages/vinext/src/server/cache-control.ts b/packages/vinext/src/server/cache-control.ts index b759185378..afadd1bf78 100644 --- a/packages/vinext/src/server/cache-control.ts +++ b/packages/vinext/src/server/cache-control.ts @@ -83,7 +83,12 @@ export function applyCdnResponseHeaders(headers: Headers, input: CdnCacheableHea /** Apply adapter-owned build identity to an HTML or RSC page response. */ export function applyCdnResponseIdentityHeaders(response: Response, request: Request): Response { const accept = request.headers.get("Accept")?.toLowerCase() ?? ""; - if (request.headers.get("RSC") !== "1" && !accept.includes("text/html")) return response; + const isPagesDataRequest = /(?:^|\/)_next\/data\/[^/]+\/.+\.json$/.test( + new URL(request.url).pathname, + ); + if (request.headers.get("RSC") !== "1" && !accept.includes("text/html") && !isPagesDataRequest) { + return response; + } const map = getCdnCacheAdapter().buildResponseIdentityHeaders?.(); if (!map || Object.keys(map).length === 0) return response; diff --git a/packages/vinext/src/server/cacheability-manifest.ts b/packages/vinext/src/server/cacheability-manifest.ts index 2c5b2d7241..d5ee60655b 100644 --- a/packages/vinext/src/server/cacheability-manifest.ts +++ b/packages/vinext/src/server/cacheability-manifest.ts @@ -17,7 +17,7 @@ import { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL } from "./app-rsc-render-mod export const CACHEABILITY_MANIFEST_MODULE = "__vinext_cacheability_manifest.js"; export type CacheabilityRouteKind = "app-page" | "pages-page"; -export type CacheabilityRepresentation = "html" | "rsc-full" | "rsc-loading-shell"; +export type CacheabilityRepresentation = "html" | "pages-data" | "rsc-full" | "rsc-loading-shell"; type CacheabilityManifestRouteState = | "static-candidate" | "runtime-check" @@ -49,7 +49,12 @@ export function cacheabilityManifestRouteKey( } function isRepresentation(value: unknown): value is CacheabilityRepresentation { - return value === "html" || value === "rsc-full" || value === "rsc-loading-shell"; + return ( + value === "html" || + value === "pages-data" || + value === "rsc-full" || + value === "rsc-loading-shell" + ); } function isRouteState(value: unknown): value is CacheabilityManifestRouteState { @@ -147,6 +152,9 @@ export function cacheabilityRequestIdentity(request: Request): { const url = new URL(request.url); const requestKey = `${url.pathname}${url.search}`; + if (/(?:^|\/)_next\/data\/[^/]+\/.+\.json$/.test(url.pathname)) { + return { representation: "pages-data", requestKey }; + } const isRsc = request.headers.get(RSC_HEADER) === "1" || url.pathname.endsWith(".rsc"); if (!isRsc) { const accept = request.headers.get("Accept")?.toLowerCase() ?? ""; diff --git a/packages/vinext/src/server/pages-page-handler.ts b/packages/vinext/src/server/pages-page-handler.ts index dff7257831..4f049a059d 100644 --- a/packages/vinext/src/server/pages-page-handler.ts +++ b/packages/vinext/src/server/pages-page-handler.ts @@ -567,8 +567,7 @@ export function createPagesPageHandler( hasRewrites, }); const isCacheabilityProbe = isRouteCacheabilityProbe(); - const isTopLevelPageRoute = - !isDataReq && !isRouteMissErrorRender && options?.__forcedRoute === undefined; + const isTopLevelPageRoute = !isRouteMissErrorRender && options?.__forcedRoute === undefined; const hasRequestTimeData = pagesReadiness.gssp === true || pagesReadiness.gip === true || pagesReadiness.appGip === true; diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 7ac4be4ffb..2401b0aff0 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -404,7 +404,11 @@ export { gsp as getStaticProps }; describe("classifyPagesRoute", () => { it("classifies isr-test.tsx as isr with revalidate=1", () => { const filePath = path.join(FIXTURES_PAGES, "isr-test.tsx"); - expect(classifyPagesRoute(filePath)).toEqual({ type: "isr", revalidate: 1 }); + expect(classifyPagesRoute(filePath)).toEqual({ + hasStaticProps: true, + type: "isr", + revalidate: 1, + }); }); it("classifies ssr.tsx as ssr", () => { diff --git a/tests/cacheability-manifest.test.ts b/tests/cacheability-manifest.test.ts index de46585f2c..289d2137e1 100644 --- a/tests/cacheability-manifest.test.ts +++ b/tests/cacheability-manifest.test.ts @@ -87,6 +87,16 @@ describe("cacheability manifest", () => { }), ), ).toEqual({ representation: "html", requestKey: "/products/one?currency=gbp" }); + expect( + cacheabilityRequestIdentity( + new Request("https://example.com/docs/_next/data/build-a/products/one.json?currency=gbp", { + headers: { Accept: "application/json" }, + }), + ), + ).toEqual({ + representation: "pages-data", + requestKey: "/docs/_next/data/build-a/products/one.json?currency=gbp", + }); expect( cacheabilityRequestIdentity( new Request("https://example.com/products/one?_rsc", { diff --git a/tests/cloudflare-cdn-cache.test.ts b/tests/cloudflare-cdn-cache.test.ts index 1ddbbdfc0d..ce63005bc7 100644 --- a/tests/cloudflare-cdn-cache.test.ts +++ b/tests/cloudflare-cdn-cache.test.ts @@ -191,6 +191,14 @@ describe("CloudflareCdnCacheAdapter", () => { expect(response.status).toBe(307); expect(response.headers.get("location")).toBe("https://example.com/target"); expect(response.headers.get(VINEXT_CDN_BUILD_ID_HEADER)).toBe("build-a"); + + const pagesData = applyCdnResponseIdentityHeaders( + Response.json({ pageProps: {} }), + new Request("https://example.com/_next/data/build-a/source.json", { + headers: { Accept: "application/json" }, + }), + ); + expect(pagesData.headers.get(VINEXT_CDN_BUILD_ID_HEADER)).toBe("build-a"); }); it("get returns null so the origin always renders fresh", async () => { diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 3f609247af..22039d61f7 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -64,6 +64,16 @@ function cacheableHtml(body = "ok", cacheStatus = "MISS"): Response { }); } +function cacheablePagesData(cacheStatus = "MISS"): Response { + return new Response('{"pageProps":{}}', { + headers: { + "cf-cache-status": cacheStatus, + "content-type": "application/json", + [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a", + }, + }); +} + function cacheableRsc(body = "flight"): Response { return new Response(body, { headers: { @@ -412,7 +422,12 @@ describe("Cloudflare CDN warmup deploy flow", () => { if (headers.get(VINEXT_CACHEABILITY_PROBE_HEADER) === "1") { const pathname = new URL(formatFetchUrl(input)).pathname; events.push(`probe:${pathname}`); - if (pathname === "/pages-about") return pagesPageProbeResponse(); + if ( + pathname === "/pages-about" || + pathname === "/_next/data/app-build-a/pages-about.json" + ) { + return pagesPageProbeResponse(); + } if (pathname === "/dynamic") { return Response.json( { @@ -435,7 +450,10 @@ describe("Cloudflare CDN warmup deploy flow", () => { events.push(`${count === 1 ? "warm" : "unexpected-second-request"}:${pathname}`); } const pathname = new URL(formatFetchUrl(input)).pathname; - return cacheableHtml("ok", (cacheRequestCounts.get(pathname) ?? 0) > 1 ? "HIT" : "MISS"); + const cacheStatus = (cacheRequestCounts.get(pathname) ?? 0) > 1 ? "HIT" : "MISS"; + return pathname.startsWith("/_next/data/") + ? cacheablePagesData(cacheStatus) + : cacheableHtml("ok", cacheStatus); }); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); @@ -447,6 +465,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { buildId: "app-build-a", buildIdentity: "app-build-a", loadingShellPaths: [], + pagesDataPaths: ["/_next/data/app-build-a/pages-about.json"], pagesPaths: ["/pages-about"], paths: ["/about", "/dynamic", "/pages-about"], rscPaths: [], @@ -461,6 +480,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(uploadCount).toBe(2); expect(statusCount).toBe(7); expect(Array.from(cacheRequestCounts.entries())).toEqual([ + ["/_next/data/app-build-a/pages-about.json", 1], ["/about", 1], ["/pages-about", 1], ]); @@ -473,6 +493,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "probe:/about", "probe:/dynamic", "probe:/pages-about", + "probe:/_next/data/app-build-a/pages-about.json", "status-3", "upload-final", "status-4", @@ -481,6 +502,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "status-6", "triggers", "readiness", + "warm:/_next/data/app-build-a/pages-about.json", "warm:/about", "warm:/pages-about", "status-7", @@ -505,6 +527,13 @@ describe("Cloudflare CDN warmup deploy flow", () => { 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", }), ]), diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index d2c5e1c92b..2c0213bf1f 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -51,6 +51,18 @@ function cacheableHtml(body = "html"): Response { }); } +function cacheablePagesData(body = '{"pageProps":{}}'): Response { + return new Response(body, { + headers: { + "cache-control": "public, max-age=0, must-revalidate", + "cdn-cache-control": "public, max-age=60", + "cf-cache-status": "MISS", + "content-type": "application/json; charset=utf-8", + [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", + }, + }); +} + function requestHref(input: RequestInfo | URL | undefined): string | undefined { if (input instanceof URL) return input.href; if (typeof input === "string") return input; @@ -89,6 +101,7 @@ describe("Cloudflare CDN warmup", () => { buildIdentity: "rsc-build-a", deploymentId: "dpl_123", loadingShellPaths: ["/dashboard"], + pagesDataPaths: ["/docs/_next/data/build-a/pages.json"], paths: ["/dashboard", "/dynamic", "/pages"], responseVary: "verbatim", rscBuildId: "rsc-build-a", @@ -106,6 +119,7 @@ describe("Cloudflare CDN warmup", () => { buildIdentity: "rsc-build-a", deploymentId: "dpl_123", loadingShellPaths: ["/docs/dashboard/"], + pagesDataPaths: ["/docs/_next/data/build-a/pages.json"], paths: ["/docs/dashboard/", "/docs/dynamic/", "/docs/pages/"], rscBuildId: "rsc-build-a", rscPaths: ["/docs/dashboard/", "/docs/dynamic/"], @@ -192,10 +206,11 @@ describe("Cloudflare CDN warmup", () => { ); }); - it("warms canonical full RSC, loading shell, and HTML with browser-identical requests", async () => { + it("warms canonical RSC, HTML, and Pages data with browser-identical requests", async () => { const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); - return headers.get("rsc") === "1" ? cacheableRsc() : cacheableHtml(); + if (headers.get("rsc") === "1") return cacheableRsc(); + return headers.get("accept") === "application/json" ? cacheablePagesData() : cacheableHtml(); }); const result = await warmCdnCache({ @@ -204,25 +219,27 @@ describe("Cloudflare CDN warmup", () => { expectedRscBuildId: "rsc-build-a", fetchImpl: fetchImpl as typeof fetch, loadingShellPaths: ["/search?q=x"], + pagesDataPaths: ["/_next/data/build-a/pages.json"], paths: ["/search?q=x"], rscPaths: ["/search?q=x"], targetUrl: "https://app.example.com", }); expect(result).toEqual({ - total: 3, - warmed: 3, + total: 4, + warmed: 4, skipped: 0, failed: 0, failures: [], warmedPlan: { loadingShellPaths: ["/search?q=x"], + pagesDataPaths: ["/_next/data/build-a/pages.json"], paths: ["/search?q=x"], rscPaths: ["/search?q=x"], }, - retryPlan: { loadingShellPaths: [], paths: [], rscPaths: [] }, + retryPlan: { loadingShellPaths: [], pagesDataPaths: [], paths: [], rscPaths: [] }, }); - expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(fetchImpl).toHaveBeenCalledTimes(4); const fullCall = fetchImpl.mock.calls.find((call) => { const headers = new Headers(call[1]?.headers); return headers.get("rsc") === "1" && !headers.has("next-router-prefetch"); @@ -232,13 +249,19 @@ describe("Cloudflare CDN warmup", () => { return headers.get("rsc") === "1" && headers.get("next-router-prefetch") === "1"; }); const htmlCall = fetchImpl.mock.calls.find( - (call) => new Headers(call[1]?.headers).get("rsc") !== "1", + (call) => new Headers(call[1]?.headers).get("accept") === "text/html", + ); + const pagesDataCall = fetchImpl.mock.calls.find( + (call) => new Headers(call[1]?.headers).get("accept") === "application/json", ); expect(requestHref(fullCall?.[0])).toBe("https://app.example.com/search?q=x&_rsc"); expect(requestHref(shellCall?.[0])).toBe( "https://app.example.com/search?q=x&_rsc=9qLBDIU2NgN178cB", ); expect(requestHref(htmlCall?.[0])).toBe("https://app.example.com/search?q=x"); + expect(requestHref(pagesDataCall?.[0])).toBe( + "https://app.example.com/_next/data/build-a/pages.json", + ); const full = new Headers(fullCall?.[1]?.headers); expect(Object.fromEntries(full)).toMatchObject({ @@ -259,6 +282,7 @@ describe("Cloudflare CDN warmup", () => { const html = new Headers(htmlCall?.[1]?.headers); expect(html.get("accept")).toBe("text/html"); + expect(new Headers(pagesDataCall?.[1]?.headers).get("accept")).toBe("application/json"); for (const call of fetchImpl.mock.calls) { expect(new Headers(call[1]?.headers).get("user-agent")).toBe("vinext-cloudflare-cdn-warm"); } @@ -297,11 +321,24 @@ describe("Cloudflare CDN warmup", () => { skipped: 2, failed: 0, failures: [], - warmedPlan: { loadingShellPaths: [], paths: [], rscPaths: [] }, - retryPlan: { loadingShellPaths: [], paths: [], rscPaths: [] }, + warmedPlan: { loadingShellPaths: [], pagesDataPaths: [], paths: [], rscPaths: [] }, + retryPlan: { loadingShellPaths: [], pagesDataPaths: [], paths: [], rscPaths: [] }, }); }); + it("rejects a Pages data response with a non-JSON representation", async () => { + await expect( + warmCdnCache({ + expectedBuildId: "build-a", + fetchImpl: (async () => cacheableHtml()) as typeof fetch, + pagesDataPaths: ["/_next/data/build-a/about.json"], + paths: [], + strict: true, + targetUrl: "https://app.example.com", + }), + ).rejects.toThrow("expected application/json response"); + }); + it("does not certify a staged cache fill until the entry is reusable", async () => { let attempt = 0; const fetchImpl = vi.fn(async () => { @@ -738,7 +775,31 @@ describe("Cloudflare CDN warmup", () => { expectedRscBuildId: "rsc-build-a", fetchImpl: fetchImpl as typeof fetch, maxAttempts: 1, - plan: { loadingShellPaths: [], paths: [], rscPaths: ["/not-found"] }, + plan: { loadingShellPaths: [], pagesDataPaths: [], paths: [], rscPaths: ["/not-found"] }, + probeIntervalMs: 0, + requiredConsecutiveSuccesses: 1, + targetUrl: "https://app.example.com", + }), + ).resolves.toEqual({ ready: true }); + }); + + 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"); + return cacheablePagesData(); + }); + + await expect( + waitForCdnWarmTargetReadiness({ + expectedBuildId: "build-a", + fetchImpl: fetchImpl as typeof fetch, + maxAttempts: 1, + plan: { + loadingShellPaths: [], + pagesDataPaths: ["/_next/data/build-a/about.json"], + paths: [], + rscPaths: [], + }, probeIntervalMs: 0, requiredConsecutiveSuccesses: 1, targetUrl: "https://app.example.com", @@ -763,7 +824,7 @@ describe("Cloudflare CDN warmup", () => { expectedBuildId: "build-a", fetchImpl: fetchImpl as typeof fetch, maxAttempts: 1, - plan: { loadingShellPaths: [], paths: ["/"], rscPaths: [] }, + plan: { loadingShellPaths: [], pagesDataPaths: [], paths: ["/"], rscPaths: [] }, probeIntervalMs: 0, requiredConsecutiveSuccesses: 1, targetUrl: "https://app.example.com", @@ -788,7 +849,7 @@ describe("Cloudflare CDN warmup", () => { fetchImpl: fetchImpl as typeof fetch, maxAttempts: 100, phaseTimeoutMs: 25, - plan: { loadingShellPaths: [], paths: ["/"], rscPaths: [] }, + plan: { loadingShellPaths: [], pagesDataPaths: [], paths: ["/"], rscPaths: [] }, probeIntervalMs: 10, requiredConsecutiveSuccesses: 1, targetUrl: "https://app.example.com", diff --git a/tests/e2e/cloudflare-pages-router/cacheability-probe.spec.ts b/tests/e2e/cloudflare-pages-router/cacheability-probe.spec.ts index eae6875fb2..76c0da0856 100644 --- a/tests/e2e/cloudflare-pages-router/cacheability-probe.spec.ts +++ b/tests/e2e/cloudflare-pages-router/cacheability-probe.spec.ts @@ -33,6 +33,9 @@ test("classifies Pages data contracts inside the staged Worker", async ({ reques [probeHeader]: "1", [secretHeader]: readPrerenderSecret(), }; + const html = await (await request.get(`${BASE}/revalidate-target`)).text(); + const runtimeBuildId = html.match(/"buildId":"([^"]+)"/)?.[1]; + expect(runtimeBuildId).toBeDefined(); // Ported from Next.js: test/e2e/prerender.test.ts and // test/e2e/getserversideprops/test/index.test.ts. @@ -50,6 +53,19 @@ test("classifies Pages data contracts inside the staged Worker", async ({ reques }); } + for (const pathname of ["/revalidate-target"]) { + const dataPath = `/_next/data/${runtimeBuildId}${pathname}.json`; + const response = await request.get(`${BASE}${dataPath}`, { headers }); + expect(response.ok(), dataPath).toBe(true); + await expect(response.json(), dataPath).resolves.toMatchObject({ + kind: "pages-page", + pattern: pathname, + state: "static-candidate", + status: 200, + version: 1, + }); + } + for (const pathname of ["/", "/ssr"]) { const response = await request.get(`${BASE}${pathname}`, { headers }); expect(response.ok(), pathname).toBe(true); @@ -61,4 +77,15 @@ test("classifies Pages data contracts inside the staged Worker", async ({ reques version: 1, }); } + + const ssrDataPath = `/_next/data/${runtimeBuildId}/ssr.json`; + const ssrData = await request.get(`${BASE}${ssrDataPath}`, { headers }); + expect(ssrData.ok(), ssrDataPath).toBe(true); + await expect(ssrData.json(), ssrDataPath).resolves.toMatchObject({ + kind: "pages-page", + pattern: "/ssr", + state: "dynamic", + status: 204, + version: 1, + }); }); diff --git a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts index 9ed7e4c559..70f0c101ee 100644 --- a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts +++ b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; const probeHeader = "X-Vinext-Cacheability-Probe"; const secretHeader = "X-Vinext-Prerender-Secret"; +const buildId = "ppr-impact-demo-cacheability"; function prerenderSecret(): string { const manifest = JSON.parse( @@ -31,6 +32,24 @@ test("classifies Pages Router data contracts inside the staged Worker", async ({ }); } + for (const [pathname, pattern] of [ + [`/_next/data/${buildId}/cacheability-pages/isr.json`, "/cacheability-pages/isr"], + [ + `/_next/data/${buildId}/cacheability-pages/posts/known.json`, + "/cacheability-pages/posts/:slug", + ], + ] as const) { + const response = await request.get(pathname, { headers }); + expect(response.ok(), pathname).toBe(true); + await expect(response.json(), pathname).resolves.toMatchObject({ + kind: "pages-page", + pattern, + state: "static-candidate", + status: 200, + version: 1, + }); + } + for (const pathname of ["/cacheability-pages/gssp", "/cacheability-pages/get-initial-props"]) { const response = await request.get(pathname, { headers }); expect(response.ok(), pathname).toBe(true); @@ -42,6 +61,17 @@ test("classifies Pages Router data contracts inside the staged Worker", async ({ version: 1, }); } + + const gsspDataPath = `/_next/data/${buildId}/cacheability-pages/gssp.json`; + const gsspData = await request.get(gsspDataPath, { headers }); + expect(gsspData.ok(), gsspDataPath).toBe(true); + await expect(gsspData.json(), gsspDataPath).resolves.toMatchObject({ + kind: "pages-page", + pattern: "/cacheability-pages/gssp", + state: "dynamic", + status: 204, + version: 1, + }); }); test("admits only exact manifest-backed Pages Router responses", async ({ request }) => { @@ -51,13 +81,28 @@ test("admits only exact manifest-backed Pages Router responses", async ({ reques expect(response.headers()["cdn-cache-control"], pathname).toContain("max-age=60"); } + for (const pathname of [ + `/_next/data/${buildId}/cacheability-pages/isr.json`, + `/_next/data/${buildId}/cacheability-pages/posts/known.json`, + ]) { + const response = await request.get(pathname, { headers: { Accept: "application/json" } }); + expect(response.status(), pathname).toBe(200); + expect(response.headers()["content-type"], pathname).toContain("application/json"); + expect(response.headers()["cdn-cache-control"], pathname).toContain("max-age=60"); + } + 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: "text/html" } }); + const response = await request.get(pathname, { + headers: { Accept: pathname.includes("/_next/data/") ? "application/json" : "text/html" }, + }); expect(response.headers()["cache-control"], pathname).toContain("no-store"); expect(response.headers()["cdn-cache-control"], pathname).toBeUndefined(); } @@ -82,6 +127,19 @@ test("keeps middleware cookie variants private", async ({ request }) => { expect(middlewarePrivate.headers()["x-cacheability-middleware"]).toBe("matched"); expect(middlewarePrivate.headers()["cache-control"]).toContain("no-store"); expect(middlewarePrivate.headers()["cdn-cache-control"]).toBeUndefined(); + + const dataPath = `/_next/data/${buildId}/cacheability-pages/middleware.json`; + for (const cookie of [undefined, "variant=private"]) { + const response = await request.get(dataPath, { + headers: { + Accept: "application/json", + ...(cookie ? { Cookie: cookie } : {}), + }, + }); + expect(response.status(), cookie ?? "public").toBe(200); + expect(response.headers()["cache-control"], cookie ?? "public").toContain("no-store"); + expect(response.headers()["cdn-cache-control"], cookie ?? "public").toBeUndefined(); + } }); test("keeps config-header cookie variants private", async ({ request }) => { @@ -100,20 +158,38 @@ test("keeps config-header cookie variants private", async ({ request }) => { expect(configPrivate.headers()["x-cacheability-config"]).toBe("private"); expect(configPrivate.headers()["cache-control"]).toContain("no-store"); expect(configPrivate.headers()["cdn-cache-control"]).toBeUndefined(); + + const dataPath = `/_next/data/${buildId}/cacheability-pages/config-header.json`; + for (const cookie of [undefined, "variant=private"]) { + const response = await request.get(dataPath, { + headers: { + Accept: "application/json", + ...(cookie ? { Cookie: cookie } : {}), + }, + }); + expect(response.status(), cookie ?? "public").toBe(200); + expect(response.headers()["cache-control"], cookie ?? "public").toContain("no-store"); + expect(response.headers()["cdn-cache-control"], cookie ?? "public").toBeUndefined(); + } }); test("keeps invalid preview-cookie cleanup private", async ({ request }) => { // Ported from Next.js: test/e2e/prerender-preview/prerender-preview.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/prerender-preview/prerender-preview.test.ts - const response = await request.get("/cacheability-pages/isr", { - headers: { - Accept: "text/html", - Cookie: "__prerender_bypass=invalid; __next_preview_data=invalid", - }, - }); + for (const pathname of [ + "/cacheability-pages/isr", + `/_next/data/${buildId}/cacheability-pages/isr.json`, + ]) { + const response = await request.get(pathname, { + headers: { + Accept: pathname.includes("/_next/data/") ? "application/json" : "text/html", + Cookie: "__prerender_bypass=invalid; __next_preview_data=invalid", + }, + }); - expect(response.status()).toBe(200); - expect(response.headers()["set-cookie"]).toBeDefined(); - expect(response.headers()["cache-control"]).toContain("no-store"); - expect(response.headers()["cdn-cache-control"]).toBeUndefined(); + expect(response.status(), pathname).toBe(200); + expect(response.headers()["set-cookie"], pathname).toBeDefined(); + expect(response.headers()["cache-control"], pathname).toContain("no-store"); + expect(response.headers()["cdn-cache-control"], pathname).toBeUndefined(); + } }); diff --git a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json index 8efa9f2db1..05dbd93ce2 100644 --- a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json +++ b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json @@ -89,6 +89,14 @@ "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 + }, "[\"pages-page\",\"/cacheability-pages/middleware\",\"html\",\"/cacheability-pages/middleware\"]": { "kind": "pages-page", "pattern": "/cacheability-pages/middleware", @@ -128,6 +136,14 @@ "requestKey": "/cacheability-pages/posts/known", "state": "static-candidate", "status": 200 + }, + "[\"pages-page\",\"/cacheability-pages/posts/:slug\",\"pages-data\",\"/_next/data/ppr-impact-demo-cacheability/cacheability-pages/posts/known.json\"]": { + "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 } }, "version": 1 diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 1cd570d39c..81d7b08bf1 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -1162,6 +1162,16 @@ describe("prerender path manifest", () => { "/posts/a%2fb", ]); expect(manifest?.pagesPaths).toEqual(manifest?.paths); + expect(manifest?.pagesDataPaths).toEqual([ + "/docs/_next/data/build-a/posts/hello.json", + "/docs/_next/data/build-a/fr/posts/bonjour.json", + "/docs/_next/data/build-a/fr/posts/string-fr.json", + "/docs/_next/data/build-a/FR/posts/string-fr-upper.json", + "/docs/_next/data/build-a/en/posts/string-en-explicit.json", + "/docs/_next/data/build-a/posts/string-en.json", + "/docs/_next/data/build-a/posts/%7Euser.json", + "/docs/_next/data/build-a/posts/a%2fb.json", + ]); 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), From ffc8beccca5039953474546f0ad5f285cad6c9c3 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 00:16:34 +0100 Subject: [PATCH 07/14] fix(cache): align Pages cacheability ownership --- packages/vinext/src/build/prerender-paths.ts | 42 +++++++++++++++---- .../vinext/src/server/pages-page-handler.ts | 4 +- tests/fixtures/ppr-impact-demo/pages/_app.tsx | 10 +++++ tests/prerender-paths.test.ts | 11 ++--- 4 files changed, 54 insertions(+), 13 deletions(-) create mode 100644 tests/fixtures/ppr-impact-demo/pages/_app.tsx diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 3c25a890d9..3347ca8831 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -598,6 +598,22 @@ function localizePagesPath( return pathname === "/" ? `/${locale}` : `/${locale}${pathname}`; } +/** + * Next.js data URLs always carry the locale segment, including the default + * locale that is omitted from the corresponding public HTML pathname. + * + * Ported from Next.js: + * packages/next/src/shared/lib/router/utils/format-next-pathname-info.ts + */ +function localizePagesDataPath( + pathname: string, + i18n: ResolvedNextConfig["i18n"], +): string { + if (!i18n) return pathname; + if (extractPagesStaticPathLocale(pathname, i18n).explicitLocalePrefix) return pathname; + return pathname === "/" ? `/${i18n.defaultLocale}` : `/${i18n.defaultLocale}${pathname}`; +} + function extractPagesStaticPathLocale( url: string, i18n: NonNullable, @@ -748,6 +764,7 @@ async function resolveAppWarmPaths(options: { appPaths: string[]; htmlPaths: string[]; loadingShellPaths: string[]; + pagesPaths: string[]; rscPaths: string[]; }> { const appRoutes = await appRouter(options.appDir, options.pageExtensions); @@ -762,6 +779,7 @@ async function resolveAppWarmPaths(options: { const appPaths: string[] = []; const htmlPaths: string[] = []; const loadingShellPaths: string[] = []; + const pagesPaths: string[] = []; for (const pathname of options.paths) { const appMatch = matchAppRoute(pathname, appRoutes); // Pages Router i18n prefixes are routing metadata rather than part of the @@ -780,7 +798,10 @@ async function resolveAppWarmPaths(options: { pagesMatch && (!appMatch || pagesRouteHasPriorityOverAppRoute(pagesMatch.route, appMatch.route)) ) { - if (!isPagesApiRequest) htmlPaths.push(pathname); + if (!isPagesApiRequest) { + htmlPaths.push(pathname); + pagesPaths.push(pathname); + } continue; } if (!appMatch) continue; @@ -805,7 +826,7 @@ async function resolveAppWarmPaths(options: { loadingShellPaths.push(pathname); } } - return { appPaths, htmlPaths, loadingShellPaths, rscPaths }; + return { appPaths, htmlPaths, loadingShellPaths, pagesPaths, rscPaths }; } function configuredRouteAffectsWarmPath( @@ -1008,9 +1029,6 @@ export async function emitPrerenderPathManifest( }) : configuredPagesWarmPaths; const discoveredPagesDataPathSet = new Set(discoveredPagesDataPaths); - const resolvedPagesDataWarmPaths = resolvedPagesWarmPaths.filter((pathname) => - discoveredPagesDataPathSet.has(pathname), - ); const configuredCandidatePaths = paths.filter((pathname) => !excludedWarmPathSet.has(pathname)); const appOwnedWarmPaths = appDir ? await resolveAppWarmPaths({ @@ -1024,11 +1042,21 @@ export async function emitPrerenderPathManifest( appPaths: [], htmlPaths: discoveredAppPaths, loadingShellPaths: discoveredLoadingShellPaths, + pagesPaths: resolvedPagesWarmPaths, rscPaths: discoveredAppPaths, }; const warmPaths = appDir ? appOwnedWarmPaths.htmlPaths : resolvedPagesWarmPaths; + const pagesOwnedWarmPaths = appDir ? appOwnedWarmPaths.pagesPaths : resolvedPagesWarmPaths; + const resolvedPagesDataWarmPaths = pagesOwnedWarmPaths.filter((pathname) => + discoveredPagesDataPathSet.has(pathname), + ); const pagesDataPaths = resolvedPagesDataWarmPaths.map((pathname) => - buildPagesDataHref(config.basePath, config.buildId, pathname, ""), + buildPagesDataHref( + config.basePath, + config.buildId, + localizePagesDataPath(pathname, config.i18n), + "", + ), ); const manifest: PrerenderPathManifest = { @@ -1042,7 +1070,7 @@ export async function emitPrerenderPathManifest( ...(pagesDir ? { pagesDataPaths, - pagesPaths: resolvedPagesWarmPaths, + pagesPaths: pagesOwnedWarmPaths, } : {}), ...(excludedWarmPathSet.size > 0 ? { excludedWarmPaths: Array.from(excludedWarmPathSet) } : {}), diff --git a/packages/vinext/src/server/pages-page-handler.ts b/packages/vinext/src/server/pages-page-handler.ts index 4f049a059d..f2eb4d8463 100644 --- a/packages/vinext/src/server/pages-page-handler.ts +++ b/packages/vinext/src/server/pages-page-handler.ts @@ -569,7 +569,9 @@ export function createPagesPageHandler( const isCacheabilityProbe = isRouteCacheabilityProbe(); const isTopLevelPageRoute = !isRouteMissErrorRender && options?.__forcedRoute === undefined; const hasRequestTimeData = - pagesReadiness.gssp === true || pagesReadiness.gip === true || pagesReadiness.appGip === true; + pagesReadiness.gssp === true || + pagesReadiness.gip === true || + (pagesReadiness.appGip === true && !isStaticPropsRoute); if (isTopLevelPageRoute) { beginRouteCacheability("pages-page", route.pattern); diff --git a/tests/fixtures/ppr-impact-demo/pages/_app.tsx b/tests/fixtures/ppr-impact-demo/pages/_app.tsx new file mode 100644 index 0000000000..193f8cb16c --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/pages/_app.tsx @@ -0,0 +1,10 @@ +import type { AppProps } from "next/app"; + +export default function CacheabilityPagesApp({ Component, pageProps }: AppProps) { + return ; +} + +// Next.js explicitly keeps getStaticProps pages static when a custom _app has +// getInitialProps: +// https://github.com/vercel/next.js/blob/canary/packages/next/src/build/index.ts +CacheabilityPagesApp.getInitialProps = async () => ({ pageProps: {} }); diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 81d7b08bf1..7516e03240 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -883,7 +883,8 @@ describe("prerender path manifest", () => { expect(manifest?.paths).toEqual(["/specific/value"]); expect(manifest?.appPaths).toEqual(["/specific/value"]); expect(manifest?.rscPaths).toEqual(["/specific/value"]); - expect(manifest?.pagesPaths).toEqual(["/health", "/specific/value"]); + expect(manifest?.pagesPaths).toEqual([]); + expect(manifest?.pagesDataPaths).toEqual([]); }); it("fails path discovery when generateStaticParams discovery aborts", async () => { @@ -1163,14 +1164,14 @@ describe("prerender path manifest", () => { ]); expect(manifest?.pagesPaths).toEqual(manifest?.paths); expect(manifest?.pagesDataPaths).toEqual([ - "/docs/_next/data/build-a/posts/hello.json", + "/docs/_next/data/build-a/en/posts/hello.json", "/docs/_next/data/build-a/fr/posts/bonjour.json", "/docs/_next/data/build-a/fr/posts/string-fr.json", "/docs/_next/data/build-a/FR/posts/string-fr-upper.json", "/docs/_next/data/build-a/en/posts/string-en-explicit.json", - "/docs/_next/data/build-a/posts/string-en.json", - "/docs/_next/data/build-a/posts/%7Euser.json", - "/docs/_next/data/build-a/posts/a%2fb.json", + "/docs/_next/data/build-a/en/posts/string-en.json", + "/docs/_next/data/build-a/en/posts/%7Euser.json", + "/docs/_next/data/build-a/en/posts/a%2fb.json", ]); 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", From 216b54b3c321e00ce60277868029db18873c7369 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 00:21:31 +0100 Subject: [PATCH 08/14] test(cache): preserve Pages ownership order --- packages/vinext/src/build/prerender-paths.ts | 5 +---- tests/prerender-paths.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 3347ca8831..32745f916c 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -605,10 +605,7 @@ function localizePagesPath( * Ported from Next.js: * packages/next/src/shared/lib/router/utils/format-next-pathname-info.ts */ -function localizePagesDataPath( - pathname: string, - i18n: ResolvedNextConfig["i18n"], -): string { +function localizePagesDataPath(pathname: string, i18n: ResolvedNextConfig["i18n"]): string { if (!i18n) return pathname; if (extractPagesStaticPathLocale(pathname, i18n).explicitLocalePrefix) return pathname; return pathname === "/" ? `/${i18n.defaultLocale}` : `/${i18n.defaultLocale}${pathname}`; diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 7516e03240..c2d73262e4 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -745,7 +745,7 @@ describe("prerender path manifest", () => { expect(manifest?.appPaths).toEqual(["/pages-dir/static", "/specific/value"]); expect(manifest?.rscPaths).toEqual(["/pages-dir/static", "/specific/value"]); expect(manifest?.loadingShellPaths).toEqual(["/specific/value"]); - expect(manifest?.pagesPaths).toEqual([]); + expect(manifest?.pagesPaths).toEqual(["/pages-dir/foobar"]); }); it("uses the runtime-best App route for App-only loading-shell discovery", async () => { @@ -845,7 +845,7 @@ describe("prerender path manifest", () => { expect(manifest?.appPaths).toEqual(["/fr/api/status"]); expect(manifest?.rscPaths).toEqual(["/fr/api/status"]); expect(manifest?.loadingShellPaths).toEqual(["/fr/api/status"]); - expect(manifest?.pagesPaths).toEqual(["/about", "/fr/about"]); + expect(manifest?.pagesPaths).toEqual(["/fr/about", "/about"]); }); it("resolves Pages-discovered warm paths to their runtime App owner", async () => { From 99d3807264859219c2d17cd3b01b1dd4206e2cf6 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 00:46:27 +0100 Subject: [PATCH 09/14] fix(cache): enforce Pages response admission without manifest --- .../vinext/src/server/pages-router-entry.ts | 18 ++++++-- .../route-handler-draft-cache.spec.ts | 45 +++++++++++++++++++ tests/fixtures/cf-app-basic/next.config.ts | 4 ++ .../vite.pages-cdn-cache.config.ts | 14 ++++++ 4 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/cf-app-basic/vite.pages-cdn-cache.config.ts diff --git a/packages/vinext/src/server/pages-router-entry.ts b/packages/vinext/src/server/pages-router-entry.ts index 7382130c9a..f34e0aed36 100644 --- a/packages/vinext/src/server/pages-router-entry.ts +++ b/packages/vinext/src/server/pages-router-entry.ts @@ -44,6 +44,7 @@ import { VINEXT_REVALIDATE_HOST_HEADER, } from "./headers.js"; import { runWithExecutionContext, type ExecutionContextLike } from "vinext/shims/request-context"; +import { getCdnCacheAdapter } from "vinext/shims/cdn-cache"; import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; import { createWorkerPrerenderDiscoveryContext, @@ -126,6 +127,10 @@ async function handleRequest( const requestCtx = createWorkerRevalidationContext(platformCtx, (internalRequest, internalCtx) => handleRequest(internalRequest, env, internalCtx), ); + // Registration must precede admission setup: the active adapter declares + // whether public response headers require a completed-response proof even + // when this build has no embedded two-stage manifest. + registerConfiguredCacheAdapters(env); let ctx = createWorkerPrerenderDiscoveryContext(requestCtx, request, pagesEntry.prerenderSecret); let finalizeCacheabilityResponse: | ((response: Response, ctx: ExecutionContextLike) => Promise) @@ -147,13 +152,19 @@ async function handleRequest( } } } - if (!finalizeCacheabilityResponse && __cacheabilityManifest) { + const requiresCompletedResponseAdmission = + getCdnCacheAdapter().requiresCompletedResponseAdmission === true; + if ( + !finalizeCacheabilityResponse && + (__cacheabilityManifest || requiresCompletedResponseAdmission) + ) { const cacheability = await import("./cacheability-request.js"); const admissionContext = cacheability.createWorkerCacheabilityAdmissionContext( ctx, request, __cacheabilityManifest, pagesEntry.buildId, + requiresCompletedResponseAdmission, ); if (admissionContext !== ctx) { ctx = admissionContext; @@ -165,9 +176,8 @@ async function handleRequest( ? finalizeCacheabilityResponse(response, ctx) : Promise.resolve(response); - // Pass the Worker env so binding-backed adapters (for example KV and Images) - // can resolve their configured bindings before request handling begins. - registerConfiguredCacheAdapters(env); + // Cache adapters were registered above because admission depends on them. + // Register the image adapter before request handling begins. registerConfiguredImageOptimizer(env); try { diff --git a/tests/e2e/cloudflare-workers/route-handler-draft-cache.spec.ts b/tests/e2e/cloudflare-workers/route-handler-draft-cache.spec.ts index 86f72db880..4672f0e664 100644 --- a/tests/e2e/cloudflare-workers/route-handler-draft-cache.spec.ts +++ b/tests/e2e/cloudflare-workers/route-handler-draft-cache.spec.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; import type { APIRequestContext } from "@playwright/test"; import { expect, test } from "../fixtures"; @@ -202,3 +203,47 @@ test.describe("Cloudflare route-handler draft-mode cache isolation", () => { expect(response.headers()["x-vinext-cache"]).toBeUndefined(); }); }); + +test.describe("Cloudflare Pages-only completed-response admission", () => { + const pagesBaseUrl = "http://localhost:4196"; + let pagesServer: ChildProcess; + + test.beforeAll(async () => { + test.setTimeout(90_000); + pagesServer = spawn( + "../../../node_modules/.bin/vp build --config vite.pages-cdn-cache.config.ts && npx wrangler dev --config dist/server/wrangler.json --port 4196", + { cwd: FIXTURE_DIR, shell: true, stdio: "inherit" }, + ); + for (let attempt = 0; attempt < 240; attempt++) { + if (pagesServer.exitCode !== null) { + throw new Error(`cf-app-basic Pages Worker exited with code ${pagesServer.exitCode}`); + } + try { + const response = await fetch(`${pagesBaseUrl}/pages-home`); + if (response.ok) return; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error("Timed out waiting for cf-app-basic Pages Worker"); + }); + + test.afterAll(() => { + pagesServer.kill(); + }); + + test("fails closed without an embedded two-stage manifest", async ({ request }) => { + expect( + fs.readFileSync(`${FIXTURE_DIR}/dist/server/__vinext_cacheability_manifest.js`, "utf8"), + ).toBe("export default null;\n"); + + const response = await request.get(`${pagesBaseUrl}/pages-about`, { + headers: { Accept: "text/html" }, + }); + expect(response.status()).toBe(200); + expect(await response.text()).toContain("About (Pages)"); + expect(response.headers()["cache-control"]).toContain("no-store"); + expect(response.headers()["cdn-cache-control"]).toBeUndefined(); + expect(response.headers()["cloudflare-cdn-cache-control"]).toBeUndefined(); + expect(response.headers()["cache-tag"]).toBeUndefined(); + }); +}); diff --git a/tests/fixtures/cf-app-basic/next.config.ts b/tests/fixtures/cf-app-basic/next.config.ts index 765e09a7f6..2d10d9bc4a 100644 --- a/tests/fixtures/cf-app-basic/next.config.ts +++ b/tests/fixtures/cf-app-basic/next.config.ts @@ -7,6 +7,10 @@ const nextConfig: NextConfig = { source: "/about", headers: [{ key: "X-Page-Header", value: "about-page" }], }, + { + source: "/pages-about", + headers: [{ key: "Cache-Control", value: "private, no-store" }], + }, ]; }, async redirects() { diff --git a/tests/fixtures/cf-app-basic/vite.pages-cdn-cache.config.ts b/tests/fixtures/cf-app-basic/vite.pages-cdn-cache.config.ts new file mode 100644 index 0000000000..3df4101bf4 --- /dev/null +++ b/tests/fixtures/cf-app-basic/vite.pages-cdn-cache.config.ts @@ -0,0 +1,14 @@ +import { cloudflare } from "@cloudflare/vite-plugin"; +import { cdnAdapter } from "@vinext/cloudflare/cache/cdn-adapter"; +import { defineConfig } from "vite"; +import vinext from "vinext"; + +export default defineConfig({ + plugins: [ + vinext({ + cache: { cdn: cdnAdapter() }, + disableAppRouter: true, + }), + cloudflare(), + ], +}); From 0267f367ec78be806dde71e3e6508f514c92e3e6 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 01:28:56 +0100 Subject: [PATCH 10/14] fix(cache): align Pages request-time cache policy --- packages/vinext/src/build/prerender-paths.ts | 14 ++++++--- packages/vinext/src/build/report.ts | 3 +- .../vinext/src/server/cacheability-request.ts | 9 +++++- .../vinext/src/server/pages-page-handler.ts | 18 ++++------- tests/build-report.test.ts | 2 +- tests/cacheability-admission.test.ts | 26 ++++++++++++++++ .../route-handler-draft-cache.spec.ts | 24 ++++++++++++++ .../pages-cacheability.spec.ts | 31 +++++++++++++++++-- .../cacheability-manifest.json | 28 +++++++++++++++-- .../pages/cacheability-pages/gssp-public.tsx | 12 +++++++ tests/prerender-paths.test.ts | 20 ++++++++++++ 11 files changed, 164 insertions(+), 23 deletions(-) create mode 100644 tests/fixtures/ppr-impact-demo/pages/cacheability-pages/gssp-public.tsx diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 32745f916c..9edff2858b 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -494,23 +494,29 @@ async function collectPagesPaths(options: { continue; } - const { hasStaticProps, type } = classifyPagesRoute(route.filePath); - if (type === "api" || type === "ssr") continue; + const { hasServerSideProps, hasStaticProps, type } = classifyPagesRoute(route.filePath); + if (type === "api") continue; if (!route.isDynamic) { if (options.i18n) { for (const locale of options.i18n.locales) { const pathname = localizePagesPath(route.pattern, locale, options.i18n); addPath(paths, seen, pathname); - if (hasStaticProps) addPath(dataPaths, seenDataPaths, pathname); + if (hasStaticProps || hasServerSideProps) addPath(dataPaths, seenDataPaths, pathname); } } else { addPath(paths, seen, route.pattern); - if (hasStaticProps) addPath(dataPaths, seenDataPaths, route.pattern); + if (hasStaticProps || hasServerSideProps) { + addPath(dataPaths, seenDataPaths, route.pattern); + } } continue; } + // A dynamic GSSP route has no enumerable parameter source. It remains + // fail-closed unless another deployment input supplies a concrete path. + if (type === "ssr") continue; + if (!options.baseUrl) continue; try { diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 2a657c896e..c1f0cedec8 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -660,6 +660,7 @@ export function classifyLayoutSegmentConfig(code: string): LayoutBuildClassifica */ export function classifyPagesRoute(filePath: string): { hasStaticProps?: boolean; + hasServerSideProps?: boolean; type: RouteType; revalidate?: number; } { @@ -679,7 +680,7 @@ export function classifyPagesRoute(filePath: string): { const program = parseRouteModule(code); if (program && hasNamedExportInProgram(program, "getServerSideProps")) { - return { type: "ssr" }; + return { hasServerSideProps: true, type: "ssr" }; } if (program && hasNamedExportInProgram(program, "getStaticProps")) { diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index 910b8b9212..198a530220 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -461,7 +461,14 @@ function completedRouteOutcome( ) { return { cacheable: false }; } - return rendererOutcome ?? inferPagesPageCacheability(response); + // Pages request-time routes (GSSP/GIP) are dynamic by default, but Next.js + // deliberately honors an explicit public response policy. ASO/config-header + // responses likewise use the completed policy rather than a hardcoded TTL. + // Ported from Next.js: + // test/e2e/getserversideprops/test/index.test.ts + // test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts + const responseOutcome = inferPagesPageCacheability(response); + return responseOutcome.cacheable ? responseOutcome : (rendererOutcome ?? responseOutcome); } function staticToDynamicResponse(route: CacheabilityManifestRoute): Response { diff --git a/packages/vinext/src/server/pages-page-handler.ts b/packages/vinext/src/server/pages-page-handler.ts index f2eb4d8463..41006d841a 100644 --- a/packages/vinext/src/server/pages-page-handler.ts +++ b/packages/vinext/src/server/pages-page-handler.ts @@ -568,23 +568,17 @@ export function createPagesPageHandler( }); const isCacheabilityProbe = isRouteCacheabilityProbe(); const isTopLevelPageRoute = !isRouteMissErrorRender && options?.__forcedRoute === undefined; - const hasRequestTimeData = - pagesReadiness.gssp === true || - pagesReadiness.gip === true || - (pagesReadiness.appGip === true && !isStaticPropsRoute); - if (isTopLevelPageRoute) { beginRouteCacheability("pages-page", route.pattern); if (isRouteCacheabilityIdentityProbe()) { return new Response(null, { status: 204 }); } - if (hasRequestTimeData) { - recordRouteCacheability({ cacheable: false, dynamicUsage: true }); - // Next.js never executes request-time Pages data functions while - // deciding which routes can be prerendered. The staged probe can make - // the same decision from the matched module contract. - if (isCacheabilityProbe) return new Response(null, { status: 204 }); - } else if (!isStaticPropsRoute) { + if ( + pagesReadiness.gssp !== true && + pagesReadiness.gip !== true && + !(pagesReadiness.appGip === true && !isStaticPropsRoute) && + !isStaticPropsRoute + ) { // Automatic Static Optimization is the Pages Router equivalent of a // `getStaticProps` page with no revalidation window. It has no origin // ISR entry to copy policy from, so carry Next.js's static policy into diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 2401b0aff0..37d58f042c 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -413,7 +413,7 @@ describe("classifyPagesRoute", () => { it("classifies ssr.tsx as ssr", () => { const filePath = path.join(FIXTURES_PAGES, "ssr.tsx"); - expect(classifyPagesRoute(filePath)).toEqual({ type: "ssr" }); + expect(classifyPagesRoute(filePath)).toEqual({ hasServerSideProps: true, type: "ssr" }); }); it("classifies index.tsx as static", () => { diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index 63b991c075..a129f8bba9 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -435,6 +435,32 @@ describe("single-request cacheability admission", () => { await expect(response.text()).resolves.toBe("pages"); }); + 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 + const pagesRequest = new Request("https://example.com/pages-route", { + headers: { Accept: "text/html" }, + }); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + pagesRequest, + null, + "build-a", + true, + ); + const state = cacheabilityState(context); + state.route = { kind: "pages-page", pattern: "/pages-route" }; + state.outcome = { cacheable: false, dynamicUsage: true }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("gssp", { headers: { "Cache-Control": "public, s-maxage=36" } }), + context, + ); + + expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=36"); + await expect(response.text()).resolves.toBe("gssp"); + }); + 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", { diff --git a/tests/e2e/cloudflare-workers/route-handler-draft-cache.spec.ts b/tests/e2e/cloudflare-workers/route-handler-draft-cache.spec.ts index 4672f0e664..3ba985da8a 100644 --- a/tests/e2e/cloudflare-workers/route-handler-draft-cache.spec.ts +++ b/tests/e2e/cloudflare-workers/route-handler-draft-cache.spec.ts @@ -202,6 +202,19 @@ test.describe("Cloudflare route-handler draft-mode cache isolation", () => { expect(response.headers()["cache-tag"]).toBeUndefined(); expect(response.headers()["x-vinext-cache"]).toBeUndefined(); }); + + test("fails hybrid Pages handoffs closed for non-browser Accept variants", async ({ + request, + }) => { + for (const accept of [undefined, "*/*", "application/json"]) { + const response = await request.get(`${BASE_URL}/pages-home`, { + headers: accept ? { Accept: accept } : undefined, + }); + expect(response.status(), accept ?? "missing Accept").toBe(200); + expect(response.headers()["cache-control"], accept ?? "missing Accept").toContain("no-store"); + expect(response.headers()["cdn-cache-control"], accept ?? "missing Accept").toBeUndefined(); + } + }); }); test.describe("Cloudflare Pages-only completed-response admission", () => { @@ -246,4 +259,15 @@ test.describe("Cloudflare Pages-only completed-response admission", () => { expect(response.headers()["cloudflare-cdn-cache-control"]).toBeUndefined(); expect(response.headers()["cache-tag"]).toBeUndefined(); }); + + test("fails closed without an HTML Accept header", async ({ request }) => { + for (const accept of [undefined, "*/*", "application/json"]) { + const response = await request.get(`${pagesBaseUrl}/pages-home`, { + headers: accept ? { Accept: accept } : undefined, + }); + expect(response.status(), accept ?? "missing Accept").toBe(200); + expect(response.headers()["cache-control"], accept ?? "missing Accept").toContain("no-store"); + expect(response.headers()["cdn-cache-control"], accept ?? "missing Accept").toBeUndefined(); + } + }); }); diff --git a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts index 70f0c101ee..eea2919a4a 100644 --- a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts +++ b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts @@ -57,7 +57,23 @@ test("classifies Pages Router data contracts inside the staged Worker", async ({ kind: "pages-page", pattern: pathname, state: "dynamic", - status: 204, + status: 200, + version: 1, + }); + } + + for (const pathname of [ + "/cacheability-pages/gssp-public", + `/_next/data/${buildId}/cacheability-pages/gssp-public.json`, + ]) { + const response = await request.get(pathname, { headers }); + expect(response.ok(), pathname).toBe(true); + await expect(response.json(), pathname).resolves.toMatchObject({ + cacheControl: "public, s-maxage=36", + kind: "pages-page", + pattern: "/cacheability-pages/gssp-public", + state: "static-candidate", + status: 200, version: 1, }); } @@ -69,7 +85,7 @@ test("classifies Pages Router data contracts inside the staged Worker", async ({ kind: "pages-page", pattern: "/cacheability-pages/gssp", state: "dynamic", - status: 204, + status: 200, version: 1, }); }); @@ -91,6 +107,17 @@ test("admits only exact manifest-backed Pages Router responses", async ({ reques expect(response.headers()["cdn-cache-control"], pathname).toContain("max-age=60"); } + for (const pathname of [ + "/cacheability-pages/gssp-public", + `/_next/data/${buildId}/cacheability-pages/gssp-public.json`, + ]) { + const response = await request.get(pathname, { + headers: { Accept: pathname.includes("/_next/data/") ? "application/json" : "text/html" }, + }); + expect(response.status(), pathname).toBe(200); + expect(response.headers()["cdn-cache-control"], pathname).toContain("max-age=36"); + } + for (const pathname of [ "/cacheability-pages/gssp", "/cacheability-pages/get-initial-props", diff --git a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json index 05dbd93ce2..c1cbd39782 100644 --- a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json +++ b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json @@ -119,7 +119,31 @@ "representation": "html", "requestKey": "/cacheability-pages/gssp", "state": "dynamic", - "status": 204 + "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 + }, + "[\"pages-page\",\"/cacheability-pages/gssp-public\",\"html\",\"/cacheability-pages/gssp-public\"]": { + "kind": "pages-page", + "pattern": "/cacheability-pages/gssp-public", + "representation": "html", + "requestKey": "/cacheability-pages/gssp-public", + "state": "static-candidate", + "status": 200 + }, + "[\"pages-page\",\"/cacheability-pages/gssp-public\",\"pages-data\",\"/_next/data/ppr-impact-demo-cacheability/cacheability-pages/gssp-public.json\"]": { + "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", @@ -127,7 +151,7 @@ "representation": "html", "requestKey": "/cacheability-pages/get-initial-props", "state": "dynamic", - "status": 204 + "status": 200 }, "[\"pages-page\",\"/cacheability-pages/posts/:slug\",\"html\",\"/cacheability-pages/posts/known\"]": { "kind": "pages-page", diff --git a/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/gssp-public.tsx b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/gssp-public.tsx new file mode 100644 index 0000000000..4bff471d8a --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/gssp-public.tsx @@ -0,0 +1,12 @@ +export async function getServerSideProps({ + res, +}: { + res: { setHeader(name: string, value: string): void }; +}) { + res.setHeader("Cache-Control", "public, s-maxage=36"); + return { props: { value: "pages-gssp-public" } }; +} + +export default function PagesGsspPublic({ value }: { value: string }) { + return

{value}

; +} diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index c2d73262e4..042a8591eb 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -1328,6 +1328,26 @@ describe("prerender path manifest", () => { expect(manifest?.excludedWarmPaths).toEqual(["/fr/about"]); }); + it("discovers concrete getServerSideProps HTML and data identities", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/entry.js", "export default {};\n"); + writeFile( + "pages/gssp.tsx", + [ + "export async function getServerSideProps() { return { props: {} }; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + + const manifest = await emitPrerenderPathManifest({ root: tmpDir }); + + expect(manifest?.pagesPaths).toEqual(["/gssp"]); + expect(manifest?.pagesDataPaths).toEqual(["/_next/data/build-a/gssp.json"]); + }); + it("does not reload disk config when supplied resolved config", async () => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("next.config.mjs", 'throw new Error("disk config loaded unexpectedly");\n'); From c3e1279ff693166072b5a1179ed5a1ee6763896b Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 01:49:46 +0100 Subject: [PATCH 11/14] test(cache): expect completed Pages request-time probes --- .../e2e/cloudflare-pages-router/cacheability-probe.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/e2e/cloudflare-pages-router/cacheability-probe.spec.ts b/tests/e2e/cloudflare-pages-router/cacheability-probe.spec.ts index 76c0da0856..df0fa6e63d 100644 --- a/tests/e2e/cloudflare-pages-router/cacheability-probe.spec.ts +++ b/tests/e2e/cloudflare-pages-router/cacheability-probe.spec.ts @@ -73,7 +73,9 @@ test("classifies Pages data contracts inside the staged Worker", async ({ reques kind: "pages-page", pattern: pathname, state: "dynamic", - status: 204, + // Request-time Pages routes must run to completion during the probe so + // an explicit public response policy can override their dynamic default. + status: 200, version: 1, }); } @@ -85,7 +87,7 @@ test("classifies Pages data contracts inside the staged Worker", async ({ reques kind: "pages-page", pattern: "/ssr", state: "dynamic", - status: 204, + status: 200, version: 1, }); }); From 53e8271cc6ca537b857db3828986d509f1a59bda Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 01:55:35 +0100 Subject: [PATCH 12/14] fix(cache): preserve routing vetoes during probing --- .../vinext/src/server/cacheability-request.ts | 3 +++ .../pages-cacheability.spec.ts | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index 198a530220..3ab5e8050f 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -451,6 +451,9 @@ function completedRouteOutcome( state: RouteCacheabilityState, rendererOutcome: RouteCacheabilityOutcome | null = state.outcome ?? null, ): RouteCacheabilityOutcome | null { + if (state.forcedDynamicReason) { + return { cacheable: false, reason: state.forcedDynamicReason }; + } if (state.route?.kind === "app-page") { return inferFinalAppPageCacheability(response, state) ?? rendererOutcome; } diff --git a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts index eea2919a4a..e5f656fd40 100644 --- a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts +++ b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts @@ -88,6 +88,27 @@ test("classifies Pages Router data contracts inside the staged Worker", async ({ status: 200, version: 1, }); + + for (const [pathname, reason] of [ + ["/cacheability-pages/middleware", "middleware is eligible for this pathname"], + [ + "/cacheability-pages/config-header", + "next.config headers depend on request headers, cookies, or hostnames", + ], + ] as const) { + const response = await request.get(pathname, { + headers: { ...headers, Accept: "text/html" }, + }); + expect(response.ok(), pathname).toBe(true); + await expect(response.json(), pathname).resolves.toMatchObject({ + kind: "pages-page", + pattern: pathname, + reason, + state: "dynamic", + status: 200, + version: 1, + }); + } }); test("admits only exact manifest-backed Pages Router responses", async ({ request }) => { From e3a8e0cb1119b0c3e024bcfb076b3aba3d929a47 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 02:11:43 +0100 Subject: [PATCH 13/14] fix(cache): preserve final routing cache policy --- .../src/server/app-rsc-response-finalizer.ts | 11 +++- .../vinext/src/server/cacheability-request.ts | 7 ++- packages/vinext/src/server/config-headers.ts | 4 ++ .../src/server/pages-request-pipeline.ts | 34 +++++++++++ .../src/shims/cacheability-classification.ts | 8 +++ tests/cacheability-admission.test.ts | 53 ++++++++++++++++ .../cacheability-admission.spec.ts | 6 ++ .../cacheability-probe.spec.ts | 14 +++++ .../pages-cacheability.spec.ts | 8 +++ .../config-public-dynamic/page.tsx | 5 ++ .../cacheability-manifest.json | 8 +++ tests/fixtures/ppr-impact-demo/next.config.ts | 23 +++++++ .../conditional-redirect.tsx | 7 +++ .../conditional-rewrite.tsx | 7 +++ tests/pages-request-pipeline.test.ts | 61 +++++++++++++++++++ 15 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/config-public-dynamic/page.tsx create mode 100644 tests/fixtures/ppr-impact-demo/pages/cacheability-pages/conditional-redirect.tsx create mode 100644 tests/fixtures/ppr-impact-demo/pages/cacheability-pages/conditional-rewrite.tsx diff --git a/packages/vinext/src/server/app-rsc-response-finalizer.ts b/packages/vinext/src/server/app-rsc-response-finalizer.ts index 840ff5b157..dbff826517 100644 --- a/packages/vinext/src/server/app-rsc-response-finalizer.ts +++ b/packages/vinext/src/server/app-rsc-response-finalizer.ts @@ -13,7 +13,10 @@ import { hasBasePath, stripBasePath } from "../utils/base-path.js"; import { normalizeDefaultLocalePathname } from "./pages-i18n.js"; import { sanitizeMethodNotAllowedHeaders } from "./http-error-responses.js"; import { hasPostConfigLinkHeaders } from "./app-response-header-provenance.js"; -import { captureRouteCacheabilityResponsePolicy } from "vinext/shims/cacheability-classification"; +import { + CACHEABILITY_POLICY_HEADERS, + captureRouteCacheabilityResponsePolicy, +} from "vinext/shims/cacheability-classification"; type FinalizeAppRscResponseOptions = { basePath: string; @@ -38,6 +41,7 @@ type FinalizeAppRscResponseOptions = { const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false"; const configHeadersAlreadyApplied = new WeakSet(); +const CONFIG_CACHE_POLICY_HEADERS = new Set(CACHEABILITY_POLICY_HEADERS); function normalizeExplicitNonCacheablePolicy(headers: Headers): void { if (!hasExplicitNonCacheableResponsePolicy(headers)) return; @@ -80,6 +84,11 @@ export async function applyAppRscConfigHeaders( basePathState: { basePath: options.basePath, hadBasePath }, appendToPostConfigLink: hasPostConfigLinkHeaders(headers), middlewareHeaders: options.middlewareHeaders, + // Next.js next.config headers override its renderer-owned Cache-Control, + // including for force-dynamic App Pages. Other response headers retain + // the existing merge precedence. + // test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts + overwriteExisting: CONFIG_CACHE_POLICY_HEADERS, }); } diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index 3ab5e8050f..95adc430e4 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -393,7 +393,7 @@ function inferFinalAppPageCacheability( response: Response, state: RouteCacheabilityState, ): RouteCacheabilityOutcome | null { - if (!state.frameworkResponseCachePolicy) return null; + if (!state.explicitConfigCachePolicy && !state.frameworkResponseCachePolicy) return null; // Config headers run after the framework snapshots its provisional policy. // Match Next.js by honoring a later explicit public policy instead of @@ -402,7 +402,10 @@ function inferFinalAppPageCacheability( ["cloudflare-cdn-cache-control", "cdn-cache-control", "cache-control"] as const ).find((name) => { const value = response.headers.get(name); - return value !== null && value !== state.frameworkResponseCachePolicy?.[name]; + return ( + value !== null && + (state.explicitConfigCachePolicy || value !== state.frameworkResponseCachePolicy?.[name]) + ); }); if (!changedPolicy) return null; diff --git a/packages/vinext/src/server/config-headers.ts b/packages/vinext/src/server/config-headers.ts index 15fb91ee84..39f8c57121 100644 --- a/packages/vinext/src/server/config-headers.ts +++ b/packages/vinext/src/server/config-headers.ts @@ -8,6 +8,7 @@ import type { HeaderRecord } from "./request-pipeline.js"; import { CACHEABILITY_POLICY_HEADERS, markRouteCacheabilityDynamic, + markRouteCacheabilityExplicitConfigPolicy, markRouteCacheabilityFinalResponseUncacheable, } from "vinext/shims/cacheability-classification"; import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache"; @@ -40,6 +41,9 @@ function markExplicitConfigResponseVeto( markRouteCacheabilityFinalResponseUncacheable("next.config headers set a cookie"); continue; } + if (CACHEABILITY_POLICY_HEADER_NAMES.has(name)) { + markRouteCacheabilityExplicitConfigPolicy(); + } if (CACHEABILITY_POLICY_HEADER_NAMES.has(name) && isNonCacheableCacheControl(header.value)) { markRouteCacheabilityFinalResponseUncacheable( `next.config headers set a non-cacheable ${header.key} policy`, diff --git a/packages/vinext/src/server/pages-request-pipeline.ts b/packages/vinext/src/server/pages-request-pipeline.ts index ba55c4c383..bc661c4d66 100644 --- a/packages/vinext/src/server/pages-request-pipeline.ts +++ b/packages/vinext/src/server/pages-request-pipeline.ts @@ -45,6 +45,29 @@ import { } from "./http-error-responses.js"; import { markRouteCacheabilityDynamic } from "vinext/shims/cacheability-classification"; +function ruleUsesUnkeyedRequestCondition(rule: NextRedirect | NextRewrite): boolean { + return [...(rule.has ?? []), ...(rule.missing ?? [])].some( + (condition) => + condition.type === "header" || condition.type === "cookie" || condition.type === "host", + ); +} + +function markConditionalRewriteCacheability(rewrite: NextRewrite): void { + if (ruleUsesUnkeyedRequestCondition(rewrite)) { + markRouteCacheabilityDynamic( + "next.config rewrite depends on request headers, cookies, or hostnames", + ); + } +} + +function markConditionalRedirectCacheability(redirect: NextRedirect): void { + if (ruleUsesUnkeyedRequestCondition(redirect)) { + markRouteCacheabilityDynamic( + "next.config redirect depends on request headers, cookies, or hostnames", + ); + } +} + // All "render options" that are passed through to the renderPage callback export type PagesRenderOptions = { isDataReq?: boolean; @@ -347,6 +370,7 @@ export async function runPagesRequest( configRedirects, reqCtx, basePathState, + markConditionalRedirectCacheability, ); if (redirect) { // Only prepend basePath when the request was actually under basePath. @@ -597,6 +621,8 @@ export async function runPagesRequest( [rewrite], rewriteRequestContext(), basePathState, + configSourcePathname(), + markConditionalRewriteCacheability, ); if (rewritten) { if (isExternalUrl(rewritten)) { @@ -697,6 +723,8 @@ export async function runPagesRequest( [rewrite], rewriteRequestContext(), basePathState, + configSourcePathname(), + markConditionalRewriteCacheability, ); if (rewritten) { if (isExternalUrl(rewritten)) { @@ -749,6 +777,8 @@ export async function runPagesRequest( [rewrite], rewriteRequestContext(), basePathState, + configSourcePathname(), + markConditionalRewriteCacheability, ); if (!fallbackRewrite) continue; if (isExternalUrl(fallbackRewrite)) { @@ -808,6 +838,8 @@ export async function runPagesRequest( [rewrite], rewriteRequestContext(), basePathState, + configSourcePathname(), + markConditionalRewriteCacheability, ); if (!fallbackRewrite) continue; if (isExternalUrl(fallbackRewrite)) { @@ -897,6 +929,8 @@ export async function runPagesRequest( [rewrite], rewriteRequestContext(), basePathState, + configSourcePathname(), + markConditionalRewriteCacheability, ); if (!fallbackRewrite) continue; if (isExternalUrl(fallbackRewrite)) { diff --git a/packages/vinext/src/shims/cacheability-classification.ts b/packages/vinext/src/shims/cacheability-classification.ts index 0ad642d216..01b5cbf781 100644 --- a/packages/vinext/src/shims/cacheability-classification.ts +++ b/packages/vinext/src/shims/cacheability-classification.ts @@ -31,6 +31,7 @@ export type RouteCacheabilityState = { captureDeadlineAt: number; complete?: (outcome: RouteCacheabilityOutcome) => void; completion?: Promise; + explicitConfigCachePolicy?: boolean; finalResponseVetoReason?: string; forcedDynamicReason?: string; frameworkResponseCachePolicy?: Partial>; @@ -109,6 +110,13 @@ export function markRouteCacheabilityFinalResponseUncacheable(reason: string): v state.finalResponseVetoReason ??= reason; } +/** Record that next.config explicitly owns the final response cache policy. */ +export function markRouteCacheabilityExplicitConfigPolicy(): void { + const state = readRouteCacheabilityState(); + if (!state) return; + state.explicitConfigCachePolicy = true; +} + /** Record framework-owned policy so admission can identify policy added later. */ export function captureRouteCacheabilityResponsePolicy(headers: Headers): void { const state = readRouteCacheabilityState(); diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index a129f8bba9..d82d23951f 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -3,6 +3,7 @@ import { captureCacheabilityAdmissionBody, createCacheabilityAdmissionCaptureBudget, createWorkerCacheabilityAdmissionContext, + createWorkerCacheabilityContext, finalizeWorkerCacheabilityResponse, } from "../packages/vinext/src/server/cacheability-request.js"; import { @@ -202,6 +203,58 @@ describe("single-request cacheability admission", () => { await expect(response.text()).resolves.toBe("dynamic"); }); + it("honors a final public config policy for a completed dynamic App Page", async () => { + // Ported from Next.js: + // test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + null, + "build-a", + true, + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: "/page" }; + state.frameworkResponseCachePolicy = { "cache-control": "no-store" }; + state.completion = Promise.resolve({ cacheable: false, dynamicUsage: true }); + + const response = await finalizeWorkerCacheabilityResponse( + new Response("dynamic", { headers: { "Cache-Control": "s-maxage=32" } }), + context, + ); + expect(response.headers.get("Cache-Control")).toBe("s-maxage=32"); + await expect(response.text()).resolves.toBe("dynamic"); + }); + + it("probes a dynamic App Page with a final public config policy as cacheable", 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.frameworkResponseCachePolicy = { "cache-control": "no-store" }; + state.completion = Promise.resolve({ cacheable: false, dynamicUsage: true }); + + const response = await finalizeWorkerCacheabilityResponse( + new Response("dynamic", { headers: { "Cache-Control": "s-maxage=32" } }), + context, + ); + await expect(response.json()).resolves.toMatchObject({ + cacheControl: "s-maxage=32", + kind: "app-page", + pattern: "/page", + state: "static-candidate", + status: 200, + }); + }); + it.each([undefined, "*/*", "application/json"])( "creates fail-closed request state without an HTML Accept header (%s)", (accept) => { diff --git a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts index d4ed405fb4..4ba0db8ed9 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts @@ -70,6 +70,12 @@ test("admits only exact manifest-backed App Page responses after clean EOF", asy expect(knownDynamic.headers()["cache-control"]).toContain("no-store"); expect(knownDynamic.headers()["cdn-cache-control"]).toBeUndefined(); + const configPublicDynamic = await request.get("/cacheability/config-public-dynamic", { + headers: { Accept: "text/html" }, + }); + expect(configPublicDynamic.status()).toBe(200); + expect(configPublicDynamic.headers()["cdn-cache-control"]).toContain("max-age=32"); + const staticToDynamic = await request.get("/cacheability/static-to-dynamic/runtime", { headers: { Accept: "text/html", "X-Probe-Value": "private-value" }, }); diff --git a/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts index 612dc2f3bc..739ec2443b 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts @@ -75,6 +75,20 @@ test("classifies completed App Page renders inside workerd", async ({ request }) 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", { + headers, + }); + await expect(configPublicDynamicProbe.json()).resolves.toMatchObject({ + cacheControl: "s-maxage=32", + kind: "app-page", + pattern: "/cacheability/config-public-dynamic", + state: "static-candidate", + status: 200, + version: 1, + }); + // Next.js keeps middleware in front of page serving on every request: // test/e2e/middleware-static-files/index.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-static-files/index.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 e5f656fd40..00400d8e88 100644 --- a/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts +++ b/tests/e2e/ppr-impact-demo/pages-cacheability.spec.ts @@ -95,6 +95,14 @@ test("classifies Pages Router data contracts inside the staged Worker", async ({ "/cacheability-pages/config-header", "next.config headers depend on request headers, cookies, or hostnames", ], + [ + "/cacheability-pages/conditional-redirect", + "next.config redirect depends on request headers, cookies, or hostnames", + ], + [ + "/cacheability-pages/conditional-rewrite", + "next.config rewrite depends on request headers, cookies, or hostnames", + ], ] as const) { const response = await request.get(pathname, { headers: { ...headers, Accept: "text/html" }, diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/config-public-dynamic/page.tsx b/tests/fixtures/ppr-impact-demo/app/cacheability/config-public-dynamic/page.tsx new file mode 100644 index 0000000000..6186e6d951 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/config-public-dynamic/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = "force-dynamic"; + +export default function ConfigPublicDynamicPage() { + return

force-dynamic page with an explicit public config policy

; +} diff --git a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json index c1cbd39782..504793e82e 100644 --- a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json +++ b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json @@ -1,6 +1,14 @@ { "buildId": "ppr-impact-demo-cacheability", "routes": { + "[\"app-page\",\"/cacheability/config-public-dynamic\",\"html\",\"/cacheability/config-public-dynamic\"]": { + "kind": "app-page", + "pattern": "/cacheability/config-public-dynamic", + "representation": "html", + "requestKey": "/cacheability/config-public-dynamic", + "state": "static-candidate", + "status": 200 + }, "[\"app-page\",\"/cacheability/dynamic\",\"html\",\"/cacheability/dynamic\"]": { "kind": "app-page", "pattern": "/cacheability/dynamic", diff --git a/tests/fixtures/ppr-impact-demo/next.config.ts b/tests/fixtures/ppr-impact-demo/next.config.ts index 5e7e95607f..a6f14e7ee1 100644 --- a/tests/fixtures/ppr-impact-demo/next.config.ts +++ b/tests/fixtures/ppr-impact-demo/next.config.ts @@ -2,7 +2,30 @@ import type { NextConfig } from "vinext"; export default { generateBuildId: () => "ppr-impact-demo-cacheability", + redirects: async () => [ + { + source: "/cacheability-pages/conditional-redirect", + destination: "/cacheability-pages/isr", + permanent: false, + has: [{ type: "cookie" as const, key: "variant", value: "redirect" }], + }, + ], + rewrites: async () => ({ + beforeFiles: [ + { + source: "/cacheability-pages/conditional-rewrite", + destination: "/cacheability-pages/isr", + has: [{ type: "header" as const, key: "x-variant", value: "rewrite" }], + }, + ], + afterFiles: [], + fallback: [], + }), headers: async () => [ + { + source: "/cacheability/config-public-dynamic", + headers: [{ key: "Cache-Control", value: "s-maxage=32" }], + }, { source: "/cacheability/static", has: [{ type: "query", key: "late-policy", value: "set-cookie" }], diff --git a/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/conditional-redirect.tsx b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/conditional-redirect.tsx new file mode 100644 index 0000000000..98e827dcbc --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/conditional-redirect.tsx @@ -0,0 +1,7 @@ +export function getStaticProps() { + return { props: {}, revalidate: 60 }; +} + +export default function ConditionalRedirectPage() { + return

conditional redirect source

; +} diff --git a/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/conditional-rewrite.tsx b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/conditional-rewrite.tsx new file mode 100644 index 0000000000..681bb95362 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/pages/cacheability-pages/conditional-rewrite.tsx @@ -0,0 +1,7 @@ +export function getStaticProps() { + return { props: {}, revalidate: 60 }; +} + +export default function ConditionalRewritePage() { + return

conditional rewrite source

; +} diff --git a/tests/pages-request-pipeline.test.ts b/tests/pages-request-pipeline.test.ts index a151397fa4..46d7c0d763 100644 --- a/tests/pages-request-pipeline.test.ts +++ b/tests/pages-request-pipeline.test.ts @@ -50,6 +50,24 @@ function makeRenderPage(status = 200, body = "ok") { ); } +async function cacheabilityReasonFor( + request: Request, + overrides: Partial, +): Promise { + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + mode: "probe", + }; + const context = { + [CACHEABILITY_REQUEST_STATE]: state, + waitUntil() {}, + }; + await runWithExecutionContext(context, () => + runPagesRequest(request, baseDeps({ renderPage: makeRenderPage(), ...overrides })), + ); + return state.forcedDynamicReason; +} + describe("on-demand revalidation middleware bypass", () => { it("uses the runtime adapter's authoritative credential verifier", async () => { const runMiddleware = makeMiddleware({}); @@ -128,6 +146,21 @@ describe("trailing slash normalization", () => { // 2. Config redirect: permanent redirect → status 308 with Location describe("config redirects", () => { + it("fails probing closed when an unkeyed redirect condition misses", async () => { + expect( + await cacheabilityReasonFor(makeRequest("/conditional"), { + configRedirects: [ + { + source: "/conditional", + destination: "/private", + permanent: false, + has: [{ type: "cookie", key: "variant", value: "private" }], + }, + ], + }), + ).toBe("next.config redirect depends on request headers, cookies, or hostnames"); + }); + it("permanent redirect returns 308", async () => { const req = makeRequest("/old"); const result = await runPagesRequest( @@ -901,6 +934,34 @@ describe("external proxy", () => { // 9. beforeFiles rewrite with external URL → {type:"response"} from proxy describe("beforeFiles rewrites", () => { + it.each(["beforeFiles", "afterFiles", "fallback"] as const)( + "fails probing closed when an unkeyed %s rewrite condition misses", + async (phase) => { + const rewrite = { + source: "/conditional", + destination: "/private", + has: [{ type: "header" as const, key: "x-variant", value: "private" }], + }; + const reason = await cacheabilityReasonFor(makeRequest("/conditional"), { + configRewrites: { + beforeFiles: phase === "beforeFiles" ? [rewrite] : [], + afterFiles: phase === "afterFiles" ? [rewrite] : [], + fallback: phase === "fallback" ? [rewrite] : [], + }, + ...(phase === "afterFiles" + ? { matchPageRoute: vi.fn().mockReturnValue({ route: { isDynamic: true } }) } + : phase === "fallback" + ? { + matchPageRoute: vi.fn().mockReturnValue(null), + renderPage: makeRenderPage(404, "not found"), + } + : {}), + }); + + expect(reason).toBe("next.config rewrite depends on request headers, cookies, or hostnames"); + }, + ); + it("does not match decoded literal aliases from the normalized route pathname", async () => { const renderPage = makeRenderPage(); const result = await runPagesRequest( From b40e1a98c03334c1aaf5c7eaf9930acd47a8d66c Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 02:23:01 +0100 Subject: [PATCH 14/14] fix(cache): retain middleware cache policy precedence --- packages/vinext/src/server/config-headers.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/vinext/src/server/config-headers.ts b/packages/vinext/src/server/config-headers.ts index 39f8c57121..a589842c31 100644 --- a/packages/vinext/src/server/config-headers.ts +++ b/packages/vinext/src/server/config-headers.ts @@ -147,6 +147,14 @@ export function applyConfigHeadersToResponse( header.key, postConfigLink ? `${header.value}, ${postConfigLink}` : header.value, ); + } else if ( + !ADDITIVE_CONFIG_HEADER_NAMES.has(lowerName) && + options.middlewareHeaders?.has(lowerName) + ) { + // Middleware runs after next.config headers in Next.js, so it remains + // authoritative even when this config field may replace a renderer-owned + // default (notably Cache-Control). + continue; } else if (ADDITIVE_CONFIG_HEADER_NAMES.has(lowerName)) { responseHeaders.append(header.key, header.value); } else if (options.overwriteExisting?.has(lowerName) || !responseHeaders.has(lowerName)) {