From 075815929840b8b84910d2e471fb4c2153cec61a Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 01:42:31 +0100 Subject: [PATCH 1/9] feat(cache): classify and warm static Route Handlers --- README.md | 5 ++ examples/workers-cache/README.md | 4 + packages/cloudflare/README.md | 4 + packages/cloudflare/src/cache/cdn-adapter.ts | 4 + packages/cloudflare/src/cacheability-probe.ts | 4 +- packages/cloudflare/src/cdn-warm.ts | 51 +++++++++++-- packages/cloudflare/src/deploy.ts | 36 +++++++-- packages/vinext/src/build/prerender-paths.ts | 73 ++++++++++++++++--- packages/vinext/src/build/report.ts | 36 +++++++++ .../vinext/src/entries/app-rsc-manifest.ts | 18 +++-- .../src/server/app-route-handler-dispatch.ts | 18 +++-- .../src/server/app-route-handler-execution.ts | 16 +++- .../src/server/app-route-handler-policy.ts | 4 +- packages/vinext/src/server/cache-control.ts | 5 ++ .../src/server/cacheability-manifest.ts | 16 +++- .../vinext/src/server/cacheability-request.ts | 51 ++++++++++++- tests/app-route-handler-policy.test.ts | 14 ++++ tests/build-report.test.ts | 12 +++ tests/cacheability-admission.test.ts | 66 ++++++++++++++++- tests/cacheability-manifest.test.ts | 28 +++++++ tests/cloudflare-cacheability-probe.test.ts | 35 +++++++++ tests/cloudflare-cdn-warm-deploy.test.ts | 30 ++++++++ tests/cloudflare-cdn-warm.test.ts | 21 ++++++ .../cacheability-admission.spec.ts | 19 +++++ .../cacheability-probe.spec.ts | 25 +++++++ tests/entry-templates.test.ts | 22 ++++++ .../route-handler-dynamic/route.ts | 8 ++ .../route-handler-static/route.ts | 5 ++ .../cacheability-manifest.json | 8 ++ tests/prerender-paths.test.ts | 48 ++++++++++++ 30 files changed, 639 insertions(+), 47 deletions(-) create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-dynamic/route.ts create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-static/route.ts diff --git a/README.md b/README.md index 031451aa06..2fbea2c04a 100644 --- a/README.md +++ b/README.md @@ -725,6 +725,11 @@ must prove every planned entry reusable before promotion. While the data adapter can store entries and serve HIT/STALE itself, the CDN adapter delegates serving to Cloudflare's edge: the origin renders fresh responses and tags them with `Cache-Tag`, and `revalidateTag()` / `revalidatePath()` purge the edge through `ctx.cache.purge({ tags })`. See [examples/workers-cache](examples/workers-cache) for both adapters wired up together. +Keep Cloudflare's incoming cache key query-sensitive when using `cdnAdapter()`. +The two-stage cacheability manifest authorizes exact pathname + query +identities, and a Cache Rule that ignores or normalizes query strings can serve +an edge HIT before the Worker has a chance to enforce that identity. + Each builder returns a plain, serializable `{ adapter, options }` descriptor — **it never touches the Workers runtime**, so nothing throws at build or dev time when bindings aren't available. The actual adapter (and its `env` binding lookup) is instantiated lazily on the first request. Registration is wired into **every router and runtime** — App Router and Pages Router, on Cloudflare Workers as well as the Node.js server (`vinext start`) and dev. It self-guards (instantiated once per isolate) and is resilient: if an adapter can't initialize on a given runtime (e.g. a KV binding doesn't exist on the Node server), vinext logs a warning and falls back to the default handler instead of failing requests. diff --git a/examples/workers-cache/README.md b/examples/workers-cache/README.md index e0f246f1f8..df9d1a212c 100644 --- a/examples/workers-cache/README.md +++ b/examples/workers-cache/README.md @@ -27,6 +27,10 @@ The Workers Cache only exposes `ctx.cache` when `cache.enabled: true` is set in `wrangler.jsonc`, and the KV adapter needs a matching `VINEXT_KV_CACHE` namespace binding — both are configured there. +The incoming Cloudflare cache key must retain the full query string. A Cache +Rule that ignores or normalizes query parameters can collapse distinct +manifest identities before the Worker runs. + ## What's in the box - ISR-cached App Router page at `/cached/[slug]` (`revalidate = 60`). diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index ec6dd24fa7..8f95c34f0d 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -67,6 +67,10 @@ makes one final fill request per admitted identity. Add `--warm-cdn-certify` only when you want an opt-in second, header-only request that must prove every planned entry reusable before promotion. +Do not configure a Cache Rule that ignores or normalizes query strings for +these responses. The two-stage cacheability manifest authorizes the full +pathname + query identity, but an edge HIT happens before Worker admission. + ## Deploy Deploy Cloudflare Workers projects with the package CLI: diff --git a/packages/cloudflare/src/cache/cdn-adapter.ts b/packages/cloudflare/src/cache/cdn-adapter.ts index 5568d88cb3..e867edf11b 100644 --- a/packages/cloudflare/src/cache/cdn-adapter.ts +++ b/packages/cloudflare/src/cache/cdn-adapter.ts @@ -30,6 +30,10 @@ export type CdnAdapterOptions = { * ``` * Wrangler does not inherit `version_metadata` into named environments. Repeat * the binding in every `env.` used for CDN warmup. + * + * Cache Rules must preserve the full query string in the incoming cache key. + * Two-stage admission certifies exact pathname + search identities, while an + * edge HIT is served before the Worker can reject a differently keyed request. */ export function cdnAdapter(options?: CdnAdapterOptions) { if ( diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index 7959917901..70610b02f1 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -314,7 +314,9 @@ export async function probeStagedWorkerCacheability(options: { } if ( result.version !== 1 || - (result.kind !== "app-page" && result.kind !== "pages-page") || + (result.kind !== "app-page" && + result.kind !== "app-route" && + result.kind !== "pages-page") || typeof result.pattern !== "string" || !result.pattern.startsWith("/") || !isProbeRouteState(result.state) || diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 0944270812..70ec76e536 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -30,6 +30,8 @@ export type CdnWarmOptions = { paths: readonly string[]; /** Pages Router JSON data identities used by client navigation. */ pagesDataPaths?: readonly string[]; + /** Statically eligible App Route Handler request identities. */ + routeHandlerPaths?: 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. */ @@ -84,6 +86,7 @@ export type CdnWarmRequestPlan = { pagesDataPaths: string[]; paths: string[]; rscPaths: string[]; + routeHandlerPaths?: string[]; }; export type CdnWarmReadinessResult = { ready: true } | { error: string; ready: false }; @@ -97,6 +100,7 @@ export type PrerenderWarmPlan = { pagesDataPaths?: string[]; pagesPaths?: string[]; paths: string[]; + routeHandlerPaths?: string[]; rscBuildId?: string; rscPaths: string[]; }; @@ -150,6 +154,9 @@ function readPrerenderPathManifest(manifestPath: string): PrerenderPathManifest (manifest.rscPaths !== undefined && (!Array.isArray(manifest.rscPaths) || !manifest.rscPaths.every((pathname) => typeof pathname === "string"))) || + (manifest.routeHandlerPaths !== undefined && + (!Array.isArray(manifest.routeHandlerPaths) || + !manifest.routeHandlerPaths.every((pathname) => typeof pathname === "string"))) || (manifest.loadingShellPaths !== undefined && (!Array.isArray(manifest.loadingShellPaths) || !manifest.loadingShellPaths.every((pathname) => typeof pathname === "string"))) || @@ -254,6 +261,9 @@ export function readPrerenderWarmPlan( paths: htmlPaths, ...(supportsCanonicalRsc ? { rscBuildId: manifest.rscBuildId } : {}), rscPaths: supportsCanonicalRsc ? manifest.rscPaths!.map(applyConfig) : [], + ...(manifest.routeHandlerPaths + ? { routeHandlerPaths: manifest.routeHandlerPaths.map(applyConfig) } + : {}), }; } @@ -364,7 +374,7 @@ async function fetchHeadersWithTimeout( export type CdnWarmTarget = { headers?: HeadersInit; - kind: "html" | "pages-data" | "rsc-full" | "rsc-loading-shell"; + kind: "app-route" | "html" | "pages-data" | "rsc-full" | "rsc-loading-shell"; label: string; pathname: string; sourcePathname: string; @@ -373,7 +383,13 @@ export type CdnWarmTarget = { export async function createCdnWarmTargets( options: Pick< CdnWarmOptions, - "deploymentId" | "headers" | "loadingShellPaths" | "pagesDataPaths" | "paths" | "rscPaths" + | "deploymentId" + | "headers" + | "loadingShellPaths" + | "pagesDataPaths" + | "paths" + | "routeHandlerPaths" + | "rscPaths" >, ): Promise { const requests: CdnWarmTarget[] = []; @@ -434,6 +450,17 @@ export async function createCdnWarmTargets( sourcePathname: pathname, }); } + for (const pathname of new Set(options.routeHandlerPaths ?? [])) { + const routeHeaders = new Headers(commonHeaders); + routeHeaders.set("Accept", "*/*"); + requests.push({ + headers: routeHeaders, + kind: "app-route", + label: `${pathname} (Route Handler)`, + pathname, + sourcePathname: pathname, + }); + } return requests; } @@ -717,7 +744,7 @@ function validatePagesDataWarmResponse( function validateReadinessResponse( response: Response, - kind: "html" | "pages-data" | "rsc", + kind: "app-route" | "html" | "pages-data" | "rsc", expectedBuildId?: string, expectedRscBuildId?: string, ): string | null { @@ -774,8 +801,9 @@ export async function waitForCdnWarmTargetReadiness( const rscPath = options.plan.rscPaths[0] ?? options.plan.loadingShellPaths[0]; const htmlPath = options.plan.paths[0]; const pagesDataPath = options.plan.pagesDataPaths[0]; - const kind = rscPath ? "rsc" : htmlPath ? "html" : "pages-data"; - const pathname = rscPath ?? htmlPath ?? pagesDataPath; + const routeHandlerPath = options.plan.routeHandlerPaths?.[0]; + const kind = rscPath ? "rsc" : htmlPath ? "html" : pagesDataPath ? "pages-data" : "app-route"; + const pathname = rscPath ?? htmlPath ?? pagesDataPath ?? routeHandlerPath; if (!pathname) return { ready: true }; if (options.expectedBuildId === undefined && options.expectedRscBuildId === undefined) { return { @@ -792,8 +820,10 @@ export async function waitForCdnWarmTargetReadiness( } } else if (kind === "html") { headers.set("Accept", "text/html"); - } else { + } else if (kind === "pages-data") { headers.set("Accept", "application/json"); + } else { + headers.set("Accept", "*/*"); } headers.set("Cache-Control", "no-cache"); headers.set("Pragma", "no-cache"); @@ -1242,6 +1272,12 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise target.kind === "app-route") + .map((target) => target.sourcePathname); + const failedRouteHandlerPaths = failedRequests + .filter(({ target }) => target.kind === "app-route") + .map(({ target }) => target.sourcePathname); console.log( ` CDN warmup: ${warmed} warmed, ${skippedResults.length} skipped, ${failures.length} failed.`, @@ -1277,6 +1313,7 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise target.kind === "rsc-full") .map((target) => target.sourcePathname), + ...(warmedRouteHandlerPaths.length > 0 ? { routeHandlerPaths: warmedRouteHandlerPaths } : {}), }, retryPlan: { loadingShellPaths: failedRequests @@ -1291,6 +1328,7 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise target.kind === "rsc-full") .map(({ target }) => target.sourcePathname), + ...(failedRouteHandlerPaths.length > 0 ? { routeHandlerPaths: failedRouteHandlerPaths } : {}), }, }; @@ -1316,6 +1354,7 @@ export async function warmCdnCacheFromPrerender( loadingShellPaths: plan.loadingShellPaths, pagesDataPaths: plan.pagesDataPaths, paths: plan.paths, + routeHandlerPaths: plan.routeHandlerPaths, rscPaths: plan.rscPaths, }; return warmCdnCache({ diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 2da5720f10..2f4ea2c5f4 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -691,6 +691,7 @@ export function hasCdnWarmRequests( return ( plan.paths.length + (plan.pagesDataPaths?.length ?? 0) + + (plan.routeHandlerPaths?.length ?? 0) + plan.rscPaths.length + plan.loadingShellPaths.length > 0 @@ -736,6 +737,7 @@ type CdnWarmDeployOptions = Pick< | "expectedRscBuildId" | "loadingShellPaths" | "pagesDataPaths" + | "routeHandlerPaths" | "rscPaths" > & { /** Probe a staged Worker and upload the resulting manifest as a second version. */ @@ -822,32 +824,41 @@ async function deployUploadedVersionWithCdnWarmup( loadingShellPaths: [...(options.loadingShellPaths ?? [])], pagesDataPaths: [...(options.pagesDataPaths ?? [])], paths: [...paths], + routeHandlerPaths: [...(options.routeHandlerPaths ?? [])], rscPaths: [...(options.rscPaths ?? [])], }; let discoveredWarmRequests = remainingWarmPlan.paths.length + remainingWarmPlan.pagesDataPaths.length + + (remainingWarmPlan.routeHandlerPaths?.length ?? 0) + remainingWarmPlan.rscPaths.length + remainingWarmPlan.loadingShellPaths.length; const prepareWarmPlan = (plan: CdnWarmRequestPlan): CdnWarmRequestPlan => { if ( - (plan.paths.length === 0 && plan.pagesDataPaths.length === 0) || + (plan.paths.length === 0 && + plan.pagesDataPaths.length === 0 && + (plan.routeHandlerPaths?.length ?? 0) === 0) || expectedBuildId !== undefined ) { return plan; } if (!allowUnverifiedPromotion) { - const warmupKind = plan.paths.length > 0 ? "CDN HTML warmup" : "CDN Pages data warmup"; + const warmupKind = + plan.paths.length > 0 + ? "CDN HTML warmup" + : plan.pagesDataPaths.length > 0 + ? "CDN Pages data warmup" + : "CDN Route Handler warmup"; throw new Error( `${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 and ${plan.pagesDataPaths.length} Pages data request(s) because the CDN adapter does not declare build-identity response headers.`, + ` CDN warmup: skipping ${plan.paths.length} HTML, ${plan.pagesDataPaths.length} Pages data, and ${plan.routeHandlerPaths?.length ?? 0} Route Handler request(s) because the CDN adapter does not declare build-identity response headers.`, ); - return { ...plan, pagesDataPaths: [], paths: [] }; + return { ...plan, pagesDataPaths: [], paths: [], routeHandlerPaths: [] }; }; const discoverWarmPlan = async (targetUrl: string, headers?: HeadersInit): Promise => { @@ -860,11 +871,13 @@ async function deployUploadedVersionWithCdnWarmup( loadingShellPaths: [...plan.loadingShellPaths], pagesDataPaths: [...(plan.pagesDataPaths ?? [])], paths: [...plan.paths], + routeHandlerPaths: [...(plan.routeHandlerPaths ?? [])], rscPaths: [...plan.rscPaths], }; discoveredWarmRequests = remainingWarmPlan.paths.length + remainingWarmPlan.pagesDataPaths.length + + (remainingWarmPlan.routeHandlerPaths?.length ?? 0) + remainingWarmPlan.rscPaths.length + remainingWarmPlan.loadingShellPaths.length; warmPlanDiscovered = true; @@ -883,6 +896,7 @@ async function deployUploadedVersionWithCdnWarmup( loadingShellPaths: remainingWarmPlan.loadingShellPaths, pagesDataPaths: remainingWarmPlan.pagesDataPaths, paths: remainingWarmPlan.paths, + routeHandlerPaths: remainingWarmPlan.routeHandlerPaths, rscPaths: remainingWarmPlan.rscPaths, }, requireCacheHit = false, @@ -897,6 +911,7 @@ async function deployUploadedVersionWithCdnWarmup( expectedRscBuildId, loadingShellPaths: plan.loadingShellPaths, pagesDataPaths: plan.pagesDataPaths, + routeHandlerPaths: plan.routeHandlerPaths, rscPaths: plan.rscPaths, concurrency: options.warmCdnConcurrency, phaseTimeoutMs: hasPreparedWarmPlan ? DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS : undefined, @@ -930,6 +945,7 @@ async function deployUploadedVersionWithCdnWarmup( options.discoverWarmPlan === undefined || hasPreparedWarmPlan ? remainingWarmPlan.paths.length + remainingWarmPlan.pagesDataPaths.length + + (remainingWarmPlan.routeHandlerPaths?.length ?? 0) + remainingWarmPlan.rscPaths.length + remainingWarmPlan.loadingShellPaths.length : 1; @@ -981,18 +997,20 @@ async function deployUploadedVersionWithCdnWarmup( await discoverWarmPlan(targetUrl, headers); remainingWarmPlan = prepareWarmPlan(remainingWarmPlan); console.log( - ` CDN warmup: discovered ${remainingWarmPlan.paths.length} HTML, ${remainingWarmPlan.pagesDataPaths.length} Pages data, ${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.routeHandlerPaths?.length ?? 0} Route Handler, ${remainingWarmPlan.rscPaths.length} RSC, and ${remainingWarmPlan.loadingShellPaths.length} loading-shell request(s).`, ); } const stagedWarmPlan: CdnWarmRequestPlan = { loadingShellPaths: remainingWarmPlan.loadingShellPaths, pagesDataPaths: remainingWarmPlan.pagesDataPaths, paths: remainingWarmPlan.paths, + routeHandlerPaths: remainingWarmPlan.routeHandlerPaths, rscPaths: remainingWarmPlan.rscPaths, }; const stagedWarmRequests = stagedWarmPlan.paths.length + stagedWarmPlan.pagesDataPaths.length + + (stagedWarmPlan.routeHandlerPaths?.length ?? 0) + stagedWarmPlan.rscPaths.length + stagedWarmPlan.loadingShellPaths.length; if (stagedWarmRequests > 0) { @@ -1045,6 +1063,7 @@ async function deployUploadedVersionWithCdnWarmup( loadingShellPaths: warmResult.retryPlan.loadingShellPaths, pagesDataPaths: warmResult.retryPlan.pagesDataPaths, paths: warmResult.retryPlan.paths, + routeHandlerPaths: warmResult.retryPlan.routeHandlerPaths, rscPaths: warmResult.retryPlan.rscPaths, }; if (hasPreparedWarmPlan && options.warmCdnCertify && warmResult.warmed > 0) { @@ -1098,6 +1117,7 @@ async function deployUploadedVersionWithCdnWarmup( const countRemainingWarmRequests = (): number => remainingWarmPlan.paths.length + remainingWarmPlan.pagesDataPaths.length + + (remainingWarmPlan.routeHandlerPaths?.length ?? 0) + remainingWarmPlan.rscPaths.length + remainingWarmPlan.loadingShellPaths.length; @@ -1415,6 +1435,7 @@ async function deployWithCacheabilityProbe( pagesDataPaths: [...(discovered.pagesDataPaths ?? [])], pagesPaths: discovered.pagesPaths ? [...discovered.pagesPaths] : undefined, paths: [...discovered.paths], + routeHandlerPaths: [...(discovered.routeHandlerPaths ?? [])], rscPaths: [...discovered.rscPaths], }; if (!plan.appPaths && !plan.pagesPaths) { @@ -1430,6 +1451,7 @@ async function deployWithCacheabilityProbe( loadingShellPaths: plan.loadingShellPaths, pagesDataPaths: plan.pagesDataPaths, paths: plan.paths, + routeHandlerPaths: plan.routeHandlerPaths, rscPaths: plan.rscPaths, }); if (targets.length > 0) { @@ -1490,6 +1512,9 @@ async function deployWithCacheabilityProbe( paths: probe.cacheableTargets .filter((target) => target.kind === "html") .map((target) => target.sourcePathname), + routeHandlerPaths: probe.cacheableTargets + .filter((target) => target.kind === "app-route") + .map((target) => target.sourcePathname), rscPaths: probe.cacheableTargets .filter((target) => target.kind === "rsc-full") .map((target) => target.sourcePathname), @@ -1533,6 +1558,7 @@ async function deployWithCacheabilityProbe( expectedDeploymentState: stagedProbeDeployment, loadingShellPaths: prepared.plan.loadingShellPaths, pagesDataPaths: prepared.plan.pagesDataPaths, + routeHandlerPaths: prepared.plan.routeHandlerPaths, 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 9edff2858b..b4c88279a0 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -17,7 +17,12 @@ import { normalizeStaticPathsEntry, type StaticPathsEntry, } from "../routing/route-pattern.js"; -import { getAppRouteRenderEntryPath, classifyAppRoute, classifyPagesRoute } from "./report.js"; +import { + getAppRouteRenderEntryPath, + classifyAppRoute, + classifyAppRouteHandler, + classifyPagesRoute, +} from "./report.js"; import { buildUrlFromParams, resolveParentParams, type StaticParamsMap } from "./prerender.js"; import { readPrerenderSecret } from "./server-manifest.js"; import { startProdServer } from "../server/prod-server.js"; @@ -46,6 +51,8 @@ export type PrerenderPathManifest = { responseVary?: CdnCacheAdapterCapabilities["responseVary"]; /** App Router paths discovered without rendering their page responses. */ rscPaths?: string[]; + /** Statically eligible App Route Handler request paths. */ + routeHandlerPaths?: string[]; /** App Router paths with an ordinary main-tree loading boundary. */ loadingShellPaths?: string[]; /** Pages Router paths selected by the existing HTML warm discovery pass. */ @@ -640,12 +647,14 @@ async function collectAppPaths(options: { pageExtensions: readonly string[]; retryOptions?: PathDiscoveryRetryOptions; secretHeaders: Record; -}): Promise<{ loadingShellPaths: string[]; paths: string[] }> { +}): Promise<{ loadingShellPaths: string[]; paths: string[]; routeHandlerPaths: string[] }> { const routes = await appRouter(options.appDir, options.pageExtensions); const paths: string[] = []; const seen = new Set(); const loadingShellPaths: string[] = []; const seenLoadingShellPaths = new Set(); + const routeHandlerPaths: string[] = []; + const seenRouteHandlerPaths = new Set(); const staticParamsCache = new Map[] | null>>(); const staticParamsMap = new Proxy({} as StaticParamsMap, { get(_target, pattern: string) { @@ -691,14 +700,23 @@ async function collectAppPaths(options: { }); for (const route of routes) { - const renderEntryPath = getAppRouteRenderEntryPath(route); + const isRouteHandler = route.routePath !== null && route.pagePath === null; + const renderEntryPath = isRouteHandler ? route.routePath : getAppRouteRenderEntryPath(route); if (!renderEntryPath) continue; - - const { type } = classifyAppRoute(renderEntryPath, route.routePath, route.isDynamic); - if (type === "api") continue; + if (isRouteHandler) { + const classification = classifyAppRouteHandler(route.routePath!); + if (!classification.hasGet || !classification.staticGenerationEnabled) continue; + } else { + const { type } = classifyAppRoute(renderEntryPath, route.routePath, route.isDynamic); + if (type === "api") continue; + } const hasMainTreeLoadingBoundary = appRouteHasMainTreeLoadingBoundary(route); const addDiscoveredPath = (pathname: string): void => { + if (isRouteHandler) { + addPath(routeHandlerPaths, seenRouteHandlerPaths, pathname); + return; + } addPath(paths, seen, pathname); if (hasMainTreeLoadingBoundary) { addPath(loadingShellPaths, seenLoadingShellPaths, pathname); @@ -754,7 +772,7 @@ async function collectAppPaths(options: { } } - return { loadingShellPaths, paths }; + return { loadingShellPaths, paths, routeHandlerPaths }; } async function resolveAppWarmPaths(options: { @@ -765,12 +783,20 @@ async function resolveAppWarmPaths(options: { paths: readonly string[]; }): Promise<{ appPaths: string[]; + appRoutePaths: string[]; htmlPaths: string[]; loadingShellPaths: string[]; pagesPaths: string[]; rscPaths: string[]; }> { const appRoutes = await appRouter(options.appDir, options.pageExtensions); + const routeHandlerClassifications = new Map( + appRoutes.flatMap((route) => + route.routePath && !route.pagePath + ? [[route.routePath, classifyAppRouteHandler(route.routePath)] as const] + : [], + ), + ); const [pageRoutes, apiRoutes] = options.pagesDir ? await Promise.all([ pagesRouter(options.pagesDir, options.pageExtensions), @@ -780,6 +806,7 @@ async function resolveAppWarmPaths(options: { const rscPaths: string[] = []; const appPaths: string[] = []; + const appRoutePaths: string[] = []; const htmlPaths: string[] = []; const loadingShellPaths: string[] = []; const pagesPaths: string[] = []; @@ -813,6 +840,14 @@ async function resolveAppWarmPaths(options: { // exposes the shared AppRoute fields, so recover the graph-owned metadata // here without rescanning the route table for every concrete path. const matchedAppRoute = appMatch.route as (typeof appRoutes)[number]; + if (matchedAppRoute.routePath && !matchedAppRoute.pagePath) { + const classification = routeHandlerClassifications.get(matchedAppRoute.routePath); + if (classification?.hasGet && classification.staticGenerationEnabled) { + appRoutePaths.push(pathname); + } + continue; + } + const appRenderEntryPath = getAppRouteRenderEntryPath(matchedAppRoute); if (!appRenderEntryPath) continue; if ( @@ -829,7 +864,7 @@ async function resolveAppWarmPaths(options: { loadingShellPaths.push(pathname); } } - return { appPaths, htmlPaths, loadingShellPaths, pagesPaths, rscPaths }; + return { appPaths, appRoutePaths, htmlPaths, loadingShellPaths, pagesPaths, rscPaths }; } function configuredRouteAffectsWarmPath( @@ -915,6 +950,8 @@ export async function emitPrerenderPathManifest( const seenPagesDataPaths = new Set(); const discoveredAppPaths: string[] = []; const seenAppPaths = new Set(); + const discoveredRouteHandlerPaths: string[] = []; + const seenRouteHandlerPaths = new Set(); const discoveredLoadingShellPaths: string[] = []; const seenLoadingShellPaths = new Set(); await withPrerenderEndpoints(async () => { @@ -988,6 +1025,9 @@ export async function emitPrerenderPathManifest( for (const pathname of appPathResult.loadingShellPaths) { addPath(discoveredLoadingShellPaths, seenLoadingShellPaths, pathname); } + for (const pathname of appPathResult.routeHandlerPaths) { + addPath(discoveredRouteHandlerPaths, seenRouteHandlerPaths, pathname); + } } if (pagesDir) { @@ -1016,7 +1056,9 @@ export async function emitPrerenderPathManifest( const excludedWarmPathSet = new Set( options.responseVary - ? paths.filter((pathname) => configuredRouteAffectsWarmPath(pathname, config)) + ? [...paths, ...discoveredRouteHandlerPaths].filter((pathname) => + configuredRouteAffectsWarmPath(pathname, config), + ) : [], ); const configuredPagesWarmPaths = discoveredPagesPaths.filter( @@ -1033,16 +1075,20 @@ export async function emitPrerenderPathManifest( : configuredPagesWarmPaths; const discoveredPagesDataPathSet = new Set(discoveredPagesDataPaths); const configuredCandidatePaths = paths.filter((pathname) => !excludedWarmPathSet.has(pathname)); + const configuredRouteHandlerPaths = discoveredRouteHandlerPaths.filter( + (pathname) => !excludedWarmPathSet.has(pathname), + ); const appOwnedWarmPaths = appDir ? await resolveAppWarmPaths({ appDir, i18n: config.i18n, pagesDir, pageExtensions: config.pageExtensions, - paths: configuredCandidatePaths, + paths: [...configuredCandidatePaths, ...configuredRouteHandlerPaths], }) : { appPaths: [], + appRoutePaths: [], htmlPaths: discoveredAppPaths, loadingShellPaths: discoveredLoadingShellPaths, pagesPaths: resolvedPagesWarmPaths, @@ -1080,6 +1126,9 @@ export async function emitPrerenderPathManifest( ...(rscBuildId ? { rscBuildId } : {}), ...(options.responseVary ? { responseVary: options.responseVary } : {}), ...(options.responseVary ? { rscPaths: appOwnedWarmPaths.rscPaths } : {}), + ...(appOwnedWarmPaths.appRoutePaths.length > 0 + ? { routeHandlerPaths: appOwnedWarmPaths.appRoutePaths } + : {}), ...(options.responseVary ? { loadingShellPaths: appOwnedWarmPaths.loadingShellPaths } : {}), trailingSlash: config.trailingSlash, paths: warmPaths, @@ -1090,7 +1139,9 @@ export async function emitPrerenderPathManifest( JSON.stringify(manifest, null, 2) + "\n", "utf-8", ); - console.log(` Discovered ${warmPaths.length} CDN warmup path(s).`); + console.log( + ` Discovered ${warmPaths.length + appOwnedWarmPaths.appRoutePaths.length} CDN warmup path(s).`, + ); return manifest; } diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index c1f0cedec8..714a6bff2b 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -758,6 +758,42 @@ export function classifyAppRoute( return { type: "unknown" }; } +/** + * Return whether a Route Handler has a GET contract that Next.js considers + * eligible for static generation. This intentionally examines only the route + * module's direct segment config; request-time dynamic usage is decided by the + * staged Worker probe after the module has executed to completion. + * + * Ported from Next.js: + * packages/next/src/server/route-modules/app-route/helpers/is-static-gen-enabled.ts + */ +export function classifyAppRouteHandler(filePath: string): { + hasGet: boolean; + staticGenerationEnabled: boolean; +} { + let code: string; + try { + code = fs.readFileSync(filePath, "utf8"); + } catch { + return { hasGet: false, staticGenerationEnabled: false }; + } + + const program = parseRouteModule(code); + if (!program) return { hasGet: false, staticGenerationEnabled: false }; + + const dynamicValue = extractExportConstStringFromProgram(program, "dynamic"); + const revalidateValue = extractExportConstNumberFromProgram(program, "revalidate"); + return { + hasGet: hasNamedExportInProgram(program, "GET"), + staticGenerationEnabled: + dynamicValue === "force-static" || + dynamicValue === "error" || + revalidateValue === Infinity || + (revalidateValue !== null && revalidateValue > 0) || + hasNamedExportInProgram(program, "generateStaticParams"), + }; +} + // ─── Row building ───────────────────────────────────────────────────────────── /** diff --git a/packages/vinext/src/entries/app-rsc-manifest.ts b/packages/vinext/src/entries/app-rsc-manifest.ts index 383d56a81b..ce83f0e00b 100644 --- a/packages/vinext/src/entries/app-rsc-manifest.ts +++ b/packages/vinext/src/entries/app-rsc-manifest.ts @@ -142,12 +142,9 @@ function registerRouteModules(routes: AppRoute[], imports: ImportAllocator): voi // reached via lazy `{ load }` sources in generateStaticParamsMap, resolved // on demand at prerender time. if (route.pagePath) imports.getLazyLoaderVar(route.pagePath); - // Route handlers are always lazy: they are never referenced by - // generateStaticParamsMap (buildGenerateStaticParamsEntries sources only - // from layouts + page, never route.routePath), so unlike dynamic-route - // pages they have no module-load-time consumer. (Next.js route handlers can - // export generateStaticParams for prerendering, but vinext does not wire - // that into the map yet — a separate gap, unaffected by lazy loading.) + // Route handlers stay lazy. Dynamic handlers that export + // generateStaticParams are reached through a lazy source in + // generateStaticParamsMap, just like page modules. if (route.routePath) imports.getLazyLoaderVar(route.routePath); for (const layout of route.layouts) imports.getLazyLoaderVar(layout); for (const tmpl of route.templates) imports.getLazyLoaderVar(tmpl); @@ -500,6 +497,15 @@ function buildGenerateStaticParamsEntries( `{ load: ${imports.getLazyLoaderVar(route.pagePath)} }`, ); } + if (!route.pagePath && route.routePath) { + // Next.js permits dynamic Route Handlers to enumerate their concrete + // cache identities with generateStaticParams. + appendStaticParamSource( + sourcesByPattern, + route.pattern, + `{ load: ${imports.getLazyLoaderVar(route.routePath)} }`, + ); + } } return Array.from(sourcesByPattern.entries()).map(([pattern, sources]) => { diff --git a/packages/vinext/src/server/app-route-handler-dispatch.ts b/packages/vinext/src/server/app-route-handler-dispatch.ts index 43422dccbd..850155c508 100644 --- a/packages/vinext/src/server/app-route-handler-dispatch.ts +++ b/packages/vinext/src/server/app-route-handler-dispatch.ts @@ -57,6 +57,7 @@ import { createStaticGenerationHeadersContext } from "./app-static-generation.js import { buildPageCacheTags } from "./implicit-tags.js"; import { makeThenableParams } from "vinext/shims/thenable-params"; import { reportRequestError } from "./instrumentation.js"; +import { applyCdnResponseBuildIdentityHeaders } from "./cache-control.js"; type AppRouteHandlerDispatchRoute = { pattern: string; @@ -196,11 +197,13 @@ export async function dispatchAppRouteHandler( isHead, }); options.clearRequestContext(); - return applyDraftModeCachePolicy( - applyRouteHandlerMiddlewareContext(finalized, options.middlewareContext, { - appendResponseLink, - }), - isDraftMode || hasDraftModeTransition, + return applyCdnResponseBuildIdentityHeaders( + applyDraftModeCachePolicy( + applyRouteHandlerMiddlewareContext(finalized, options.middlewareContext, { + appendResponseLink, + }), + isDraftMode || hasDraftModeTransition, + ), ); }; @@ -312,12 +315,12 @@ export async function dispatchAppRouteHandler( setNavigationContext, }); if (cachedRouteResponse) { - return cachedRouteResponse; + return applyCdnResponseBuildIdentityHeaders(cachedRouteResponse); } } if (resolvedHandlerFn) { - return executeAppRouteHandler({ + const response = await executeAppRouteHandler({ basePath: options.basePath, buildPageCacheTags(pathname, extraTags) { return buildRouteHandlerPageCacheTags(pathname, extraTags, route.routeSegments); @@ -356,6 +359,7 @@ export async function dispatchAppRouteHandler( routePattern: route.pattern, setHeadersAccessPhase, }); + return applyCdnResponseBuildIdentityHeaders(response); } return finalizeFrameworkResponse(new Response(null, { status: 405 })); diff --git a/packages/vinext/src/server/app-route-handler-execution.ts b/packages/vinext/src/server/app-route-handler-execution.ts index 5327c0673d..ead55e608b 100644 --- a/packages/vinext/src/server/app-route-handler-execution.ts +++ b/packages/vinext/src/server/app-route-handler-execution.ts @@ -9,7 +9,11 @@ import type { CachedRouteValue } from "vinext/shims/cache-handler"; import type { NextRequest } from "vinext/shims/server"; import { _drainPendingRevalidations } from "vinext/shims/cache-request-state"; import { runWithRootParamsUsage } from "vinext/shims/root-params"; -import { applyCdnResponseHeaders, NEVER_CACHE_CONTROL } from "./cache-control.js"; +import { + applyCdnResponseHeaders, + hasExplicitNonCacheableResponsePolicy, + NEVER_CACHE_CONTROL, +} from "./cache-control.js"; import { isrCacheControl, type IsrWritePolicy } from "./isr-cache.js"; import { createStaticGenerationHeadersContext, @@ -40,6 +44,7 @@ import { import { getRouteCacheabilityCaptureOptions, getRouteCacheabilityDynamicReason, + CACHEABILITY_POLICY_HEADERS, isRouteCacheabilityEvaluation, markRouteCacheabilityFinalResponseUncacheable, } from "vinext/shims/cacheability-classification"; @@ -110,6 +115,13 @@ type CompletedAppRouteHandlerResponse = { response: Response; }; +function hasExplicitCacheableResponsePolicy(headers: Headers): boolean { + return ( + !hasExplicitNonCacheableResponsePolicy(headers) && + CACHEABILITY_POLICY_HEADERS.some((name) => headers.has(name)) + ); +} + async function completeAppRouteHandlerResponse( response: Response, ): Promise { @@ -312,6 +324,7 @@ export async function executeAppRouteHandler( let { dynamicUsedInHandler, response } = handlerResult; assertSupportedAppRouteHandlerResponse(response); const handlerSetCacheControl = response.headers.has("cache-control"); + const hasExplicitCacheablePolicy = hasExplicitCacheableResponsePolicy(response.headers); const draftModeBeforeCompletion = options.getActiveDraftModeState?.() ?? options.isDraftMode === true; @@ -321,6 +334,7 @@ export async function executeAppRouteHandler( shouldCompleteAppRouteHandlerResponse({ dynamicConfig: options.handler.dynamic, dynamicUsedInHandler, + hasExplicitCacheablePolicy, handlerSetCacheControl, isAutoHead: options.isAutoHead, isDraftMode: draftModeBeforeCompletion || handlerDraftCookieBeforeCompletion != null, diff --git a/packages/vinext/src/server/app-route-handler-policy.ts b/packages/vinext/src/server/app-route-handler-policy.ts index f05e1c3d5a..1a9ef7ebba 100644 --- a/packages/vinext/src/server/app-route-handler-policy.ts +++ b/packages/vinext/src/server/app-route-handler-policy.ts @@ -39,6 +39,7 @@ type AppRouteHandlerCacheReadOptions = { type AppRouteHandlerResponseCacheOptions = { dynamicConfig?: string; dynamicUsedInHandler: boolean; + hasExplicitCacheablePolicy?: boolean; handlerSetCacheControl: boolean; isAutoHead: boolean; isDraftMode?: boolean; @@ -172,7 +173,8 @@ export function shouldCompleteAppRouteHandlerResponse( return ( options.isProduction && ((options.revalidateSeconds !== null && options.revalidateSeconds > 0) || - options.requiresCompletedResponseAdmission === true) && + options.requiresCompletedResponseAdmission === true || + options.hasExplicitCacheablePolicy === true) && options.dynamicConfig !== "force-dynamic" && !options.isDraftMode && !options.dynamicUsedInHandler && diff --git a/packages/vinext/src/server/cache-control.ts b/packages/vinext/src/server/cache-control.ts index afadd1bf78..dac517fa9a 100644 --- a/packages/vinext/src/server/cache-control.ts +++ b/packages/vinext/src/server/cache-control.ts @@ -89,6 +89,11 @@ export function applyCdnResponseIdentityHeaders(response: Response, request: Req if (request.headers.get("RSC") !== "1" && !accept.includes("text/html") && !isPagesDataRequest) { return response; } + return applyCdnResponseBuildIdentityHeaders(response); +} + +/** Apply adapter-owned build identity to an already-classified response. */ +export function applyCdnResponseBuildIdentityHeaders(response: Response): 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 d5ee60655b..452cd1a92a 100644 --- a/packages/vinext/src/server/cacheability-manifest.ts +++ b/packages/vinext/src/server/cacheability-manifest.ts @@ -16,8 +16,13 @@ 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" | "pages-data" | "rsc-full" | "rsc-loading-shell"; +export type CacheabilityRouteKind = "app-page" | "app-route" | "pages-page"; +export type CacheabilityRepresentation = + | "app-route" + | "html" + | "pages-data" + | "rsc-full" + | "rsc-loading-shell"; type CacheabilityManifestRouteState = | "static-candidate" | "runtime-check" @@ -50,6 +55,7 @@ export function cacheabilityManifestRouteKey( function isRepresentation(value: unknown): value is CacheabilityRepresentation { return ( + value === "app-route" || value === "html" || value === "pages-data" || value === "rsc-full" || @@ -70,7 +76,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 !== "pages-page") || + (route.kind !== "app-page" && route.kind !== "app-route" && route.kind !== "pages-page") || typeof route.pattern !== "string" || !route.pattern.startsWith("/") || !isRepresentation(route.representation) || @@ -158,7 +164,9 @@ export function cacheabilityRequestIdentity(request: Request): { const isRsc = request.headers.get(RSC_HEADER) === "1" || url.pathname.endsWith(".rsc"); if (!isRsc) { const accept = request.headers.get("Accept")?.toLowerCase() ?? ""; - return accept.includes("text/html") ? { representation: "html", requestKey } : null; + return accept.includes("text/html") + ? { representation: "html", requestKey } + : { representation: "app-route", requestKey }; } if (CONTEXTUAL_RSC_HEADERS.some((header) => request.headers.has(header))) return null; diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index 95adc430e4..c5a90256ca 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -457,6 +457,7 @@ function completedRouteOutcome( if (state.forcedDynamicReason) { return { cacheable: false, reason: state.forcedDynamicReason }; } + if (state.route?.kind === "app-route") return inferPagesPageCacheability(response); if (state.route?.kind === "app-page") { return inferFinalAppPageCacheability(response, state) ?? rendererOutcome; } @@ -521,11 +522,45 @@ async function finalizeWorkerCacheabilityAdmission( // its own stack layer. if (state.preserveResponseCachePolicy) return response; - // Route Handlers prove body completion inside their execution boundary. - // This outer state still carries middleware/config routing vetoes that were - // observed before dispatch and must win over a handler's public policy. + const admission = state.admission; + + // Route Handlers prove body completion inside their execution boundary, so + // the outer Worker does not buffer them a second time. A manifest-bearing + // deployment must still authorize the exact route/path identity before a + // public response policy can escape. Normalize HTML-shaped direct + // navigations to the same Route Handler representation as canonical fetches; + // unsupported Vary fields remain a final veto below. if (state.route?.kind === "app-route") { + let manifestRoute: CacheabilityManifestRoute | null = null; + if ( + !admission || + admission.policy === "deny" || + !admission.representation || + !admission.requestKey + ) { + return responseWithCachePolicy(response, response.body, null); + } + if (admission.policy === "manifest") { + const manifest = admission.manifest as CacheabilityManifest; + const representation = + admission.representation === "html" ? "app-route" : admission.representation; + manifestRoute = findCacheabilityManifestRoute( + manifest, + state.route.kind, + state.route.pattern, + { + representation: representation as Parameters< + typeof findCacheabilityManifestRoute + >[3]["representation"], + requestKey: admission.requestKey, + }, + ); + } if ( + (admission.policy === "manifest" && + (!manifestRoute || + manifestRoute.state !== "static-candidate" || + manifestRoute.status !== response.status)) || response.status >= 500 || state.forcedDynamicReason || hasStrictFinalResponseVeto(response, state) || @@ -536,7 +571,6 @@ async function finalizeWorkerCacheabilityAdmission( return response; } - const admission = state.admission; if ( !admission || admission.policy === "deny" || @@ -547,6 +581,15 @@ async function finalizeWorkerCacheabilityAdmission( ) { return responseWithCachePolicy(response, response.body, null); } + const representationMatchesRoute = + state.route.kind === "app-page" + ? admission.representation === "html" || + admission.representation === "rsc-full" || + admission.representation === "rsc-loading-shell" + : admission.representation === "html" || admission.representation === "pages-data"; + if (!representationMatchesRoute) { + return responseWithCachePolicy(response, response.body, null); + } let manifestRoute: CacheabilityManifestRoute | null = null; if (admission.policy === "manifest") { diff --git a/tests/app-route-handler-policy.test.ts b/tests/app-route-handler-policy.test.ts index ad9967ca0b..7659bea863 100644 --- a/tests/app-route-handler-policy.test.ts +++ b/tests/app-route-handler-policy.test.ts @@ -183,6 +183,20 @@ describe("app route handler policy helpers", () => { ).toBe(true); }); + it("completes explicit public policies even without a static segment config", () => { + expect( + shouldCompleteAppRouteHandlerResponse({ + dynamicUsedInHandler: false, + handlerSetCacheControl: true, + hasExplicitCacheablePolicy: true, + isAutoHead: false, + isProduction: true, + method: "GET", + revalidateSeconds: null, + }), + ).toBe(true); + }); + it("maps special route handler digests to typed redirect and status results", () => { expect( resolveAppRouteHandlerSpecialError( diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 37d58f042c..54d5a90883 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -17,6 +17,7 @@ import { extractGetStaticPropsRevalidate, classifyPagesRoute, classifyAppRoute, + classifyAppRouteHandler, classifyLayoutSegmentConfig, buildReportRows, formatBuildReport, @@ -497,6 +498,17 @@ describe("classifyAppRoute", () => { }); }); +describe("classifyAppRouteHandler", () => { + it("matches Next.js static generation eligibility", () => { + expect( + classifyAppRouteHandler(path.join(FIXTURES_APP, "api", "static-data", "route.ts")), + ).toEqual({ hasGet: true, staticGenerationEnabled: true }); + expect(classifyAppRouteHandler(path.join(FIXTURES_APP, "api", "no-cache", "route.ts"))).toEqual( + { hasGet: true, staticGenerationEnabled: false }, + ); + }); +}); + // ─── buildReportRows ────────────────────────────────────────────────────────── describe("buildReportRows", () => { diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index d82d23951f..c3f0446fc3 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -133,6 +133,27 @@ function staticPagesManifestRoute(): { raw: string; route: CacheabilityManifestR }; } +function staticAppRouteManifest(): { raw: string; route: CacheabilityManifestRoute } { + const route: CacheabilityManifestRoute = { + kind: "app-route", + pattern: "/api/data", + representation: "app-route", + requestKey: "/api/data", + state: "static-candidate", + status: 200, + }; + const key = cacheabilityManifestRouteKey( + route.kind, + route.pattern, + route.representation, + route.requestKey, + ); + 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" }, @@ -270,7 +291,11 @@ describe("single-request cacheability admission", () => { ); expect(context).not.toBe(base); - expect(cacheabilityState(context).admission).toEqual({ policy: "deny" }); + expect(cacheabilityState(context).admission).toEqual({ + policy: "runtime", + representation: "app-route", + requestKey: "/page", + }); }, ); @@ -314,6 +339,45 @@ describe("single-request cacheability admission", () => { await expect(response.text()).resolves.toBe("public"); }); + it.each(["*/*", "text/html"])( + "admits only an exact manifest-backed Route Handler identity for Accept: %s", + async (accept) => { + const { raw } = staticAppRouteManifest(); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/api/data", { headers: { Accept: accept } }), + raw, + "build-a", + ); + cacheabilityState(context).route = { kind: "app-route", pattern: "/api/data" }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("public", { headers: { "Cache-Control": "public, s-maxage=60" } }), + context, + ); + + expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=60"); + }, + ); + + it("keeps an unlisted Route Handler query identity private", async () => { + const { raw } = staticAppRouteManifest(); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/api/data?user=one"), + raw, + "build-a", + ); + cacheabilityState(context).route = { kind: "app-route", pattern: "/api/data" }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("private", { headers: { "Cache-Control": "public, s-maxage=60" } }), + context, + ); + + expect(response.headers.get("Cache-Control")).toContain("no-store"); + }); + it("preserves an independently classified hybrid Pages response", async () => { const context = createWorkerCacheabilityAdmissionContext( { waitUntil() {} }, diff --git a/tests/cacheability-manifest.test.ts b/tests/cacheability-manifest.test.ts index 289d2137e1..13e0a8f6c5 100644 --- a/tests/cacheability-manifest.test.ts +++ b/tests/cacheability-manifest.test.ts @@ -66,6 +66,31 @@ describe("cacheability manifest", () => { ).toEqual(pagesRoute); }); + it("accepts an exact App Route Handler identity", () => { + const appRoute: CacheabilityManifestRoute = { + ...route, + kind: "app-route", + representation: "app-route", + requestKey: "/api/products/one", + }; + const appRouteKey = cacheabilityManifestRouteKey( + appRoute.kind, + appRoute.pattern, + appRoute.representation, + appRoute.requestKey, + ); + const manifest = parseCacheabilityManifest( + JSON.stringify({ buildId: "build-a", routes: { [appRouteKey]: appRoute }, version: 1 }), + "build-a", + ); + expect( + findCacheabilityManifestRoute(manifest!, "app-route", appRoute.pattern, { + representation: "app-route", + requestKey: appRoute.requestKey, + }), + ).toEqual(appRoute); + }); + it("rejects malformed routes instead of partially trusting a manifest", () => { expect( parseCacheabilityManifest( @@ -104,6 +129,9 @@ describe("cacheability manifest", () => { }), ), ).toEqual({ representation: "rsc-full", requestKey: "/products/one?_rsc" }); + expect( + cacheabilityRequestIdentity(new Request("https://example.com/api/products/one")), + ).toEqual({ representation: "app-route", requestKey: "/api/products/one" }); }); it("fails closed for contextual RSC and action requests", () => { diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index 92b26572a0..e52a40e126 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -354,4 +354,39 @@ describe("staged Worker cacheability probes", () => { }), ]); }); + + it("records a statically eligible App Route Handler identity", async () => { + const root = createProbeRoot(); + const appRouteTarget = { + headers: { Accept: "*/*" }, + kind: "app-route" as const, + label: "/api/data (Route Handler)", + pathname: "/api/data", + sourcePathname: "/api/data", + }; + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async () => + Response.json({ + kind: "app-route", + pattern: "/api/data", + state: "static-candidate", + status: 200, + version: 1, + }), + root, + targetUrl: "https://example.com", + targets: [appRouteTarget], + }); + + expect(result.failures).toEqual([]); + expect(result.cacheableTargets).toEqual([appRouteTarget]); + expect(Object.values(result.manifest.routes)).toEqual([ + expect.objectContaining({ + kind: "app-route", + representation: "app-route", + requestKey: "/api/data", + }), + ]); + }); }); diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 22039d61f7..c4e4ec0a47 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -243,6 +243,14 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(hasCdnWarmRequests({ loadingShellPaths: ["/dashboard"], paths: [], rscPaths: [] })).toBe( true, ); + expect( + hasCdnWarmRequests({ + loadingShellPaths: [], + paths: [], + routeHandlerPaths: ["/api/data"], + rscPaths: [], + }), + ).toBe(true); expect(hasCdnWarmRequests({ loadingShellPaths: [], paths: [], rscPaths: [] })).toBe(false); }); @@ -440,6 +448,18 @@ describe("Cloudflare CDN warmup deploy flow", () => { { headers: { [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a" } }, ); } + if (pathname === "/api/data") { + return Response.json( + { + kind: "app-route", + pattern: "/api/data", + state: "static-candidate", + status: 200, + version: 1, + }, + { headers: { [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a" } }, + ); + } return appPageProbeResponse(); } if (isReadinessFetch(input)) events.push("readiness"); @@ -468,6 +488,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { pagesDataPaths: ["/_next/data/app-build-a/pages-about.json"], pagesPaths: ["/pages-about"], paths: ["/about", "/dynamic", "/pages-about"], + routeHandlerPaths: ["/api/data"], rscPaths: [], }), warmCdnConcurrency: 1, @@ -481,6 +502,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(statusCount).toBe(7); expect(Array.from(cacheRequestCounts.entries())).toEqual([ ["/_next/data/app-build-a/pages-about.json", 1], + ["/api/data", 1], ["/about", 1], ["/pages-about", 1], ]); @@ -494,6 +516,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "probe:/dynamic", "probe:/pages-about", "probe:/_next/data/app-build-a/pages-about.json", + "probe:/api/data", "status-3", "upload-final", "status-4", @@ -503,6 +526,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "triggers", "readiness", "warm:/_next/data/app-build-a/pages-about.json", + "warm:/api/data", "warm:/about", "warm:/pages-about", "status-7", @@ -524,6 +548,12 @@ describe("Cloudflare CDN warmup deploy flow", () => { pattern: "/about", state: "static-candidate", }), + expect.objectContaining({ + kind: "app-route", + pattern: "/api/data", + representation: "app-route", + state: "static-candidate", + }), expect.objectContaining({ kind: "pages-page", pattern: "/pages-about", diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index 2c0213bf1f..a46de1d055 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -106,6 +106,7 @@ describe("Cloudflare CDN warmup", () => { responseVary: "verbatim", rscBuildId: "rsc-build-a", rscPaths: ["/dashboard", "/dynamic"], + routeHandlerPaths: ["/api/data"], trailingSlash: true, }), ); @@ -123,6 +124,7 @@ describe("Cloudflare CDN warmup", () => { paths: ["/docs/dashboard/", "/docs/dynamic/", "/docs/pages/"], rscBuildId: "rsc-build-a", rscPaths: ["/docs/dashboard/", "/docs/dynamic/"], + routeHandlerPaths: ["/docs/api/data/"], }); }); @@ -288,6 +290,25 @@ describe("Cloudflare CDN warmup", () => { } }); + it("warms Route Handlers with the canonical fetch request identity", async () => { + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).get("Accept")).toBe("*/*"); + return cacheablePagesData('{"ok":true}'); + }); + + const result = await warmCdnCache({ + expectedBuildId: "build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: [], + routeHandlerPaths: ["/api/data"], + targetUrl: "https://app.example.com", + }); + + expect(result.warmed).toBe(1); + expect(result.warmedPlan.routeHandlerPaths).toEqual(["/api/data"]); + expect(requestHref(fetchImpl.mock.calls[0]?.[0])).toBe("https://app.example.com/api/data"); + }); + it("counts coherent no-store/BYPASS responses as skipped, including in strict mode", async () => { const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { const isRsc = new Headers(init?.headers).get("rsc") === "1"; diff --git a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts index 4ba0db8ed9..2f69631afe 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts @@ -90,4 +90,23 @@ test("admits only exact manifest-backed App Page responses after clean EOF", asy expect(uncertifiedRsc.status()).toBe(200); expect(uncertifiedRsc.headers()["cache-control"]).toContain("no-store"); expect(uncertifiedRsc.headers()["cdn-cache-control"]).toBeUndefined(); + + const certifiedRouteHandler = await request.get("/cacheability/route-handler-static"); + expect(certifiedRouteHandler.status()).toBe(200); + await expect(certifiedRouteHandler.json()).resolves.toEqual({ kind: "static-route-handler" }); + expect(certifiedRouteHandler.headers()["cdn-cache-control"]).toContain("public"); + + const unlistedRouteHandlerQuery = await request.get( + "/cacheability/route-handler-static?user=one", + ); + expect(unlistedRouteHandlerQuery.status()).toBe(200); + expect(unlistedRouteHandlerQuery.headers()["cache-control"]).toContain("no-store"); + expect(unlistedRouteHandlerQuery.headers()["cdn-cache-control"]).toBeUndefined(); + + const dynamicRouteHandler = await request.get("/cacheability/route-handler-dynamic", { + headers: { "X-Probe-Value": "private" }, + }); + await expect(dynamicRouteHandler.json()).resolves.toEqual({ value: "private" }); + expect(dynamicRouteHandler.headers()["cache-control"]).toContain("no-store"); + expect(dynamicRouteHandler.headers()["cdn-cache-control"]).toBeUndefined(); }); diff --git a/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts index 739ec2443b..4ddeeafd8b 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts @@ -89,6 +89,31 @@ test("classifies completed App Page renders inside workerd", async ({ request }) version: 1, }); + const staticRouteHandlerProbe = await request.get("/cacheability/route-handler-static", { + headers: { ...headers, Accept: "*/*" }, + }); + await expect(staticRouteHandlerProbe.json()).resolves.toMatchObject({ + kind: "app-route", + pattern: "/cacheability/route-handler-static", + state: "static-candidate", + status: 200, + version: 1, + }); + + // Next.js lets revalidate make a Route Handler statically eligible, but a + // dynamic API used by the completed handler still opts that route out. + // Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts + const dynamicRouteHandlerProbe = await request.get("/cacheability/route-handler-dynamic", { + headers: { ...headers, Accept: "*/*" }, + }); + await expect(dynamicRouteHandlerProbe.json()).resolves.toMatchObject({ + kind: "app-route", + pattern: "/cacheability/route-handler-dynamic", + state: "dynamic", + 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/entry-templates.test.ts b/tests/entry-templates.test.ts index 6d8b3e96cf..03cb228dc4 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -735,6 +735,28 @@ describe("App Router generated manifest construction", () => { expect(routeEntry).toContain("loadingTreePositions: [1,2]"); }); + it("wires Route Handler generateStaticParams into staged path discovery", () => { + const route = { + ...minimalAppRoutes[1], + pattern: "/api/items/:slug", + patternParts: ["api", "items", ":slug"], + pagePath: null, + routePath: "/tmp/test/app/api/items/[slug]/route.ts", + routeSegments: ["api", "items", "[slug]"], + isDynamic: true, + params: ["slug"], + } satisfies AppRoute; + + const manifest = buildAppRscManifestCode({ routes: [route] }); + + expect(manifest.imports).toContain( + 'const load_0 = () => import("/tmp/test/app/api/items/[slug]/route.ts");', + ); + expect(manifest.generateStaticParamsEntries).toEqual([ + ' "/api/items/:slug": __createAppPrerenderStaticParamsResolver([{ load: load_0 }], []),', + ]); + }); + it("emits positional loading modules for named slots and intercepted branches", () => { const route = { ...minimalAppRoutes[0], diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-dynamic/route.ts b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-dynamic/route.ts new file mode 100644 index 0000000000..08157e5f38 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-dynamic/route.ts @@ -0,0 +1,8 @@ +import { headers } from "next/headers"; + +export const revalidate = 60; + +export async function GET() { + const requestHeaders = await headers(); + return Response.json({ value: requestHeaders.get("X-Probe-Value") ?? "none" }); +} diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-static/route.ts b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-static/route.ts new file mode 100644 index 0000000000..fedbd9fb03 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-static/route.ts @@ -0,0 +1,5 @@ +export const revalidate = 60; + +export function GET() { + return Response.json({ kind: "static-route-handler" }); +} diff --git a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json index 504793e82e..8d4d0d3f93 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 }, + "[\"app-route\",\"/cacheability/route-handler-static\",\"app-route\",\"/cacheability/route-handler-static\"]": { + "kind": "app-route", + "pattern": "/cacheability/route-handler-static", + "representation": "app-route", + "requestKey": "/cacheability/route-handler-static", + "state": "static-candidate", + "status": 200 + }, "[\"pages-page\",\"/cacheability-pages/isr\",\"html\",\"/cacheability-pages/isr\"]": { "kind": "pages-page", "pattern": "/cacheability-pages/isr", diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 042a8591eb..8536477006 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -67,6 +67,12 @@ describe("prerender path manifest", () => { ) { return Response.json([{ category: "news" }]); } + if ( + url.pathname === "/__vinext/prerender/static-params" && + url.searchParams.get("pattern") === "/api/items/:slug" + ) { + return Response.json([{ slug: "one" }, { slug: "two" }]); + } return new Response(null, { status: 204 }); }), ); @@ -137,6 +143,48 @@ describe("prerender path manifest", () => { expect(closeMock).toHaveBeenCalledOnce(); }); + it("discovers only Next.js-static Route Handler GET identities", async () => { + // Ported from Next.js static eligibility and dynamic Route Handler params: + // packages/next/src/server/route-modules/app-route/helpers/is-static-gen-enabled.ts + // test/e2e/app-dir/app-static/app-static.test.ts + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/api/static/route.ts", + "export const revalidate = 60; export function GET() { return Response.json({ ok: true }); }", + ); + writeFile( + "app/api/dynamic/route.ts", + "export function GET(request) { return Response.json({ url: request.url }); }", + ); + writeFile( + "app/api/items/[slug]/route.ts", + [ + "export const revalidate = false;", + "export function generateStaticParams() { return [{ slug: 'one' }, { slug: 'two' }]; }", + "export function GET(_request, { params }) { return Response.json(params); }", + ].join("\n"), + ); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + buildIdentity: "response-header", + responseVary: "verbatim", + }); + + expect(manifest?.routeHandlerPaths).toEqual([ + "/api/static", + "/api/items/one", + "/api/items/two", + ]); + expect(manifest?.paths).toEqual([]); + expect(manifest?.rscPaths).toEqual([]); + }); + it("discovers dynamic paths from an uploaded Worker without loading its bundle in Node", async () => { // No Next.js test port applies: staged Worker version overrides and // cloudflare:workers bindings are Cloudflare-specific. From bd3b85e9142944a3063078a79d8726cbead0fbbb Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 01:49:11 +0100 Subject: [PATCH 2/9] fix(cache): identify Route Handler probe responses --- .../vinext/src/server/cacheability-request.ts | 9 +++-- tests/cacheability-admission.test.ts | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index c5a90256ca..866d6a2510 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -6,6 +6,7 @@ import { type RouteCacheabilityState, } from "vinext/shims/cacheability-classification"; import { + applyCdnResponseBuildIdentityHeaders, applyCdnResponseHeaders, hasExplicitNonCacheableResponsePolicy, isNonCacheableCacheControl, @@ -150,9 +151,11 @@ function probeResponse( status, version: 1, }; - return Response.json(body, { - headers: { "Cache-Control": NO_STORE_CACHE_CONTROL }, - }); + return applyCdnResponseBuildIdentityHeaders( + Response.json(body, { + headers: { "Cache-Control": NO_STORE_CACHE_CONTROL }, + }), + ); } async function drainProbeBody(response: Response, deadlineAt: number): Promise { diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index c3f0446fc3..4ff266a7e2 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -14,6 +14,11 @@ import { cacheabilityManifestRouteKey, type CacheabilityManifestRoute, } from "../packages/vinext/src/server/cacheability-manifest.js"; +import { + DefaultCdnCacheAdapter, + setCdnCacheAdapter, +} from "../packages/vinext/src/shims/cdn-cache.js"; +import { CloudflareCdnCacheAdapter } from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js"; const encoder = new TextEncoder(); @@ -664,4 +669,32 @@ describe("cacheability probe finalization", () => { status: 500, }); }); + + it("preserves build identity on Route Handler probe envelopes", async () => { + const previousBuildId = process.env.__VINEXT_BUILD_ID; + process.env.__VINEXT_BUILD_ID = "build-a"; + setCdnCacheAdapter(new CloudflareCdnCacheAdapter()); + try { + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + mode: "probe", + route: { kind: "app-route", pattern: "/api/data" }, + }; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("static", { headers: { "Cache-Control": "public, s-maxage=60" } }), + contextWith(state), + ); + + expect(response.headers.get("X-Vinext-Build-Id")).toBe("build-a"); + await expect(response.json()).resolves.toMatchObject({ + kind: "app-route", + state: "static-candidate", + }); + } finally { + setCdnCacheAdapter(new DefaultCdnCacheAdapter()); + if (previousBuildId === undefined) delete process.env.__VINEXT_BUILD_ID; + else process.env.__VINEXT_BUILD_ID = previousBuildId; + } + }); }); From 26fc5158b4731a9780699643abbadbca8466da00 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 01:51:45 +0100 Subject: [PATCH 3/9] test(cache): assert Route Handler probe identity --- tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts index 4ddeeafd8b..f83b75ef6a 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts @@ -92,6 +92,7 @@ test("classifies completed App Page renders inside workerd", async ({ request }) const staticRouteHandlerProbe = await request.get("/cacheability/route-handler-static", { headers: { ...headers, Accept: "*/*" }, }); + expect(staticRouteHandlerProbe.headers()["x-vinext-build-id"]).toBeDefined(); await expect(staticRouteHandlerProbe.json()).resolves.toMatchObject({ kind: "app-route", pattern: "/cacheability/route-handler-static", From 266c378b6c893fd9e3f5ae08eb7773825e8aaec1 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 02:14:36 +0100 Subject: [PATCH 4/9] fix(cache): prove final Route Handler responses --- packages/vinext/src/build/report.ts | 30 ++++++++++--- .../src/server/app-route-handler-execution.ts | 9 +--- .../vinext/src/server/cacheability-request.ts | 36 ++++++++++++---- .../src/shims/cacheability-classification.ts | 8 ++++ tests/app-route-handler-execution.test.ts | 16 +++---- tests/build-report.test.ts | 42 +++++++++++++++++++ tests/cacheability-admission.test.ts | 30 +++++++++++++ .../cacheability-admission.spec.ts | 7 ++++ .../route.ts | 13 ++++++ .../cacheability-manifest.json | 8 ++++ tests/fixtures/ppr-impact-demo/next.config.ts | 4 ++ 11 files changed, 173 insertions(+), 30 deletions(-) create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-config-public-late-error/route.ts diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 714a6bff2b..a49fe5db3b 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -177,6 +177,19 @@ function hasNamedExportInProgram(program: Program, name: string): boolean { return false; } +function hasRuntimeExportedNameInProgram(program: Program, name: string): boolean { + for (const node of program.body) { + if (node.type !== "ExportNamedDeclaration" || node.exportKind === "type") continue; + if (declarationHasBindingName(node.declaration, name)) return true; + + for (const specifier of node.specifiers) { + if (specifier.exportKind === "type") continue; + if (moduleExportNameValue(specifier.exported ?? specifier.local) === name) return true; + } + } + return false; +} + function unwrapStaticExpression(expression: Expression): Expression { let current = expression; while ( @@ -783,14 +796,19 @@ export function classifyAppRouteHandler(filePath: string): { const dynamicValue = extractExportConstStringFromProgram(program, "dynamic"); const revalidateValue = extractExportConstNumberFromProgram(program, "revalidate"); + const hasGet = hasRuntimeExportedNameInProgram(program, "GET"); + const hasNonStaticMethod = ["POST", "PUT", "DELETE", "PATCH", "OPTIONS"].some((method) => + hasRuntimeExportedNameInProgram(program, method), + ); return { - hasGet: hasNamedExportInProgram(program, "GET"), + hasGet, staticGenerationEnabled: - dynamicValue === "force-static" || - dynamicValue === "error" || - revalidateValue === Infinity || - (revalidateValue !== null && revalidateValue > 0) || - hasNamedExportInProgram(program, "generateStaticParams"), + !hasNonStaticMethod && + (dynamicValue === "force-static" || + dynamicValue === "error" || + revalidateValue === Infinity || + (revalidateValue !== null && revalidateValue > 0) || + hasRuntimeExportedNameInProgram(program, "generateStaticParams")), }; } diff --git a/packages/vinext/src/server/app-route-handler-execution.ts b/packages/vinext/src/server/app-route-handler-execution.ts index ead55e608b..a797d891aa 100644 --- a/packages/vinext/src/server/app-route-handler-execution.ts +++ b/packages/vinext/src/server/app-route-handler-execution.ts @@ -46,7 +46,7 @@ import { getRouteCacheabilityDynamicReason, CACHEABILITY_POLICY_HEADERS, isRouteCacheabilityEvaluation, - markRouteCacheabilityFinalResponseUncacheable, + markRouteCacheabilityResponseBodyComplete, } from "vinext/shims/cacheability-classification"; import { CACHEABILITY_PROBE_BODY_LIMIT, @@ -347,6 +347,7 @@ export async function executeAppRouteHandler( const completed = await completeAppRouteHandlerResponse(response); response = completed.response; cleanupDeferredToBody = !completed.completed; + if (completed.completed) markRouteCacheabilityResponseBodyComplete(); const dynamicUsedDuringCompletion = options.consumeDynamicUsage(); dynamicUsedInHandler = handlerResult.didAccessDynamicRequest() || @@ -361,12 +362,6 @@ export async function executeAppRouteHandler( requestCacheabilityVeto || cleanupDeferredToBody, ); - if (responseMustStayPrivate) { - markRouteCacheabilityFinalResponseUncacheable( - "Route Handler did not complete as a reusable static response", - ); - } - if (dynamicUsedInHandler) { markKnownDynamicAppRoute(options.routePattern); } diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index 866d6a2510..7b88231b20 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -527,12 +527,13 @@ async function finalizeWorkerCacheabilityAdmission( const admission = state.admission; - // Route Handlers prove body completion inside their execution boundary, so - // the outer Worker does not buffer them a second time. A manifest-bearing - // deployment must still authorize the exact route/path identity before a - // public response policy can escape. Normalize HTML-shaped direct - // navigations to the same Route Handler representation as canonical fetches; - // unsupported Vary fields remain a final veto below. + // Route Handlers normally prove body completion inside their execution + // boundary, so the outer Worker does not buffer them a second time. Config + // headers run later, however, and can make an otherwise dynamic response + // public. Capture only that unproven final-public case before it can escape. + // A manifest-bearing deployment must also authorize the exact route/path + // identity. Normalize HTML-shaped direct navigations to the same Route + // Handler representation as canonical fetches. if (state.route?.kind === "app-route") { let manifestRoute: CacheabilityManifestRoute | null = null; if ( @@ -571,7 +572,28 @@ async function finalizeWorkerCacheabilityAdmission( ) { return responseWithCachePolicy(response, response.body, null); } - return response; + + const outcome = inferPagesPageCacheability(response); + if (!outcome.cacheable || !outcome.cacheControl) { + return responseWithCachePolicy(response, response.body, null); + } + if (state.completedResponseBody) return response; + + let captured: CapturedAdmissionBody; + try { + captured = await captureCacheabilityAdmissionBody( + response.body, + state.captureDeadlineAt, + CACHEABILITY_PROBE_BODY_LIMIT, + state.captureBudget ?? isolateCaptureBudget, + ); + } catch { + return cacheabilityEvaluationFailureResponse(state.route.pattern); + } + if (captured.kind === "fallback") { + return responseWithCachePolicy(response, captured.body, null); + } + return responseWithCachePolicy(response, captured.body, outcome); } if ( diff --git a/packages/vinext/src/shims/cacheability-classification.ts b/packages/vinext/src/shims/cacheability-classification.ts index 01b5cbf781..306f23b4c4 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; + completedResponseBody?: boolean; explicitConfigCachePolicy?: boolean; finalResponseVetoReason?: string; forcedDynamicReason?: string; @@ -117,6 +118,13 @@ export function markRouteCacheabilityExplicitConfigPolicy(): void { state.explicitConfigCachePolicy = true; } +/** Record that the response body reached clean EOF and is now a replay stream. */ +export function markRouteCacheabilityResponseBodyComplete(): void { + const state = readRouteCacheabilityState(); + if (!state) return; + state.completedResponseBody = 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/app-route-handler-execution.test.ts b/tests/app-route-handler-execution.test.ts index 64bf2ffd86..1720ffa0e8 100644 --- a/tests/app-route-handler-execution.test.ts +++ b/tests/app-route-handler-execution.test.ts @@ -595,7 +595,7 @@ describe("app route handler execution helpers", () => { await expect(response.text()).resolves.toBe("tenant-a"); }); - it("completes an otherwise dynamic GET before adapter admission", async () => { + it("records clean completion for an otherwise unconfigured GET during adapter admission", async () => { const request = new Request("https://example.com/api/config-cache", { headers: { "x-tenant": "tenant-a" }, }); @@ -628,14 +628,12 @@ describe("app route handler execution helpers", () => { return null; }, handler: { dynamic: "auto" }, - handlerFn(trackedRequest) { + handlerFn() { return new Response( new ReadableStream( { pull(controller) { - controller.enqueue( - new TextEncoder().encode(trackedRequest.headers.get("x-tenant") ?? "missing"), - ); + controller.enqueue(new TextEncoder().encode("reusable")); controller.close(); }, }, @@ -665,11 +663,9 @@ describe("app route handler execution helpers", () => { }), ); - expect(state.finalResponseVetoReason).toContain( - "did not complete as a reusable static response", - ); - expect(response.headers.get("cache-control")).toContain("no-store"); - await expect(response.text()).resolves.toBe("tenant-a"); + expect(state.completedResponseBody).toBe(true); + expect(state.finalResponseVetoReason).toBeUndefined(); + await expect(response.text()).resolves.toBe("reusable"); }); it("falls back to private streaming and defers cleanup when bounded completion overflows", async () => { diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 54d5a90883..56bf14a51d 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -499,6 +499,17 @@ describe("classifyAppRoute", () => { }); describe("classifyAppRouteHandler", () => { + async function classifySource(code: string) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-route-handler-report-")); + const filePath = path.join(root, "route.ts"); + try { + await fs.writeFile(filePath, code); + return classifyAppRouteHandler(filePath); + } finally { + await fs.rm(root, { force: true, recursive: true }); + } + } + it("matches Next.js static generation eligibility", () => { expect( classifyAppRouteHandler(path.join(FIXTURES_APP, "api", "static-data", "route.ts")), @@ -507,6 +518,37 @@ describe("classifyAppRouteHandler", () => { { hasGet: true, staticGenerationEnabled: false }, ); }); + + it("uses exported aliases for the Route Handler module contract", async () => { + await expect( + classifySource(` + const handler = () => new Response("ok"); + const params = () => []; + export { handler as GET, params as generateStaticParams }; + `), + ).resolves.toEqual({ hasGet: true, staticGenerationEnabled: true }); + + await expect( + classifySource(` + type Handler = () => Response; + export type { Handler as GET }; + export const revalidate = 60; + `), + ).resolves.toEqual({ hasGet: false, staticGenerationEnabled: true }); + }); + + it("rejects GET modules with methods Next.js cannot statically generate", async () => { + // Ported from Next.js: + // packages/next/src/server/route-modules/app-route/module.ts#hasNonStaticMethods + await expect( + classifySource(` + export const revalidate = 60; + export function GET() { return new Response("get"); } + const mutate = () => new Response("post"); + export { mutate as POST }; + `), + ).resolves.toEqual({ hasGet: true, staticGenerationEnabled: false }); + }); }); // ─── buildReportRows ────────────────────────────────────────────────────────── diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index 4ff266a7e2..48d10643bf 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -344,6 +344,36 @@ describe("single-request cacheability admission", () => { await expect(response.text()).resolves.toBe("public"); }); + it("rejects a late-failing Route Handler made public by final config headers", async () => { + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/api/config-public", { headers: { Accept: "*/*" } }), + null, + "build-a", + true, + ); + const state = cacheabilityState(context); + state.route = { kind: "app-route", pattern: "/api/config-public" }; + state.explicitConfigCachePolicy = true; + + const response = await finalizeWorkerCacheabilityResponse( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("partial")); + controller.error(new Error("late failure")); + }, + }), + { headers: { "Cache-Control": "public, s-maxage=60" } }, + ), + context, + ); + + expect(response.status).toBe(500); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + expect(response.headers.get("CDN-Cache-Control")).toBeNull(); + }); + it.each(["*/*", "text/html"])( "admits only an exact manifest-backed Route Handler identity for Accept: %s", async (accept) => { diff --git a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts index 2f69631afe..4475604ad8 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts @@ -109,4 +109,11 @@ test("admits only exact manifest-backed App Page responses after clean EOF", asy await expect(dynamicRouteHandler.json()).resolves.toEqual({ value: "private" }); expect(dynamicRouteHandler.headers()["cache-control"]).toContain("no-store"); expect(dynamicRouteHandler.headers()["cdn-cache-control"]).toBeUndefined(); + + const lateConfigPublicFailure = await request.get( + "/cacheability/route-handler-config-public-late-error", + ); + expect(lateConfigPublicFailure.status()).toBe(500); + expect(lateConfigPublicFailure.headers()["cache-control"]).toContain("no-store"); + expect(lateConfigPublicFailure.headers()["cdn-cache-control"]).toBeUndefined(); }); diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-config-public-late-error/route.ts b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-config-public-late-error/route.ts new file mode 100644 index 0000000000..a9c673b14d --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-config-public-late-error/route.ts @@ -0,0 +1,13 @@ +import { headers } from "next/headers"; + +export async function GET() { + await headers(); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("partial")); + queueMicrotask(() => controller.error(new Error("late route handler failure"))); + }, + }), + ); +} diff --git a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json index 8d4d0d3f93..00e5e88fc1 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 }, + "[\"app-route\",\"/cacheability/route-handler-config-public-late-error\",\"app-route\",\"/cacheability/route-handler-config-public-late-error\"]": { + "kind": "app-route", + "pattern": "/cacheability/route-handler-config-public-late-error", + "representation": "app-route", + "requestKey": "/cacheability/route-handler-config-public-late-error", + "state": "static-candidate", + "status": 200 + }, "[\"app-route\",\"/cacheability/route-handler-static\",\"app-route\",\"/cacheability/route-handler-static\"]": { "kind": "app-route", "pattern": "/cacheability/route-handler-static", diff --git a/tests/fixtures/ppr-impact-demo/next.config.ts b/tests/fixtures/ppr-impact-demo/next.config.ts index a6f14e7ee1..65add0b37e 100644 --- a/tests/fixtures/ppr-impact-demo/next.config.ts +++ b/tests/fixtures/ppr-impact-demo/next.config.ts @@ -26,6 +26,10 @@ export default { source: "/cacheability/config-public-dynamic", headers: [{ key: "Cache-Control", value: "s-maxage=32" }], }, + { + source: "/cacheability/route-handler-config-public-late-error", + headers: [{ key: "Cache-Control", value: "public, s-maxage=60" }], + }, { source: "/cacheability/static", has: [{ type: "query", key: "late-policy", value: "set-cookie" }], From 4ff00588d480488ca11c3f0bce6677a4fd578882 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 02:39:59 +0100 Subject: [PATCH 5/9] fix(cache): admit explicit Route Handler policies safely --- packages/vinext/src/build/report.ts | 11 +++ .../src/server/app-route-handler-execution.ts | 4 + .../vinext/src/server/cacheability-request.ts | 19 ++++- .../src/shims/cacheability-classification.ts | 8 ++ tests/app-route-handler-execution.test.ts | 62 ++++++++++++++++ tests/build-report.test.ts | 18 +++++ tests/cacheability-admission.test.ts | 74 ++++++++++++++++++- .../cacheability-admission.spec.ts | 20 +++++ .../route-handler-mixed-explicit/route.ts | 10 +++ .../route-handler-mixed-revalidate/route.ts | 9 +++ 10 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-mixed-explicit/route.ts create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-mixed-revalidate/route.ts diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index a49fe5db3b..6a849fe7a1 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -190,6 +190,12 @@ function hasRuntimeExportedNameInProgram(program: Program, name: string): boolea return false; } +function hasRuntimeExportAllInProgram(program: Program): boolean { + return program.body.some( + (node) => node.type === "ExportAllDeclaration" && node.exportKind !== "type", + ); +} + function unwrapStaticExpression(expression: Expression): Expression { let current = expression; while ( @@ -800,10 +806,15 @@ export function classifyAppRouteHandler(filePath: string): { const hasNonStaticMethod = ["POST", "PUT", "DELETE", "PATCH", "OPTIONS"].some((method) => hasRuntimeExportedNameInProgram(program, method), ); + // A value-bearing export star can contribute any HTTP method. Resolving it + // would require walking and parsing the module graph, so fail closed instead + // of incorrectly warming a module that may export a non-static method. + const hasUnknownRuntimeExports = hasRuntimeExportAllInProgram(program); return { hasGet, staticGenerationEnabled: !hasNonStaticMethod && + !hasUnknownRuntimeExports && (dynamicValue === "force-static" || dynamicValue === "error" || revalidateValue === Infinity || diff --git a/packages/vinext/src/server/app-route-handler-execution.ts b/packages/vinext/src/server/app-route-handler-execution.ts index a797d891aa..9149fb3fe6 100644 --- a/packages/vinext/src/server/app-route-handler-execution.ts +++ b/packages/vinext/src/server/app-route-handler-execution.ts @@ -46,6 +46,7 @@ import { getRouteCacheabilityDynamicReason, CACHEABILITY_POLICY_HEADERS, isRouteCacheabilityEvaluation, + markRouteCacheabilityExplicitResponsePolicy, markRouteCacheabilityResponseBodyComplete, } from "vinext/shims/cacheability-classification"; import { @@ -325,6 +326,9 @@ export async function executeAppRouteHandler( assertSupportedAppRouteHandlerResponse(response); const handlerSetCacheControl = response.headers.has("cache-control"); const hasExplicitCacheablePolicy = hasExplicitCacheableResponsePolicy(response.headers); + if (hasExplicitCacheablePolicy) { + markRouteCacheabilityExplicitResponsePolicy(); + } const draftModeBeforeCompletion = options.getActiveDraftModeState?.() ?? options.isDraftMode === true; diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index 7b88231b20..c5a6dacf64 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -536,6 +536,9 @@ async function finalizeWorkerCacheabilityAdmission( // Handler representation as canonical fetches. if (state.route?.kind === "app-route") { let manifestRoute: CacheabilityManifestRoute | null = null; + let manifestContainsRoutePattern = false; + const hasExplicitRuntimePolicy = + state.explicitResponseCachePolicy === true || state.explicitConfigCachePolicy === true; if ( !admission || admission.policy === "deny" || @@ -559,12 +562,20 @@ async function finalizeWorkerCacheabilityAdmission( requestKey: admission.requestKey, }, ); + if (!manifestRoute && hasExplicitRuntimePolicy) { + manifestContainsRoutePattern = Object.values(manifest.routes).some( + (route) => route.kind === state.route?.kind && route.pattern === state.route?.pattern, + ); + } } + const isManifestAuthorized = + manifestRoute?.state === "static-candidate" && manifestRoute.status === response.status; + const canUseBoundedRuntimeAdmission = + hasExplicitRuntimePolicy && + (admission.policy === "runtime" || + (admission.policy === "manifest" && !manifestContainsRoutePattern)); if ( - (admission.policy === "manifest" && - (!manifestRoute || - manifestRoute.state !== "static-candidate" || - manifestRoute.status !== response.status)) || + (!isManifestAuthorized && !canUseBoundedRuntimeAdmission) || response.status >= 500 || state.forcedDynamicReason || hasStrictFinalResponseVeto(response, state) || diff --git a/packages/vinext/src/shims/cacheability-classification.ts b/packages/vinext/src/shims/cacheability-classification.ts index 306f23b4c4..c45c67d19c 100644 --- a/packages/vinext/src/shims/cacheability-classification.ts +++ b/packages/vinext/src/shims/cacheability-classification.ts @@ -33,6 +33,7 @@ export type RouteCacheabilityState = { completion?: Promise; completedResponseBody?: boolean; explicitConfigCachePolicy?: boolean; + explicitResponseCachePolicy?: boolean; finalResponseVetoReason?: string; forcedDynamicReason?: string; frameworkResponseCachePolicy?: Partial>; @@ -118,6 +119,13 @@ export function markRouteCacheabilityExplicitConfigPolicy(): void { state.explicitConfigCachePolicy = true; } +/** Record a public cache policy supplied by the Route Handler itself. */ +export function markRouteCacheabilityExplicitResponsePolicy(): void { + const state = readRouteCacheabilityState(); + if (!state || state.mode !== "admit") return; + state.explicitResponseCachePolicy = true; +} + /** Record that the response body reached clean EOF and is now a replay stream. */ export function markRouteCacheabilityResponseBodyComplete(): void { const state = readRouteCacheabilityState(); diff --git a/tests/app-route-handler-execution.test.ts b/tests/app-route-handler-execution.test.ts index 1720ffa0e8..4ed7f61cc3 100644 --- a/tests/app-route-handler-execution.test.ts +++ b/tests/app-route-handler-execution.test.ts @@ -668,6 +668,68 @@ describe("app route handler execution helpers", () => { await expect(response.text()).resolves.toBe("reusable"); }); + it("records handler-owned public policy separately from framework revalidate policy", async () => { + async function executeWithHeaders(headers?: HeadersInit) { + const request = new Request("https://example.com/api/mixed-methods"); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + JSON.stringify({ buildId: "build-a", routes: {}, version: 1 }), + "build-a", + ); + const state = Reflect.get(context, CACHEABILITY_REQUEST_STATE) as RouteCacheabilityState; + const dynamicUsage = createDynamicUsageState(); + + await runWithExecutionContext(context, () => + executeAppRouteHandler({ + buildPageCacheTags() { + return []; + }, + cleanPathname: "/api/mixed-methods", + clearRequestContext() {}, + consumeDynamicUsage: dynamicUsage.consumeDynamicUsage, + executionContext: null, + getAndClearPendingCookies() { + return []; + }, + getCollectedFetchTags() { + return []; + }, + getDraftModeCookieHeader() { + return null; + }, + handler: { dynamic: "auto", revalidate: 60 }, + handlerFn() { + return new Response("reusable", { headers }); + }, + isAutoHead: false, + isProduction: true, + isrRouteKey(pathname) { + return pathname; + }, + async isrSet() {}, + markDynamicUsage: dynamicUsage.markDynamicUsage, + method: "GET", + middlewareContext: { headers: null, status: null }, + params: null, + reportRequestError() {}, + request, + revalidateSeconds: 60, + routePattern: "/api/mixed-methods", + setHeadersAccessPhase() { + return "render"; + }, + }), + ); + return state; + } + + await expect(executeWithHeaders()).resolves.not.toHaveProperty("explicitResponseCachePolicy"); + await expect( + executeWithHeaders({ "Cache-Control": "public, s-maxage=60" }), + ).resolves.toHaveProperty("explicitResponseCachePolicy", true); + }); + it("falls back to private streaming and defers cleanup when bounded completion overflows", async () => { const request = new Request("https://example.com/api/large", { headers: { Accept: "*/*" }, diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 56bf14a51d..79d4135b82 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -549,6 +549,24 @@ describe("classifyAppRouteHandler", () => { `), ).resolves.toEqual({ hasGet: true, staticGenerationEnabled: false }); }); + + it("fails closed for value-bearing export stars without walking the module graph", async () => { + await expect( + classifySource(` + export const revalidate = 60; + export function GET() { return new Response("get"); } + export * from "./handlers"; + `), + ).resolves.toEqual({ hasGet: true, staticGenerationEnabled: false }); + + await expect( + classifySource(` + export const revalidate = 60; + export function GET() { return new Response("get"); } + export type * from "./types"; + `), + ).resolves.toEqual({ hasGet: true, staticGenerationEnabled: true }); + }); }); // ─── buildReportRows ────────────────────────────────────────────────────────── diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index 48d10643bf..22299a7d26 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -333,7 +333,73 @@ describe("single-request cacheability admission", () => { "build-a", true, ); - cacheabilityState(context).route = { kind: "app-route", pattern: "/api/data" }; + const state = cacheabilityState(context); + state.route = { kind: "app-route", pattern: "/api/data" }; + state.explicitResponseCachePolicy = true; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("public", { headers: { "Cache-Control": "public, s-maxage=60" } }), + context, + ); + + expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=60"); + await expect(response.text()).resolves.toBe("public"); + }); + + it("admits an unmanifested Route Handler only with an explicit response policy", async () => { + const raw = JSON.stringify({ buildId: "build-a", routes: {}, version: 1 }); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/api/mixed-methods", { headers: { Accept: "*/*" } }), + raw, + "build-a", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-route", pattern: "/api/mixed-methods" }; + state.explicitResponseCachePolicy = true; + state.completedResponseBody = true; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("public", { headers: { "Cache-Control": "public, s-maxage=60" } }), + context, + ); + + expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=60"); + await expect(response.text()).resolves.toBe("public"); + }); + + it("does not treat framework revalidate policy as an explicit unmanifested opt-in", async () => { + const raw = JSON.stringify({ buildId: "build-a", routes: {}, version: 1 }); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/api/mixed-methods", { headers: { Accept: "*/*" } }), + raw, + "build-a", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-route", pattern: "/api/mixed-methods" }; + state.completedResponseBody = true; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("private", { headers: { "Cache-Control": "s-maxage=60" } }), + context, + ); + + expect(response.headers.get("Cache-Control")).toContain("no-store"); + await expect(response.text()).resolves.toBe("private"); + }); + + it("admits an unmanifested Route Handler with an explicit config policy", async () => { + const raw = JSON.stringify({ buildId: "build-a", routes: {}, version: 1 }); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/api/mixed-methods", { headers: { Accept: "*/*" } }), + raw, + "build-a", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-route", pattern: "/api/mixed-methods" }; + state.explicitConfigCachePolicy = true; const response = await finalizeWorkerCacheabilityResponse( new Response("public", { headers: { "Cache-Control": "public, s-maxage=60" } }), @@ -395,7 +461,7 @@ describe("single-request cacheability admission", () => { }, ); - it("keeps an unlisted Route Handler query identity private", async () => { + it("keeps an unlisted Route Handler query identity private despite explicit policy", async () => { const { raw } = staticAppRouteManifest(); const context = createWorkerCacheabilityAdmissionContext( { waitUntil() {} }, @@ -403,7 +469,9 @@ describe("single-request cacheability admission", () => { raw, "build-a", ); - cacheabilityState(context).route = { kind: "app-route", pattern: "/api/data" }; + const state = cacheabilityState(context); + state.route = { kind: "app-route", pattern: "/api/data" }; + state.explicitResponseCachePolicy = true; const response = await finalizeWorkerCacheabilityResponse( new Response("private", { headers: { "Cache-Control": "public, s-maxage=60" } }), diff --git a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts index 4475604ad8..9882262005 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts @@ -103,6 +103,26 @@ test("admits only exact manifest-backed App Page responses after clean EOF", asy expect(unlistedRouteHandlerQuery.headers()["cache-control"]).toContain("no-store"); expect(unlistedRouteHandlerQuery.headers()["cdn-cache-control"]).toBeUndefined(); + // Next.js does not statically generate a GET+POST Route Handler, so this + // route is intentionally absent from the probe manifest. Its handler-owned + // public policy still opts the completed response into runtime admission. + const explicitMixedRouteHandler = await request.get("/cacheability/route-handler-mixed-explicit"); + await expect(explicitMixedRouteHandler.json()).resolves.toEqual({ + kind: "explicit-mixed-route-handler", + }); + expect(explicitMixedRouteHandler.headers()["cache-control"]).toBe("public, s-maxage=60"); + + // `revalidate` alone is framework policy, not an explicit response-level + // opt-in, and must not bypass the route's manifest absence. + const frameworkPolicyMixedRouteHandler = await request.get( + "/cacheability/route-handler-mixed-revalidate", + ); + await expect(frameworkPolicyMixedRouteHandler.json()).resolves.toEqual({ + kind: "framework-policy-mixed-route-handler", + }); + expect(frameworkPolicyMixedRouteHandler.headers()["cache-control"]).toContain("no-store"); + expect(frameworkPolicyMixedRouteHandler.headers()["cdn-cache-control"]).toBeUndefined(); + const dynamicRouteHandler = await request.get("/cacheability/route-handler-dynamic", { headers: { "X-Probe-Value": "private" }, }); diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-mixed-explicit/route.ts b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-mixed-explicit/route.ts new file mode 100644 index 0000000000..68eaf7d827 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-mixed-explicit/route.ts @@ -0,0 +1,10 @@ +export function GET() { + return Response.json( + { kind: "explicit-mixed-route-handler" }, + { headers: { "Cache-Control": "public, s-maxage=60" } }, + ); +} + +export function POST() { + return new Response(null, { status: 204 }); +} diff --git a/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-mixed-revalidate/route.ts b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-mixed-revalidate/route.ts new file mode 100644 index 0000000000..d93562471a --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-mixed-revalidate/route.ts @@ -0,0 +1,9 @@ +export const revalidate = 60; + +export function GET() { + return Response.json({ kind: "framework-policy-mixed-route-handler" }); +} + +export function POST() { + return new Response(null, { status: 204 }); +} From 1246114ebd75b0c72cb9ab5a377f86e461d4a2be Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 02:47:31 +0100 Subject: [PATCH 6/9] fix(cache): close Route Handler admission fallbacks --- .../src/server/app-route-handler-dispatch.ts | 11 ++++- .../src/server/app-route-handler-policy.ts | 5 +++ .../src/server/cacheability-manifest.ts | 29 ++++++++++++- .../vinext/src/server/cacheability-request.ts | 9 ++-- tests/app-route-handler-dispatch.test.ts | 43 +++++++++++++++++++ tests/app-route-handler-policy.test.ts | 8 ++++ tests/cacheability-admission.test.ts | 25 +++++++++++ 7 files changed, 124 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/server/app-route-handler-dispatch.ts b/packages/vinext/src/server/app-route-handler-dispatch.ts index 850155c508..6591624599 100644 --- a/packages/vinext/src/server/app-route-handler-dispatch.ts +++ b/packages/vinext/src/server/app-route-handler-dispatch.ts @@ -29,6 +29,7 @@ import type { ISRCacheEntry } from "./isr-cache.js"; import { getAppRouteHandlerRevalidateSeconds, hasAppRouteHandlerDefaultExport, + hasNonStaticAppRouteHandlerMethods, resolveAppRouteHandlerMethod, shouldReadAppRouteHandlerCache, type AppRouteHandlerModule, @@ -177,7 +178,13 @@ export async function dispatchAppRouteHandler( if (method === "GET" || method === "HEAD") { beginRouteCacheability("app-route", route.pattern); } - const revalidateSeconds = getAppRouteHandlerRevalidateSeconds(handler); + const configuredRevalidateSeconds = getAppRouteHandlerRevalidateSeconds(handler); + // Next.js bails out of static generation when any mutating method is + // exported. Keep segment revalidation available to inner fetches, but do + // not read/write ISR or generate a public route-response policy. + const revalidateSeconds = hasNonStaticAppRouteHandlerMethods(handler) + ? null + : configuredRevalidateSeconds; const isDevelopment = options.isDevelopment ?? process.env.NODE_ENV === "development"; const isProduction = options.isProduction ?? process.env.NODE_ENV === "production"; const appendResponseLink = handler.runtime === "edge" || handler.runtime === "experimental-edge"; @@ -245,7 +252,7 @@ export async function dispatchAppRouteHandler( // where handlers ignored their `fetchCache`/`force-dynamic` segment config. const fetchCacheMode = resolveAppRouteHandlerFetchCacheMode(handler); setCurrentFetchCacheMode(fetchCacheMode); - setCurrentFetchRevalidate(revalidateSeconds); + setCurrentFetchRevalidate(configuredRevalidateSeconds); setCurrentForceDynamicFetchDefault(handler.dynamic === "force-dynamic"); if ( diff --git a/packages/vinext/src/server/app-route-handler-policy.ts b/packages/vinext/src/server/app-route-handler-policy.ts index 1a9ef7ebba..649919b9ff 100644 --- a/packages/vinext/src/server/app-route-handler-policy.ts +++ b/packages/vinext/src/server/app-route-handler-policy.ts @@ -97,6 +97,11 @@ export function hasAppRouteHandlerDefaultExport(handler: RouteHandlerModule): bo return typeof handler.default === "function"; } +/** Match Next.js methods that force an App Route out of static generation. */ +export function hasNonStaticAppRouteHandlerMethods(handler: RouteHandlerModule): boolean { + return Boolean(handler.POST || handler.PUT || handler.DELETE || handler.PATCH || handler.OPTIONS); +} + export function resolveAppRouteHandlerMethod( handler: AppRouteHandlerModule, method: string, diff --git a/packages/vinext/src/server/cacheability-manifest.ts b/packages/vinext/src/server/cacheability-manifest.ts index 452cd1a92a..db04553f1f 100644 --- a/packages/vinext/src/server/cacheability-manifest.ts +++ b/packages/vinext/src/server/cacheability-manifest.ts @@ -44,6 +44,12 @@ export type CacheabilityManifest = { version: 1; }; +const manifestRoutePatterns = new WeakMap>(); + +function cacheabilityManifestRoutePatternKey(kind: CacheabilityRouteKind, pattern: string): string { + return `${kind}\0${pattern}`; +} + export function cacheabilityManifestRouteKey( kind: CacheabilityManifestRoute["kind"], pattern: string, @@ -128,17 +134,38 @@ export function parseCacheabilityManifest( } const routes: Record = {}; + const routePatterns = new Set(); for (const [key, routeValue] of Object.entries(record.routes)) { const route = parseRoute(key, routeValue); if (!route) return null; routes[key] = route; + routePatterns.add(cacheabilityManifestRoutePatternKey(route.kind, route.pattern)); } - return { buildId: expectedBuildId, routes, version: 1 }; + const manifest: CacheabilityManifest = { buildId: expectedBuildId, routes, version: 1 }; + manifestRoutePatterns.set(manifest, routePatterns); + return manifest; } catch { return null; } } +export function cacheabilityManifestHasRoutePattern( + manifest: CacheabilityManifest, + kind: CacheabilityRouteKind, + pattern: string, +): boolean { + let routePatterns = manifestRoutePatterns.get(manifest); + if (!routePatterns) { + routePatterns = new Set( + Object.values(manifest.routes).map((route) => + cacheabilityManifestRoutePatternKey(route.kind, route.pattern), + ), + ); + manifestRoutePatterns.set(manifest, routePatterns); + } + return routePatterns.has(cacheabilityManifestRoutePatternKey(kind, pattern)); +} + const CONTEXTUAL_RSC_HEADERS = [ NEXT_ROUTER_STATE_TREE_HEADER, NEXT_URL_HEADER, diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index c5a6dacf64..a5290785a5 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -25,6 +25,7 @@ import { } from "./cacheability-limits.js"; import { cacheabilityRequestIdentity, + cacheabilityManifestHasRoutePattern, findCacheabilityManifestRoute, parseCacheabilityManifest, type CacheabilityManifest, @@ -563,8 +564,10 @@ async function finalizeWorkerCacheabilityAdmission( }, ); if (!manifestRoute && hasExplicitRuntimePolicy) { - manifestContainsRoutePattern = Object.values(manifest.routes).some( - (route) => route.kind === state.route?.kind && route.pattern === state.route?.pattern, + manifestContainsRoutePattern = cacheabilityManifestHasRoutePattern( + manifest, + state.route.kind, + state.route.pattern, ); } } @@ -573,7 +576,7 @@ async function finalizeWorkerCacheabilityAdmission( const canUseBoundedRuntimeAdmission = hasExplicitRuntimePolicy && (admission.policy === "runtime" || - (admission.policy === "manifest" && !manifestContainsRoutePattern)); + (admission.policy === "manifest" && !manifestRoute && !manifestContainsRoutePattern)); if ( (!isManifestAuthorized && !canUseBoundedRuntimeAdmission) || response.status >= 500 || diff --git a/tests/app-route-handler-dispatch.test.ts b/tests/app-route-handler-dispatch.test.ts index 1ae86f47a1..6d1bf14db0 100644 --- a/tests/app-route-handler-dispatch.test.ts +++ b/tests/app-route-handler-dispatch.test.ts @@ -296,6 +296,49 @@ describe("app route handler dispatch", () => { expect(didClearRequestContext).toBe(true); }); + it("keeps mixed-method handlers out of the normal ISR cache path", async () => { + const isrGet = vi.fn(async () => buildISRCacheEntry(buildCachedRouteValue("unsafe-hit"))); + const isrSet = vi.fn(); + const response = await dispatchAppRouteHandler({ + cleanPathname: "/api/mixed", + clearRequestContext() {}, + draftModeSecret: "test-draft-secret", + i18n: null, + isDevelopment: false, + isProduction: true, + isrGet, + isrRouteKey(pathname) { + return "route:" + pathname; + }, + isrSet, + middlewareContext: { headers: null, status: null }, + middlewareRequestHeaders: null, + params: null, + request: new Request("https://example.com/api/mixed"), + route: { + pattern: "/api/mixed", + routeHandler: { + GET() { + return new Response("fresh"); + }, + POST() { + return new Response(null, { status: 204 }); + }, + revalidate: 60, + }, + routeSegments: ["api", "mixed"], + }, + scheduleBackgroundRegeneration() {}, + searchParams: new URLSearchParams(), + }); + + expect(isrGet).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + expect(response.headers.get("cache-control")).toBeNull(); + expect(response.headers.get("x-vinext-cache")).toBeNull(); + await expect(response.text()).resolves.toBe("fresh"); + }); + // Matches Next.js behavior: route handlers on non-dynamic routes receive // `context.params` as null (not `{}`). User code typically does // `const resolved = params ? await params : null`, and the resolved value diff --git a/tests/app-route-handler-policy.test.ts b/tests/app-route-handler-policy.test.ts index 7659bea863..ae851e10ef 100644 --- a/tests/app-route-handler-policy.test.ts +++ b/tests/app-route-handler-policy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { getAppRouteHandlerRevalidateSeconds, hasAppRouteHandlerDefaultExport, + hasNonStaticAppRouteHandlerMethods, isPossibleAppRouteActionRequest, resolveAppRouteHandlerMethod, resolveAppRouteHandlerSpecialError, @@ -96,6 +97,13 @@ describe("app route handler policy helpers", () => { expect(hasAppRouteHandlerDefaultExport({ GET() {} })).toBe(false); }); + it("matches Next.js non-static Route Handler method detection", () => { + expect(hasNonStaticAppRouteHandlerMethods({ GET() {} })).toBe(false); + for (const method of ["POST", "PUT", "DELETE", "PATCH", "OPTIONS"] as const) { + expect(hasNonStaticAppRouteHandlerMethods({ GET() {}, [method]() {} })).toBe(true); + } + }); + it("resolves auto-options and auto-head route handler behavior", () => { const resolvedOptions = resolveAppRouteHandlerMethod( { diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index 22299a7d26..b6ac4faf05 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -461,6 +461,31 @@ describe("single-request cacheability admission", () => { }, ); + it("does not let explicit policy bypass an exact manifest status mismatch", async () => { + const { raw } = staticAppRouteManifest(); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + new Request("https://example.com/api/data", { headers: { Accept: "*/*" } }), + raw, + "build-a", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-route", pattern: "/api/data" }; + state.explicitResponseCachePolicy = true; + state.completedResponseBody = true; + + const response = await finalizeWorkerCacheabilityResponse( + new Response("redirected", { + headers: { "Cache-Control": "public, s-maxage=60", Location: "/elsewhere" }, + status: 302, + }), + context, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + }); + it("keeps an unlisted Route Handler query identity private despite explicit policy", async () => { const { raw } = staticAppRouteManifest(); const context = createWorkerCacheabilityAdmissionContext( From b3e88c899df79531c94b8bb739b32c57da0896ce Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 02:54:40 +0100 Subject: [PATCH 7/9] fix(cache): honor explicit dynamic handler policies --- .../src/server/app-route-handler-execution.ts | 4 +- tests/app-route-handler-execution.test.ts | 74 ++++++++++++++++++- .../cacheability-admission.spec.ts | 10 +++ .../cacheability-probe.spec.ts | 15 ++++ .../route-handler-explicit-dynamic/route.ts | 8 ++ .../cacheability-manifest.json | 8 ++ 6 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-explicit-dynamic/route.ts diff --git a/packages/vinext/src/server/app-route-handler-execution.ts b/packages/vinext/src/server/app-route-handler-execution.ts index 9149fb3fe6..b497c0c4fd 100644 --- a/packages/vinext/src/server/app-route-handler-execution.ts +++ b/packages/vinext/src/server/app-route-handler-execution.ts @@ -464,7 +464,9 @@ export async function executeAppRouteHandler( // Next.js preserves a Route Handler's explicit Cache-Control even when the // handler used request data. During CDN probe/admission the adapter still // owns fail-closed policy until the completed response is authorized. - const preserveHandlerPolicy = !isRouteCacheabilityEvaluation() && handlerSetCacheControl; + const preserveHandlerPolicy = isRouteCacheabilityEvaluation() + ? hasExplicitCacheablePolicy + : handlerSetCacheControl; if (responseMustStayPrivate && !preserveHandlerPolicy) { const headers = new Headers(finalized.headers); applyCdnResponseHeaders(headers, { cacheControl: NEVER_CACHE_CONTROL }); diff --git a/tests/app-route-handler-execution.test.ts b/tests/app-route-handler-execution.test.ts index 4ed7f61cc3..711f2e1a40 100644 --- a/tests/app-route-handler-execution.test.ts +++ b/tests/app-route-handler-execution.test.ts @@ -26,7 +26,10 @@ import { runWithRequestContext, } from "../packages/vinext/src/shims/unified-request-context.js"; import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js"; -import { createWorkerCacheabilityAdmissionContext } from "../packages/vinext/src/server/cacheability-request.js"; +import { + createWorkerCacheabilityAdmissionContext, + finalizeWorkerCacheabilityResponse, +} from "../packages/vinext/src/server/cacheability-request.js"; import { CACHEABILITY_REQUEST_STATE, type RouteCacheabilityState, @@ -668,6 +671,75 @@ describe("app route handler execution helpers", () => { await expect(response.text()).resolves.toBe("reusable"); }); + it("preserves an explicit public policy after dynamic reads complete during admission", async () => { + const request = new Request("https://example.com/api/explicit-dynamic", { + headers: { "x-tenant": "tenant-a" }, + }); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + null, + "build-a", + true, + ); + const state = Reflect.get(context, CACHEABILITY_REQUEST_STATE) as RouteCacheabilityState; + state.route = { kind: "app-route", pattern: "/api/explicit-dynamic" }; + const dynamicUsage = createDynamicUsageState(); + + const executed = await runWithExecutionContext(context, () => + executeAppRouteHandler({ + buildPageCacheTags() { + return []; + }, + cleanPathname: "/api/explicit-dynamic", + clearRequestContext() {}, + consumeDynamicUsage: dynamicUsage.consumeDynamicUsage, + executionContext: null, + getAndClearPendingCookies() { + return []; + }, + getCollectedFetchTags() { + return []; + }, + getDraftModeCookieHeader() { + return null; + }, + handler: { dynamic: "auto", revalidate: 60 }, + handlerFn(trackedRequest) { + return Response.json( + { tenant: trackedRequest.headers.get("x-tenant") }, + { headers: { "Cache-Control": "public, s-maxage=60" } }, + ); + }, + isAutoHead: false, + isProduction: true, + isrRouteKey(pathname) { + return pathname; + }, + async isrSet() { + throw new Error("dynamic response must not enter origin ISR"); + }, + markDynamicUsage: dynamicUsage.markDynamicUsage, + method: "GET", + middlewareContext: { headers: null, status: null }, + params: null, + reportRequestError() {}, + request, + revalidateSeconds: 60, + routePattern: "/api/explicit-dynamic", + setHeadersAccessPhase() { + return "render"; + }, + }), + ); + + expect(state.explicitResponseCachePolicy).toBe(true); + expect(state.completedResponseBody).toBeUndefined(); + const response = await finalizeWorkerCacheabilityResponse(executed, context); + expect(response.headers.get("cache-control")).toBe("public, s-maxage=60"); + await expect(response.json()).resolves.toEqual({ tenant: "tenant-a" }); + }); + it("records handler-owned public policy separately from framework revalidate policy", async () => { async function executeWithHeaders(headers?: HeadersInit) { const request = new Request("https://example.com/api/mixed-methods"); diff --git a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts index 9882262005..e45c927a3a 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-admission.spec.ts @@ -130,6 +130,16 @@ test("admits only exact manifest-backed App Page responses after clean EOF", asy expect(dynamicRouteHandler.headers()["cache-control"]).toContain("no-store"); expect(dynamicRouteHandler.headers()["cdn-cache-control"]).toBeUndefined(); + const explicitDynamicRouteHandler = await request.get( + "/cacheability/route-handler-explicit-dynamic", + { headers: { "X-Probe-Value": "explicitly-public" } }, + ); + await expect(explicitDynamicRouteHandler.json()).resolves.toEqual({ + value: "explicitly-public", + }); + expect(explicitDynamicRouteHandler.headers()["cdn-cache-control"]).toBe("public, max-age=60"); + expect(explicitDynamicRouteHandler.headers()["cache-control"]).toContain("must-revalidate"); + const lateConfigPublicFailure = await request.get( "/cacheability/route-handler-config-public-late-error", ); diff --git a/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts index f83b75ef6a..928ea28a95 100644 --- a/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts +++ b/tests/e2e/ppr-impact-demo/cacheability-probe.spec.ts @@ -115,6 +115,21 @@ test("classifies completed App Page renders inside workerd", async ({ request }) version: 1, }); + // A handler-owned public policy is an explicit cache opt-in even when the + // handler reads request data. Next.js preserves that policy rather than + // replacing it with the framework's dynamic default. + const explicitDynamicRouteHandlerProbe = await request.get( + "/cacheability/route-handler-explicit-dynamic", + { headers: { ...headers, Accept: "*/*" } }, + ); + await expect(explicitDynamicRouteHandlerProbe.json()).resolves.toMatchObject({ + kind: "app-route", + pattern: "/cacheability/route-handler-explicit-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/fixtures/ppr-impact-demo/app/cacheability/route-handler-explicit-dynamic/route.ts b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-explicit-dynamic/route.ts new file mode 100644 index 0000000000..e5515a7228 --- /dev/null +++ b/tests/fixtures/ppr-impact-demo/app/cacheability/route-handler-explicit-dynamic/route.ts @@ -0,0 +1,8 @@ +export const revalidate = 60; + +export function GET(request: Request) { + return Response.json( + { value: request.headers.get("X-Probe-Value") ?? "none" }, + { headers: { "Cache-Control": "public, s-maxage=60" } }, + ); +} diff --git a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json index 00e5e88fc1..b9fa7b7f27 100644 --- a/tests/fixtures/ppr-impact-demo/cacheability-manifest.json +++ b/tests/fixtures/ppr-impact-demo/cacheability-manifest.json @@ -97,6 +97,14 @@ "state": "static-candidate", "status": 200 }, + "[\"app-route\",\"/cacheability/route-handler-explicit-dynamic\",\"app-route\",\"/cacheability/route-handler-explicit-dynamic\"]": { + "kind": "app-route", + "pattern": "/cacheability/route-handler-explicit-dynamic", + "representation": "app-route", + "requestKey": "/cacheability/route-handler-explicit-dynamic", + "state": "static-candidate", + "status": 200 + }, "[\"app-route\",\"/cacheability/route-handler-static\",\"app-route\",\"/cacheability/route-handler-static\"]": { "kind": "app-route", "pattern": "/cacheability/route-handler-static", From df3d10b6a19c1765de57cc29664f3abc080cefb2 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 02:57:27 +0100 Subject: [PATCH 8/9] fix(cache): reject unsafe Route Handler probe responses --- .../vinext/src/server/cacheability-request.ts | 10 +++++++- tests/cacheability-admission.test.ts | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index a5290785a5..ba6c992352 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -461,7 +461,15 @@ function completedRouteOutcome( if (state.forcedDynamicReason) { return { cacheable: false, reason: state.forcedDynamicReason }; } - if (state.route?.kind === "app-route") return inferPagesPageCacheability(response); + if (state.route?.kind === "app-route") { + if (response.headers.has("set-cookie")) { + return { cacheable: false, reason: "response sets a cookie" }; + } + if (hasUnsupportedCacheabilityVary(response.headers)) { + return { cacheable: false, reason: "response has unsupported Vary fields" }; + } + return inferPagesPageCacheability(response); + } if (state.route?.kind === "app-page") { return inferFinalAppPageCacheability(response, state) ?? rendererOutcome; } diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index b6ac4faf05..e3f4386d1b 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -820,4 +820,27 @@ describe("cacheability probe finalization", () => { else process.env.__VINEXT_BUILD_ID = previousBuildId; } }); + + it.each([ + ["Set-Cookie", "session=private; Path=/", "response sets a cookie"], + ["Vary", "User-Agent", "response has unsupported Vary fields"], + ])("keeps Route Handler probes with unsafe %s private", async (name, value, reason) => { + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 1_000, + mode: "probe", + route: { kind: "app-route", pattern: "/api/data" }, + }; + const response = await finalizeWorkerCacheabilityResponse( + new Response("unsafe", { + headers: { "Cache-Control": "public, s-maxage=60", [name]: value }, + }), + contextWith(state), + ); + + await expect(response.json()).resolves.toMatchObject({ + kind: "app-route", + reason, + state: "dynamic", + }); + }); }); From 85ab9571f5ac14604bb5d4d4f67b14983a1fba8f Mon Sep 17 00:00:00 2001 From: James Date: Thu, 27 Aug 2026 03:04:51 +0100 Subject: [PATCH 9/9] fix(cache): preserve Route Handler CDN policy ownership --- .../src/server/app-route-handler-execution.ts | 12 +++-- .../src/server/app-route-handler-policy.ts | 4 +- tests/app-route-handler-execution.test.ts | 54 +++++++++++++++++++ tests/app-route-handler-policy.test.ts | 10 ++-- 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/server/app-route-handler-execution.ts b/packages/vinext/src/server/app-route-handler-execution.ts index b497c0c4fd..0028f829c1 100644 --- a/packages/vinext/src/server/app-route-handler-execution.ts +++ b/packages/vinext/src/server/app-route-handler-execution.ts @@ -324,7 +324,9 @@ export async function executeAppRouteHandler( } let { dynamicUsedInHandler, response } = handlerResult; assertSupportedAppRouteHandlerResponse(response); - const handlerSetCacheControl = response.headers.has("cache-control"); + const handlerSetCachePolicy = CACHEABILITY_POLICY_HEADERS.some((name) => + response.headers.has(name), + ); const hasExplicitCacheablePolicy = hasExplicitCacheableResponsePolicy(response.headers); if (hasExplicitCacheablePolicy) { markRouteCacheabilityExplicitResponsePolicy(); @@ -339,7 +341,7 @@ export async function executeAppRouteHandler( dynamicConfig: options.handler.dynamic, dynamicUsedInHandler, hasExplicitCacheablePolicy, - handlerSetCacheControl, + handlerSetCachePolicy, isAutoHead: options.isAutoHead, isDraftMode: draftModeBeforeCompletion || handlerDraftCookieBeforeCompletion != null, isProduction: options.isProduction, @@ -394,7 +396,7 @@ export async function executeAppRouteHandler( if ( shouldApplyAppRouteHandlerRevalidateHeader({ dynamicUsedInHandler: responseMustStayPrivate, - handlerSetCacheControl, + handlerSetCachePolicy, isAutoHead: options.isAutoHead, isDraftMode: shouldApplyDraftPolicy, method: options.method, @@ -417,7 +419,7 @@ export async function executeAppRouteHandler( shouldWriteAppRouteHandlerCache({ dynamicConfig: options.handler.dynamic, dynamicUsedInHandler: responseMustStayPrivate, - handlerSetCacheControl, + handlerSetCachePolicy, isAutoHead: options.isAutoHead, isDraftMode: shouldApplyDraftPolicy, isProduction: options.isProduction, @@ -466,7 +468,7 @@ export async function executeAppRouteHandler( // owns fail-closed policy until the completed response is authorized. const preserveHandlerPolicy = isRouteCacheabilityEvaluation() ? hasExplicitCacheablePolicy - : handlerSetCacheControl; + : handlerSetCachePolicy; if (responseMustStayPrivate && !preserveHandlerPolicy) { const headers = new Headers(finalized.headers); applyCdnResponseHeaders(headers, { cacheControl: NEVER_CACHE_CONTROL }); diff --git a/packages/vinext/src/server/app-route-handler-policy.ts b/packages/vinext/src/server/app-route-handler-policy.ts index 649919b9ff..5fe7e24d86 100644 --- a/packages/vinext/src/server/app-route-handler-policy.ts +++ b/packages/vinext/src/server/app-route-handler-policy.ts @@ -40,7 +40,7 @@ type AppRouteHandlerResponseCacheOptions = { dynamicConfig?: string; dynamicUsedInHandler: boolean; hasExplicitCacheablePolicy?: boolean; - handlerSetCacheControl: boolean; + handlerSetCachePolicy: boolean; isAutoHead: boolean; isDraftMode?: boolean; isProduction: boolean; @@ -162,7 +162,7 @@ export function shouldApplyAppRouteHandlerRevalidateHeader( !options.isDraftMode && !options.dynamicUsedInHandler && (options.method === "GET" || options.isAutoHead) && - !options.handlerSetCacheControl + !options.handlerSetCachePolicy ); } diff --git a/tests/app-route-handler-execution.test.ts b/tests/app-route-handler-execution.test.ts index 711f2e1a40..668837c9fe 100644 --- a/tests/app-route-handler-execution.test.ts +++ b/tests/app-route-handler-execution.test.ts @@ -303,6 +303,60 @@ describe("app route handler execution helpers", () => { expect(reportCalls).toEqual([]); }); + it.each(["CDN-Cache-Control", "Cloudflare-CDN-Cache-Control"])( + "preserves handler-owned %s instead of applying framework revalidation", + async (policyHeader) => { + const dynamicUsage = createDynamicUsageState(); + const isrSet = vi.fn(); + const response = await executeAppRouteHandler({ + buildPageCacheTags() { + return []; + }, + cleanPathname: "/api/provider-private", + clearRequestContext() {}, + consumeDynamicUsage: dynamicUsage.consumeDynamicUsage, + executionContext: null, + getAndClearPendingCookies() { + return []; + }, + getCollectedFetchTags() { + return []; + }, + getDraftModeCookieHeader() { + return null; + }, + handler: { dynamic: "auto", revalidate: 60 }, + handlerFn() { + return new Response("private", { + headers: { [policyHeader]: "private, no-store" }, + }); + }, + isAutoHead: false, + isProduction: true, + isrRouteKey(pathname) { + return pathname; + }, + isrSet, + markDynamicUsage: dynamicUsage.markDynamicUsage, + method: "GET", + middlewareContext: { headers: null, status: null }, + params: null, + reportRequestError() {}, + request: new Request("https://example.com/api/provider-private"), + revalidateSeconds: 60, + routePattern: "/api/provider-private", + setHeadersAccessPhase() { + return "render"; + }, + }); + + expect(response.headers.get(policyHeader)).toBe("private, no-store"); + expect(response.headers.get("cache-control")).toBeNull(); + expect(isrSet).not.toHaveBeenCalled(); + await expect(response.text()).resolves.toBe("private"); + }, + ); + it.each([ { enabled: true, initialDraftMode: false, expectedCookie: "__prerender_bypass=draft-secret" }, { enabled: false, initialDraftMode: true, expectedCookie: "__prerender_bypass=;" }, diff --git a/tests/app-route-handler-policy.test.ts b/tests/app-route-handler-policy.test.ts index ae851e10ef..29ca6470e6 100644 --- a/tests/app-route-handler-policy.test.ts +++ b/tests/app-route-handler-policy.test.ts @@ -60,7 +60,7 @@ describe("app route handler policy helpers", () => { const writeBase = { dynamicConfig: "auto", dynamicUsedInHandler: false, - handlerSetCacheControl: false, + handlerSetCachePolicy: false, isAutoHead: false, isProduction: true, method: "GET", @@ -81,7 +81,7 @@ describe("app route handler policy helpers", () => { shouldCompleteAppRouteHandlerResponse({ dynamicConfig: "auto", dynamicUsedInHandler: false, - handlerSetCacheControl: false, + handlerSetCachePolicy: false, isAutoHead: false, isProduction: true, method: "GET", @@ -161,7 +161,7 @@ describe("app route handler policy helpers", () => { const base = { dynamicConfig: "auto", dynamicUsedInHandler: false, - handlerSetCacheControl: false, + handlerSetCachePolicy: false, isAutoHead: false, isProduction: true, method: "GET", @@ -173,7 +173,7 @@ describe("app route handler policy helpers", () => { shouldApplyAppRouteHandlerRevalidateHeader({ ...base, dynamicUsedInHandler: true }), ).toBe(false); expect( - shouldApplyAppRouteHandlerRevalidateHeader({ ...base, handlerSetCacheControl: true }), + shouldApplyAppRouteHandlerRevalidateHeader({ ...base, handlerSetCachePolicy: true }), ).toBe(false); expect(shouldWriteAppRouteHandlerCache(base)).toBe(true); expect(shouldWriteAppRouteHandlerCache({ ...base, isProduction: false })).toBe(false); @@ -195,7 +195,7 @@ describe("app route handler policy helpers", () => { expect( shouldCompleteAppRouteHandlerResponse({ dynamicUsedInHandler: false, - handlerSetCacheControl: true, + handlerSetCachePolicy: true, hasExplicitCacheablePolicy: true, isAutoHead: false, isProduction: true,