diff --git a/packages/cloudflare/src/cache/cdn-adapter-config.ts b/packages/cloudflare/src/cache/cdn-adapter-config.ts index c0a1f2de0..2a48e966e 100644 --- a/packages/cloudflare/src/cache/cdn-adapter-config.ts +++ b/packages/cloudflare/src/cache/cdn-adapter-config.ts @@ -1,7 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; -const RESPONSE_STAGE_EXPORT = "VinextCachedResponse"; +const CACHED_RESPONSE_STAGE_EXPORT = "VinextCachedResponse"; +const UNCACHED_RESPONSE_STAGE_EXPORT = "VinextUncachedResponse"; const CTX_EXPORTS_DEFAULT_DATE = "2025-11-17"; type WranglerWorkerExport = { @@ -78,7 +79,7 @@ export function configureWorkersCacheEntrypoints( : [...compatibilityFlags, "enable_ctx_exports"] : compatibilityFlags.filter((flag) => flag !== "enable_ctx_exports"); - for (const name of ["default", RESPONSE_STAGE_EXPORT]) { + for (const name of ["default", CACHED_RESPONSE_STAGE_EXPORT, UNCACHED_RESPONSE_STAGE_EXPORT]) { const existing = configuredExports[name]; if (existing?.type !== undefined && existing.type !== "worker") { throw new Error( @@ -97,11 +98,16 @@ export function configureWorkersCacheEntrypoints( type: "worker", cache: { enabled: false }, }, - [RESPONSE_STAGE_EXPORT]: { - ...configuredExports[RESPONSE_STAGE_EXPORT], + [CACHED_RESPONSE_STAGE_EXPORT]: { + ...configuredExports[CACHED_RESPONSE_STAGE_EXPORT], type: "worker", cache: { enabled: true }, }, + [UNCACHED_RESPONSE_STAGE_EXPORT]: { + ...configuredExports[UNCACHED_RESPONSE_STAGE_EXPORT], + type: "worker", + cache: { enabled: false }, + }, }, }; } diff --git a/packages/cloudflare/src/cache/cdn-adapter.ts b/packages/cloudflare/src/cache/cdn-adapter.ts index 8a38e9f8a..dea6c6ee1 100644 --- a/packages/cloudflare/src/cache/cdn-adapter.ts +++ b/packages/cloudflare/src/cache/cdn-adapter.ts @@ -18,12 +18,15 @@ export type CdnAdapterOptions = { * Unlike the data adapter (which stores cache entries in a durable store and * serves HIT/STALE itself), this adapter delegates serving to Workers Cache on * a named Worker entrypoint. The default entrypoint always runs middleware and - * request-time routing before dispatching the cacheable response stage. + * request-time routing before dispatching to cached or uncached response-stage + * entrypoints. * * The emitted Wrangler configuration enables Workers Cache only for that * response-stage export, so cache hits do not start the application stage. * The generated deployment config automatically enables Workers Cache for the - * response entrypoint and adds the version metadata binding used by warmup. + * cached response entrypoint and adds the version metadata binding used by + * warmup. The uncached response entrypoint keeps bypass and probe renders out + * of the gateway without enabling Workers Cache for them. * * The adapter adds a transport-only URL digest so distinct response-stage * identities cannot collide. Workers Cache owns this key independently of @@ -57,7 +60,7 @@ export function cdnAdapter(options?: CdnAdapterOptions) { transformHostEntry({ code, id }: { code: string; id: string }) { const cleanId = id.charCodeAt(0) === 0 ? id.slice(1) : id; if (cleanId !== CLOUDFLARE_WORKER_ENTRY_ID) return null; - return `${code}\nexport { VinextCachedResponse } from ${JSON.stringify(workerEntry)};\n`; + return `${code}\nexport { VinextCachedResponse, VinextUncachedResponse } from ${JSON.stringify(workerEntry)};\n`; }, finalizeBuildOutput({ outDir, diff --git a/packages/cloudflare/src/cache/cdn-adapter.worker.ts b/packages/cloudflare/src/cache/cdn-adapter.worker.ts index 225329b23..cbfa2945a 100644 --- a/packages/cloudflare/src/cache/cdn-adapter.worker.ts +++ b/packages/cloudflare/src/cache/cdn-adapter.worker.ts @@ -48,9 +48,20 @@ type RestoredResponseStageRequest = { request: Request; }; -const RESPONSE_STAGE_EXPORT = "VinextCachedResponse"; +type CloudflareResponse = Response & { + readonly webSocket?: WebSocket | null; +}; + +type CloudflareResponseInit = ResponseInit & { + webSocket?: WebSocket | null; +}; + +const CACHED_RESPONSE_STAGE_EXPORT = "VinextCachedResponse"; +const UNCACHED_RESPONSE_STAGE_EXPORT = "VinextUncachedResponse"; const AUTHORIZATION_TRANSPORT_HEADER = "x-vinext-internal-authorization"; +const REQUEST_CACHE_CONTROL_TRANSPORT_HEADER = "x-vinext-internal-request-cache-control"; const REQUEST_CF_TRANSPORT_HEADER = "x-vinext-internal-request-cf"; +const REQUEST_PRAGMA_TRANSPORT_HEADER = "x-vinext-internal-request-pragma"; const CLOUDFLARE_EDGE_POLICY_HEADER = "Cloudflare-CDN-Cache-Control"; const SHARED_RESPONSE_STAGE_HEADER = "x-vinext-cloudflare-shared-response-stage"; const RESPONSE_STAGE_WIRE_CACHE = { @@ -90,11 +101,22 @@ function stampResponseStageBuildIdentity(response: Response): Response { } catch { const headers = new Headers(response.headers); headers.set(VINEXT_CDN_BUILD_ID_HEADER, buildIdentity); - return new Response(response.body, { + const webSocket = (response as CloudflareResponse).webSocket; + // A Workers WebSocket upgrade is the one non-standard status that can be + // reconstructed. Convert other non-HTTP responses before they cross the + // entrypoint boundary, where a network-error response would reject fetch. + if (!webSocket && (response.status < 200 || response.status > 599)) { + const unavailable = responseStageUnavailable(); + unavailable.headers.set(VINEXT_CDN_BUILD_ID_HEADER, buildIdentity); + return unavailable; + } + const init: CloudflareResponseInit = { headers, status: response.status, statusText: response.statusText, - }); + }; + if (webSocket) init.webSocket = webSocket; + return new Response(response.body, init); } } @@ -116,13 +138,17 @@ function validateResponseStageBuildIdentity(response: Response): Response { function stripUntrustedTransportHeaders(request: Request): Request { if ( !request.headers.has(AUTHORIZATION_TRANSPORT_HEADER) && - !request.headers.has(REQUEST_CF_TRANSPORT_HEADER) + !request.headers.has(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER) && + !request.headers.has(REQUEST_CF_TRANSPORT_HEADER) && + !request.headers.has(REQUEST_PRAGMA_TRANSPORT_HEADER) ) { return request; } const headers = new Headers(request.headers); headers.delete(AUTHORIZATION_TRANSPORT_HEADER); + headers.delete(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER); headers.delete(REQUEST_CF_TRANSPORT_HEADER); + headers.delete(REQUEST_PRAGMA_TRANSPORT_HEADER); const sanitized = new Request(request, { headers }); const requestCf = Reflect.get(request, "cf"); if (requestCf !== undefined) { @@ -186,9 +212,10 @@ function hasPurge(value: unknown): value is Required function getResponseStageBinding( context: CloudflareStageContext, + exportName: typeof CACHED_RESPONSE_STAGE_EXPORT | typeof UNCACHED_RESPONSE_STAGE_EXPORT, serializedInvocation: string, ): StageBinding | null { - const binding = context.exports?.[RESPONSE_STAGE_EXPORT]; + const binding = context.exports?.[exportName]; if (typeof binding !== "function") return null; // Configurable-entrypoint props cross a Workers RPC boundary. Some vinext @@ -240,15 +267,27 @@ async function createCacheFacingRequest( const url = new URL(request.url); url.searchParams.set("__vinext_cache_key", key); const headers = new Headers(request.headers); + const requestCacheControl = headers.get("Cache-Control"); + const requestPragma = headers.get("Pragma"); + headers.delete("Cache-Control"); + headers.delete("Pragma"); headers.delete("Authorization"); headers.delete(AUTHORIZATION_TRANSPORT_HEADER); + headers.delete(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER); headers.delete(REQUEST_CF_TRANSPORT_HEADER); + headers.delete(REQUEST_PRAGMA_TRANSPORT_HEADER); if (authorization !== null) { headers.set(AUTHORIZATION_TRANSPORT_HEADER, encodeURIComponent(authorization)); } if (serializedRequestCf !== null) { headers.set(REQUEST_CF_TRANSPORT_HEADER, serializedRequestCf); } + if (requestCacheControl !== null) { + headers.set(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER, encodeURIComponent(requestCacheControl)); + } + if (requestPragma !== null) { + headers.set(REQUEST_PRAGMA_TRANSPORT_HEADER, encodeURIComponent(requestPragma)); + } const init = { // Explicitly replace inherited inbound `cf` metadata. In workerd, // Request-from-Request construction otherwise preserves values such as a @@ -259,7 +298,14 @@ async function createCacheFacingRequest( } satisfies RequestInit & { cf: { vary: { default: { action: "passthrough" } } }; }; - return new Request(new Request(url, request), init); + // Construct from the URL rather than cloning the inbound request. Workers + // carries cache-bypass state from browser reloads outside the visible header + // map, and cloning would leak that state into the cache-enabled entrypoint + // even after the directives above were transported privately. + return new Request(url, { + ...init, + method: request.method, + }); } function restoreResponseStageRequest( @@ -269,10 +315,16 @@ function restoreResponseStageRequest( ): RestoredResponseStageRequest { const headers = new Headers(request.headers); const serializedAuthorization = headers.get(AUTHORIZATION_TRANSPORT_HEADER); + const serializedRequestCacheControl = headers.get(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER); const serializedRequestCf = headers.get(REQUEST_CF_TRANSPORT_HEADER); + const serializedRequestPragma = headers.get(REQUEST_PRAGMA_TRANSPORT_HEADER); + headers.delete("Cache-Control"); + headers.delete("Pragma"); headers.delete("Authorization"); headers.delete(AUTHORIZATION_TRANSPORT_HEADER); + headers.delete(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER); headers.delete(REQUEST_CF_TRANSPORT_HEADER); + headers.delete(REQUEST_PRAGMA_TRANSPORT_HEADER); if (serializedAuthorization !== null) { try { headers.set("Authorization", decodeURIComponent(serializedAuthorization)); @@ -288,6 +340,20 @@ function restoreResponseStageRequest( // Malformed internal metadata is stripped rather than exposed to userland. } } + if (serializedRequestCacheControl !== null) { + try { + headers.set("Cache-Control", decodeURIComponent(serializedRequestCacheControl)); + } catch { + // Malformed internal metadata is stripped rather than exposed to userland. + } + } + if (serializedRequestPragma !== null) { + try { + headers.set("Pragma", decodeURIComponent(serializedRequestPragma)); + } catch { + // Malformed internal metadata is stripped rather than exposed to userland. + } + } const restored = new Request(new Request(requestUrl, request), { headers, method: requestMethod, @@ -399,7 +465,7 @@ function hasTaggedCustomVary(response: Response): boolean { } function withResponseStagePurge(context: CloudflareStageContext): CloudflareStageContext { - const factory = context.exports?.[RESPONSE_STAGE_EXPORT]; + const factory = context.exports?.[CACHED_RESPONSE_STAGE_EXPORT]; if (typeof factory !== "function") return context; const fallback = context.cache; return { @@ -414,7 +480,10 @@ function withResponseStagePurge(context: CloudflareStageContext): CloudflareStag }; } -function getResponseStageInvocation(value: unknown): CloudflareResponseStageInvocation | null { +function getResponseStageInvocation( + value: unknown, + expectedCache?: VinextResponseStageDispatchOptions["cache"], +): CloudflareResponseStageInvocation | null { if (!value || typeof value !== "object") return null; const expectedResponseStageBuildIdentity = Reflect.get( value, @@ -452,6 +521,7 @@ function getResponseStageInvocation(value: unknown): CloudflareResponseStageInvo } else { return null; } + if (expectedCache !== undefined && cache !== expectedCache) return null; const requestUrl = Reflect.get(value, "requestUrl"); if (typeof requestUrl !== "string") return null; const requestMethod = Reflect.get(value, "requestMethod"); @@ -504,7 +574,7 @@ async function invokeResponseStage( export class VinextCachedResponse extends WorkerEntrypoint { async fetch(request: Request): Promise { const context = withWorkerHostRuntime(this.ctx, this.env); - const invocation = getResponseStageInvocation(context.props); + const invocation = getResponseStageInvocation(context.props, "shared"); if (!invocation) { return stampResponseStageBuildIdentity( new Response("Invalid vinext response-stage invocation", { @@ -545,6 +615,31 @@ export class VinextCachedResponse extends WorkerEntrypoint { } } +/** Uncached response entrypoint. Bypass and probe renders execute only here. */ +export class VinextUncachedResponse extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const context = withResponseStagePurge(withWorkerHostRuntime(this.ctx, this.env)); + const invocation = getResponseStageInvocation(context.props, "bypass"); + if (!invocation) { + return stampResponseStageBuildIdentity( + new Response("Invalid vinext response-stage invocation", { + status: 400, + headers: { "Cache-Control": "no-store" }, + }), + ); + } + if ( + invocation.expectedResponseStageBuildIdentity !== undefined && + invocation.expectedResponseStageBuildIdentity !== getVinextCdnBuildIdentity() + ) { + return stampResponseStageBuildIdentity(responseStageUnavailable()); + } + return stampResponseStageBuildIdentity( + await invokeResponseStage(request, this.env, context, invocation), + ); + } +} + /** Uncached gateway: request routing and middleware always execute here. */ export default { async fetch( @@ -570,10 +665,7 @@ export default { requestMethod: stageRequest.method, requestUrl: stageRequest.url, }; - const requiresEntrypoint = isResponseStageReadinessRequest(stageRequest); - if (options.cache === "bypass" && !requiresEntrypoint) { - return invokeResponseStage(stageRequest, env, stageContext, invocation); - } + const usesSharedCache = options.cache === "shared"; try { const serializedInvocation = JSON.stringify({ ...invocation, @@ -585,24 +677,23 @@ export default { cache: RESPONSE_STAGE_WIRE_CACHE[options.cache] satisfies ResponseStageWireCache, }, }); - const binding = getResponseStageBinding(stageContext, serializedInvocation); + const binding = getResponseStageBinding( + stageContext, + usesSharedCache ? CACHED_RESPONSE_STAGE_EXPORT : UNCACHED_RESPONSE_STAGE_EXPORT, + serializedInvocation, + ); if (!binding) { - return requiresEntrypoint - ? responseStageUnavailable() - : markSharedResponseStage( - await invokeResponseStage(stageRequest, env, stageContext, invocation), - sharedResponseStageProvenance, - ); + return responseStageUnavailable(); } - const entrypointRequest = requiresEntrypoint - ? stageRequest - : await createCacheFacingRequest(stageRequest, serializedInvocation); + const entrypointRequest = usesSharedCache + ? await createCacheFacingRequest(stageRequest, serializedInvocation) + : stageRequest; const response = validateResponseStageBuildIdentity(await binding.fetch(entrypointRequest)); - return requiresEntrypoint - ? response - : markSharedResponseStage(response, sharedResponseStageProvenance, true); + return usesSharedCache + ? markSharedResponseStage(response, sharedResponseStageProvenance, true) + : response; } catch (error) { - if (requiresEntrypoint) return responseStageUnavailable(); + if (isResponseStageReadinessRequest(stageRequest)) return responseStageUnavailable(); throw error; } }; diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index e814a79d5..89a7cc399 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -382,9 +382,10 @@ export async function probeStagedWorkerCacheability(options: { pruned: boolean; results: Map; route: NonNullable; - splitRepresentations: boolean; + requestStageMayTerminate: boolean; }; type ConcretePathGroup = { + deferred: boolean; pattern: PatternClassification; primary: CdnWarmTarget; result?: ConcretePathResult; @@ -413,9 +414,9 @@ export async function probeStagedWorkerCacheability(options: { pruned: false, results: new Map(), route: target.route, - splitRepresentations: false, + requestStageMayTerminate: false, }; - pattern.splitRepresentations ||= + pattern.requestStageMayTerminate ||= target.route.cacheabilityProbe?.requestStageMayTerminate === true; pattern.canPrune &&= target.route.cacheabilityProbe?.canPrunePattern === true && @@ -445,9 +446,7 @@ export async function probeStagedWorkerCacheability(options: { const routePathname = route.cacheabilityProbe?.concretePathname ?? cacheabilityRoutePathname(target.pathname, target.kind); - const resultKey = pattern.splitRepresentations - ? `${target.kind}\0${routePathname}` - : routePathname; + const resultKey = routePathname; pattern.resultKeys.add(resultKey); const concreteKey = `${key}\0${resultKey}`; const group = targetGroups.get(concreteKey) ?? { @@ -464,7 +463,7 @@ export async function probeStagedWorkerCacheability(options: { const preference = targetPreference(first) - targetPreference(second); return preference || first.sourcePathname.localeCompare(second.sourcePathname); }); - const group = { ...targetGroup, primary: targetGroup.targets[0] }; + const group = { ...targetGroup, deferred: false, primary: targetGroup.targets[0] }; targetGroup.pattern.groups.push(group); return group; }); @@ -519,12 +518,12 @@ export async function probeStagedWorkerCacheability(options: { pruned: false, results: new Map(), route, - splitRepresentations: previousPattern.splitRepresentations, + requestStageMayTerminate: previousPattern.requestStageMayTerminate, }; patterns.set(key, pattern); } pattern.canPrune = false; - pattern.splitRepresentations ||= previousPattern.splitRepresentations; + pattern.requestStageMayTerminate ||= previousPattern.requestStageMayTerminate; // A direct destination probe may have provisionally pruned this pattern // before the routed source completed. Its retained concrete observation is // authoritative once another public path joins the resolved route. @@ -548,15 +547,34 @@ export async function probeStagedWorkerCacheability(options: { const pattern = group.pattern; const previousResultKey = group.resultKey; group.routePathname = normalized; - group.resultKey = pattern.splitRepresentations - ? `${group.primary.kind}\0${normalized}` - : normalized; + group.resultKey = normalized; if (!pattern.groups.some((candidate) => candidate.resultKey === previousResultKey)) { pattern.resultKeys.delete(previousResultKey); } pattern.resultKeys.add(group.resultKey); }; + const deferPairedRepresentationsAtOriginalRoute = (group: ConcretePathGroup): void => { + const pairedTargets = group.targets.filter((target) => target !== group.primary); + if (pairedTargets.length === 0) return; + group.targets = [group.primary]; + for (const target of pairedTargets) { + const routePathname = + target.route?.cacheabilityProbe?.concretePathname ?? + cacheabilityRoutePathname(target.pathname, target.kind); + const deferredGroup: ConcretePathGroup = { + deferred: true, + pattern: group.pattern, + primary: target, + resultKey: routePathname, + routePathname, + targets: [target], + }; + group.pattern.groups.push(deferredGroup); + group.pattern.resultKeys.add(routePathname); + } + }; + const classifyConcretePath = async (group: ConcretePathGroup): Promise => { if (group.pattern.pruned) { skippedPathCount += 1; @@ -613,7 +631,7 @@ export async function probeStagedWorkerCacheability(options: { (result.terminal === true && (result.state !== "dynamic" || result.scope !== "identity" || - !group.pattern.splitRepresentations)) || + !group.pattern.requestStageMayTerminate)) || (result.rendererStatic !== undefined && typeof result.rendererStatic !== "boolean") || !Number.isInteger(result.status) || result.status! < 100 || @@ -636,7 +654,12 @@ export async function probeStagedWorkerCacheability(options: { reportProgress(); return; } - if (result.kind !== target.route.kind || result.pattern !== target.route.pattern) { + const resolvedRouteChanged = + result.kind !== target.route.kind || result.pattern !== target.route.pattern; + const resolvedPathnameChanged = + result.routePathname !== undefined && + normalizeCacheabilityRoutePathname(result.routePathname) !== group.routePathname; + if (resolvedRouteChanged) { if (target.route.cacheabilityProbe?.routeMayResolve !== true) { failures.push( `${target.label}: probe resolved to unexpected route ${result.pattern ?? ""}`, @@ -651,6 +674,17 @@ export async function probeStagedWorkerCacheability(options: { reportProgress(); return; } + } + if ( + target.route.cacheabilityProbe?.routeMayResolve === true && + (resolvedRouteChanged || resolvedPathnameChanged) + ) { + // The representative request proves only its own routed destination. + // Keep alternate representations attached to the original route so + // their final completed renders can still pass manifest admission. + deferPairedRepresentationsAtOriginalRoute(group); + } + if (resolvedRouteChanged) { moveGroupToResolvedRoute(group, { kind: result.kind, pattern: result.pattern }); } if (result.routePathname !== undefined) { @@ -692,31 +726,58 @@ export async function probeStagedWorkerCacheability(options: { reportProgress(); }; - const runGroups = async (scheduledGroups: ConcretePathGroup[]): Promise => { - let nextIndex = 0; - const worker = async (): Promise => { - while (!limitFailure && !phaseTimedOut && nextIndex < scheduledGroups.length) { - await classifyConcretePath(scheduledGroups[nextIndex++]); - } - }; - await Promise.all( - Array.from({ length: Math.min(concurrency, scheduledGroups.length) }, () => worker()), - ); - }; - reportProgress(); - const representativeGroups: ConcretePathGroup[] = []; - const siblingGroups: ConcretePathGroup[] = []; - const scheduledPatterns = new Set(); - for (const group of groups) { - if (scheduledPatterns.has(group.pattern.key)) siblingGroups.push(group); - else { - scheduledPatterns.add(group.pattern.key); - representativeGroups.push(group); + let activeProbes = 0; + const slotWaiters: Array<() => void> = []; + const acquireProbeSlot = async (): Promise => { + if (activeProbes < concurrency) { + activeProbes++; + return; } - } - await runGroups(representativeGroups); - if (!limitFailure && !phaseTimedOut) await runGroups(siblingGroups); + await new Promise((resolve) => slotWaiters.push(resolve)); + }; + const releaseProbeSlot = (): void => { + const next = slotWaiters.shift(); + if (next) next(); + else activeProbes--; + }; + let pendingRouteMovers = groups.filter( + (group) => group.primary.route?.cacheabilityProbe?.routeMayResolve === true, + ).length; + let settleRouteMovers: (() => void) | undefined; + const routeMoversSettled = + pendingRouteMovers === 0 + ? Promise.resolve() + : new Promise((resolve) => { + settleRouteMovers = resolve; + }); + const runGroup = async (group: ConcretePathGroup): Promise => { + const mayResolveRoute = group.primary.route?.cacheabilityProbe?.routeMayResolve === true; + while (true) { + if (!mayResolveRoute && group.pattern.pruned && pendingRouteMovers > 0) { + await routeMoversSettled; + } + await acquireProbeSlot(); + if (!mayResolveRoute && group.pattern.pruned && pendingRouteMovers > 0) { + releaseProbeSlot(); + continue; + } + break; + } + try { + if (!limitFailure && !phaseTimedOut) await classifyConcretePath(group); + } finally { + releaseProbeSlot(); + if (mayResolveRoute && --pendingRouteMovers === 0) settleRouteMovers?.(); + } + }; + const initialPatternGroups = Array.from(patterns.values(), (pattern) => [...pattern.groups]); + await Promise.all( + initialPatternGroups.map(async ([representative, ...siblings]) => { + await runGroup(representative); + await Promise.all(siblings.map(runGroup)); + }), + ); if (limitFailure) throw limitFailure; if (phaseTimedOut || Date.now() >= getDeadlineAt()) { throw new Error(`cacheability probing made no progress for ${phaseTimeoutMs}ms`); @@ -771,7 +832,7 @@ export async function probeStagedWorkerCacheability(options: { } continue; } - if (pattern.results.size === 0) continue; + if (pattern.results.size === 0 && !pattern.groups.some((group) => group.deferred)) continue; classified += 1; if (Array.from(pattern.results.values()).some((result) => result.state === "dynamic")) { dynamic += 1; @@ -780,6 +841,12 @@ export async function probeStagedWorkerCacheability(options: { const rendererStaticTargets = new Map(); const runtimePathSet = new Set(); for (const group of pattern.groups) { + if (group.deferred) { + runtimePathSet.add(group.routePathname); + cacheableTargets.push(...group.targets); + speculativeTargets.push(...group.targets); + continue; + } const result = group.result; if (result?.state === "static-candidate") { if (result.rendererStatic) { @@ -795,12 +862,14 @@ export async function probeStagedWorkerCacheability(options: { continue; } - if (result?.terminal !== true) runtimePathSet.add(group.routePathname); + const pairedTargets = group.targets.filter((target) => target !== group.primary); + if (result?.terminal !== true || pairedTargets.length > 0) { + runtimePathSet.add(group.routePathname); + } // A representation-specific response policy can make an RSC/data // sibling reusable even when the representative HTML render is private. // The final completed render decides admission without another probe. if (!pattern.pruned) { - const pairedTargets = group.targets.filter((target) => target !== group.primary); cacheableTargets.push(...pairedTargets); speculativeTargets.push(...pairedTargets); } diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 2ea6777e2..02b2815c3 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -24,6 +24,7 @@ import { classifyAppRouteHandler, classifyPagesRoute, extractExportConstString, + extractMiddlewareMatcherConfig, } from "./report.js"; import { buildUrlFromParams, resolveParentParams, type StaticParamsMap } from "./prerender.js"; import { readPrerenderSecret } from "./server-manifest.js"; @@ -42,6 +43,11 @@ import { normalizePathTrailingSlash } from "vinext/shims/url-utils"; import { buildPagesDataHref } from "vinext/shims/internal/pages-data-url"; import { CACHEABILITY_POLICY_HEADERS } from "vinext/shims/cacheability-classification"; import { resolveBuiltRscEntryPath } from "./server-entry.js"; +import { + matchesMiddlewarePathname, + type MatcherConfig, + type MiddlewareLocaleMatchContext, +} from "../server/middleware-matcher.js"; export type PrerenderRoutePattern = { kind: "app-page" | "app-route" | "pages-page"; @@ -1203,18 +1209,53 @@ function configuredRewritesCanReplaceWarmPath( return configuredRulesAffectWarmPath(pathname, applicableRewrites, config); } -function hasMiddlewareConventionFile( +function findMiddlewareConventionFile( root: string, appDir: string | null, pagesDir: string | null, pageExtensions: readonly string[], -): boolean { +): string | null { const routeDir = appDir ?? pagesDir; const routeRoot = routeDir ? path.dirname(routeDir) : root; const conventionDir = routeRoot === path.join(root, "src") ? routeRoot : root; - return ["proxy", "middleware"].some((name) => - pageExtensions.some((extension) => - fs.existsSync(path.join(conventionDir, `${name}.${extension}`)), + for (const name of ["proxy", "middleware"]) { + for (const extension of pageExtensions) { + const filePath = path.join(conventionDir, `${name}.${extension}`); + if (fs.existsSync(filePath)) return filePath; + } + } + return null; +} + +/** @internal Match the pathname forms and locale provenance used by middleware runtime. */ +export function matchesMiddlewareWarmPath( + pathname: string, + matcher: MatcherConfig | undefined, + i18n: ResolvedNextConfig["i18n"], +): boolean { + const encodedPathname = new URL(pathname, "https://vinext.invalid").pathname; + const firstSegment = encodedPathname.split("/", 3)[1]?.toLowerCase(); + const localeContexts: Array = i18n + ? i18n.locales.some((locale) => locale.toLowerCase() === firstSegment) + ? [{ kind: "literal" }] + : Array.from( + new Set([ + i18n.defaultLocale, + ...(i18n.domains?.map((domain) => domain.defaultLocale) ?? []), + ]), + (defaultLocale) => ({ defaultLocale, kind: "defaulted" as const }), + ) + : [undefined]; + const matchPathnames = [encodedPathname]; + try { + const decodedPathname = decodeURIComponent(encodedPathname); + if (decodedPathname !== encodedPathname) matchPathnames.push(decodedPathname); + } catch { + // Match runtime middleware behavior: malformed encoding is non-fatal. + } + return matchPathnames.some((matchPathname) => + localeContexts.some((localeContext) => + matchesMiddlewarePathname(matchPathname, matcher, i18n, localeContext), ), ); } @@ -1396,15 +1437,21 @@ export async function emitPrerenderPathManifest( }); const hasStagedRequestRouting = options.requestRouting === "uncached-stage"; - const middlewareMayRouteWarmPaths = - hasStagedRequestRouting && - hasMiddlewareConventionFile(root, appDir, pagesDir, config.pageExtensions); + const middlewarePath = hasStagedRequestRouting + ? findMiddlewareConventionFile(root, appDir, pagesDir, config.pageExtensions) + : null; + const middlewareMatcher = middlewarePath + ? extractMiddlewareMatcherConfig(middlewarePath) + : undefined; + const configuredMatcher = middlewareMatcher as MatcherConfig | undefined; + const middlewareMayRouteWarmPath = (pathname: string): boolean => + middlewarePath !== null && matchesMiddlewareWarmPath(pathname, configuredMatcher, config.i18n); const routedWarmPaths = [...paths, ...discoveredRouteHandlerPaths]; const routeMayResolveWarmPathSet = new Set( hasStagedRequestRouting ? routedWarmPaths.filter( (pathname) => - middlewareMayRouteWarmPaths || + middlewareMayRouteWarmPath(pathname) || configuredRewritesCanReplaceWarmPath( pathname, config.rewrites, @@ -1419,7 +1466,7 @@ export async function emitPrerenderPathManifest( hasStagedRequestRouting ? routedWarmPaths.filter( (pathname) => - middlewareMayRouteWarmPaths || + middlewareMayRouteWarmPath(pathname) || configuredRulesAffectWarmPath(pathname, config.redirects, config) || configuredRewritesCanReplaceWarmPath( pathname, diff --git a/packages/vinext/src/server/app-request-stage-dispatch.ts b/packages/vinext/src/server/app-request-stage-dispatch.ts index c54c6c8d1..4fa8a6c0a 100644 --- a/packages/vinext/src/server/app-request-stage-dispatch.ts +++ b/packages/vinext/src/server/app-request-stage-dispatch.ts @@ -46,13 +46,6 @@ export function appRequestUsesFullResponseGraph( if (isDraftModeRequest(request, options.draftModeSecret)) return true; if (request.headers.has(VINEXT_PRERENDER_ROUTE_PARAMS_HEADER)) return true; - const cacheControl = request.headers.get("cache-control")?.toLowerCase() ?? ""; - if ( - !options.probeMode && - /(?:^|,)\s*(?:no-cache|no-store)(?:\s*(?:,|$)|\s*=)/.test(cacheControl) - ) { - return true; - } if ( getScriptNonceFromHeaderSources(request.headers) !== undefined || isRouteTreePrefetchRequest(request) diff --git a/packages/vinext/src/server/pages-response-stage.ts b/packages/vinext/src/server/pages-response-stage.ts index dd505e9cc..92d1edb41 100644 --- a/packages/vinext/src/server/pages-response-stage.ts +++ b/packages/vinext/src/server/pages-response-stage.ts @@ -5,7 +5,6 @@ import { MIDDLEWARE_SET_COOKIE_HEADER } from "./headers.js"; import type { PagesRouteDataKind } from "./pages-route-data-kind.js"; const PREVIEW_COOKIE_NAMES = new Set(["__prerender_bypass", "__next_preview_data"]); -const BYPASS_CACHE_CONTROL_DIRECTIVES = new Set(["no-cache", "no-store"]); export function hasPagesPreviewCookie(cookieHeader: string | null): boolean { if (!cookieHeader) return false; @@ -17,20 +16,6 @@ export function hasPagesPreviewCookie(cookieHeader: string | null): boolean { return false; } -function hasCacheBypassDirective( - cacheControl: string | null, - directives = BYPASS_CACHE_CONTROL_DIRECTIVES, -): boolean { - if (!cacheControl) return false; - return cacheControl.split(",").some((directive) => { - const separator = directive.indexOf("="); - const name = (separator === -1 ? directive : directive.slice(0, separator)) - .trim() - .toLowerCase(); - return directives.has(name); - }); -} - function hasStagedCacheBypass(stagedHeaders: Headers | undefined): boolean { // Middleware cookie overlays affect the same-request render and are not part // of the shared artifact identity. Pure response headers (Cache-Control, @@ -57,8 +42,9 @@ export type PagesResponseStageCacheDisposition = VinextResponseStageDispatchOpti * * Next.js does not construct a static response-cache key in draft mode and * handles authenticated on-demand revalidation outside ordinary cache reuse. - * Request cache bypass directives and non-idempotent methods likewise need to - * reach the renderer, while CSP nonces are embedded in the rendered body. + * Non-idempotent methods need to reach the renderer, while CSP nonces are + * embedded in the rendered body. Request Cache-Control remains visible on a + * cache miss, but does not bypass an existing host-cache entry in production. * * @see https://github.com/vercel/next.js/blob/canary/packages/next/src/server/route-modules/pages/pages-handler.ts * @see https://github.com/vercel/next.js/blob/canary/packages/next/src/server/base-server.ts @@ -89,7 +75,6 @@ export function shouldDispatchPagesResponseStage({ if (hasRequestAwareDocument && routeDataKind === "static") return false; if (hasPagesPreviewCookie(request.headers.get("cookie"))) return false; - if (hasCacheBypassDirective(request.headers.get("cache-control"))) return false; if (requestHeadersChanged) return false; if (hasStagedCacheBypass(stagedHeaders)) return false; diff --git a/tests/app-request-stage-dispatch.test.ts b/tests/app-request-stage-dispatch.test.ts index 1227612db..c8946c019 100644 --- a/tests/app-request-stage-dispatch.test.ts +++ b/tests/app-request-stage-dispatch.test.ts @@ -55,12 +55,6 @@ describe("App request-stage dispatch", () => { headers: { "x-vinext-prerender-route-params": "payload" }, }), }, - { - name: "request cache bypass", - request: new Request("https://example.test/docs/page", { - headers: { "Cache-Control": "max-age=0, NO-STORE" }, - }), - }, { name: "CSP nonce", request: new Request("https://example.test/docs/page", { @@ -122,6 +116,20 @@ describe("App request-stage dispatch", () => { ).toBe(false); }); + it.each(["no-cache", "NO-STORE", "max-age=0, no-cache"])( + "keeps production request Cache-Control %s in the request-only graph", + (cacheControl) => { + expect( + appRequestUsesFullResponseGraph( + new Request("https://example.test/docs/page", { + headers: { "Cache-Control": cacheControl }, + }), + createOptions(), + ), + ).toBe(false); + }, + ); + it("allows an authenticated cacheability probe to classify a no-cache request", () => { const request = new Request("https://example.test/docs/page", { headers: { "Cache-Control": "no-cache" }, diff --git a/tests/cache-adapters-build.test.ts b/tests/cache-adapters-build.test.ts index f447d5887..c552c3a96 100644 --- a/tests/cache-adapters-build.test.ts +++ b/tests/cache-adapters-build.test.ts @@ -313,6 +313,9 @@ export default createAdapter; const workerPath = path.join(root, "dist/server/index.js"); const worker = fs.readFileSync(workerPath, "utf8"); expect(worker).toMatch(/export\s*\{[^}]*\b(?:[A-Za-z_$][\w$]*\s+as\s+)?VinextCachedResponse\b/); + expect(worker).toMatch( + /export\s*\{[^}]*\b(?:[A-Za-z_$][\w$]*\s+as\s+)?VinextUncachedResponse\b/, + ); expect(worker).not.toMatch(/import\s*["']\.\/__vinext_cacheability_manifest\.js["']/); expect(readTextFilesRecursive(path.join(root, "dist/server"))).toContain(RESPONSE_STAGE_MARKER); expect(readStaticJavaScriptClosure(workerPath)).not.toContain(RESPONSE_STAGE_MARKER); @@ -338,6 +341,7 @@ export default createAdapter; expect(wrangler.exports).toMatchObject({ default: { type: "worker", cache: { enabled: false } }, VinextCachedResponse: { type: "worker", cache: { enabled: true } }, + VinextUncachedResponse: { type: "worker", cache: { enabled: false } }, }); expect(wrangler.version_metadata).toEqual({ binding: "CF_VERSION_METADATA" }); expect(wrangler.cache).toBeUndefined(); @@ -389,11 +393,15 @@ export default createAdapter; expect(fs.readFileSync(workerPath, "utf8")).toMatch( /export\s*\{[^}]*\b(?:[A-Za-z_$][\w$]*\s+as\s+)?VinextCachedResponse\b/, ); + expect(fs.readFileSync(workerPath, "utf8")).toMatch( + /export\s*\{[^}]*\b(?:[A-Za-z_$][\w$]*\s+as\s+)?VinextUncachedResponse\b/, + ); expect(readTextFilesRecursive(serverDir)).toContain(RESPONSE_STAGE_MARKER); expect(readStaticJavaScriptClosure(workerPath)).not.toContain(RESPONSE_STAGE_MARKER); expect(wrangler.exports).toMatchObject({ default: { type: "worker", cache: { enabled: false } }, VinextCachedResponse: { type: "worker", cache: { enabled: true } }, + VinextUncachedResponse: { type: "worker", cache: { enabled: false } }, }); expect(wrangler.version_metadata).toEqual({ binding: "CF_VERSION_METADATA" }); expect(wrangler.cache).toBeUndefined(); @@ -415,6 +423,7 @@ export default createAdapter; [ 'import handler from "vinext/server/fetch-handler";', 'export class VinextCachedResponse { marker = "CUSTOM_RESERVED_EXPORT_MARKER"; }', + 'export class VinextUncachedResponse { marker = "CUSTOM_UNCACHED_EXPORT_MARKER"; }', "export default handler;", "", ].join("\n"), @@ -438,6 +447,7 @@ export default createAdapter; const buildOutput = readTextFilesRecursive(path.join(root, "dist/server")); expect(buildOutput).toContain("Invalid vinext response-stage invocation"); expect(buildOutput).not.toContain("CUSTOM_RESERVED_EXPORT_MARKER"); + expect(buildOutput).not.toContain("CUSTOM_UNCACHED_EXPORT_MARKER"); }, 60_000); it("keeps the data adapter out of the emitted request-stage graph", async () => { diff --git a/tests/cache-adapters-config.test.ts b/tests/cache-adapters-config.test.ts index e72e94469..6fe24ea68 100644 --- a/tests/cache-adapters-config.test.ts +++ b/tests/cache-adapters-config.test.ts @@ -359,7 +359,9 @@ describe("cdnAdapter builder + factory", () => { code: 'import handler from "vinext/server/fetch-handler";\nexport default handler;', id: "\0virtual:cloudflare/worker-entry", }), - ).toContain(`export { VinextCachedResponse } from ${JSON.stringify(descriptor.output.entry)};`); + ).toContain( + `export { VinextCachedResponse, VinextUncachedResponse } from ${JSON.stringify(descriptor.output.entry)};`, + ); expect( descriptor.output.transformHostEntry({ code: "export default { fetch() {} };", diff --git a/tests/cdn-adapter-config.test.ts b/tests/cdn-adapter-config.test.ts index d7951a4a7..7f88362d4 100644 --- a/tests/cdn-adapter-config.test.ts +++ b/tests/cdn-adapter-config.test.ts @@ -70,6 +70,7 @@ describe("Cloudflare CDN adapter generated config", () => { expect(generatedConfig.exports).toMatchObject({ default: { type: "worker", cache: { enabled: false } }, VinextCachedResponse: { type: "worker", cache: { enabled: true } }, + VinextUncachedResponse: { type: "worker", cache: { enabled: false } }, }); const auxiliaryConfig = JSON.parse(fs.readFileSync(auxiliaryPath, "utf8")); expect(auxiliaryConfig.version_metadata).toBeUndefined(); diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index 4f4afb22b..883aa8504 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -46,6 +46,26 @@ describe("staged Worker cacheability probes", () => { pattern, }); + const pairedRouteTargets = () => { + const route = { + cacheabilityProbe: { canPrunePattern: true, routeMayResolve: true }, + kind: "app-page" as const, + pattern: "/source", + }; + return { + html: { ...target("/source"), route }, + route, + rsc: { + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-full" as const, + label: "/source (RSC full)", + pathname: "/source?_rsc", + route, + sourcePathname: "/source", + }, + }; + }; + const createStaticProbeFetch = () => vi.fn(async (input) => { const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; @@ -313,7 +333,7 @@ describe("staged Worker cacheability probes", () => { ]); }); - it("probes App representations independently when the request stage may terminate", async () => { + it("defers alternate App representations to final admission when routing may terminate", async () => { const root = createProbeRoot(); const route = { cacheabilityProbe: { canPrunePattern: true, requestStageMayTerminate: true }, @@ -351,18 +371,18 @@ describe("staged Worker cacheability probes", () => { targets: [rsc, html], }); - expect(fetchImpl).toHaveBeenCalledTimes(2); - expect(result).toMatchObject({ classified: 1, dynamic: 1, probed: 2, skipped: 0 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ classified: 1, dynamic: 1, probed: 1, skipped: 0 }); expect(result.failures).toEqual([]); expect(result.cacheableTargets).toEqual([rsc]); - expect(result.speculativeTargets).toEqual([]); + expect(result.speculativeTargets).toEqual([rsc]); const manifestRoute = Object.values(result.manifest.routes)[0]; expect(cacheabilityManifestRouteState(manifestRoute, "/conditional", "rsc-full")).toBe( - "static-candidate", + "runtime-check", ); }); - it("probes Pages HTML and data independently when the request stage may terminate", async () => { + it("defers Pages data to final admission when routing may terminate", async () => { const root = createProbeRoot(); const route = { cacheabilityProbe: { canPrunePattern: true, requestStageMayTerminate: true }, @@ -410,9 +430,141 @@ describe("staged Worker cacheability probes", () => { targets: [data, html], }); - expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl).toHaveBeenCalledTimes(1); expect(result.cacheableTargets).toEqual([data]); - expect(result.speculativeTargets).toEqual([]); + expect(result.speculativeTargets).toEqual([data]); + }); + + it("authorizes deferred representations within mixed dynamic patterns", async () => { + const root = createProbeRoot(); + const route = { + cacheabilityProbe: { canPrunePattern: true, requestStageMayTerminate: true }, + kind: "app-page" as const, + pattern: "/posts/:slug", + }; + const firstHtml = { ...target("/posts/one"), route }; + const firstRsc = { + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-full" as const, + label: "/posts/one (RSC full)", + pathname: "/posts/one?_rsc", + route, + sourcePathname: "/posts/one", + }; + const secondHtml = { ...target("/posts/two"), route }; + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + return Response.json({ + kind: "app-page", + pattern: route.pattern, + ...(pathname.endsWith("/one") + ? { scope: "identity", state: "dynamic", terminal: true } + : { rendererStatic: true, state: "static-candidate" }), + status: pathname.endsWith("/one") ? 307 : 200, + version: 1, + }); + }, + root, + targetUrl: "https://example.com", + targets: [firstHtml, firstRsc, secondHtml], + }); + + expect(result).toMatchObject({ probed: 2, speculativeTargets: [firstRsc] }); + const manifestRoute = Object.values(result.manifest.routes)[0]; + expect(cacheabilityManifestRouteState(manifestRoute, "/posts/one", "rsc-full")).toBe( + "runtime-check", + ); + }); + + it("unlocks sibling paths without waiting for every pattern representative", async () => { + const root = createProbeRoot(); + const slow = target("/slow"); + const fastRoute = optimizableRoute("/fast/:slug"); + const fastFirst = { ...target("/fast/one"), route: fastRoute }; + const fastSecond = { ...target("/fast/two"), route: fastRoute }; + let slowCompleted = false; + let siblingStartedBeforeSlowCompleted = false; + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 2, + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + if (pathname === "/slow") { + await new Promise((resolve) => setTimeout(resolve, 20)); + slowCompleted = true; + } else if (pathname === "/fast/two") { + siblingStartedBeforeSlowCompleted = !slowCompleted; + } + return Response.json({ + kind: "app-page", + pattern: pathname === "/slow" ? "/slow" : fastRoute.pattern, + rendererStatic: true, + state: "static-candidate", + status: 200, + version: 1, + }); + }, + root, + targetUrl: "https://example.com", + targets: [slow, fastFirst, fastSecond], + }); + + expect(result).toMatchObject({ failures: [], probed: 3 }); + expect(siblingStartedBeforeSlowCompleted).toBe(true); + }); + + it("does not prune destination siblings before route-moving probes settle", async () => { + const root = createProbeRoot(); + const destinationRoute = optimizableRoute("/posts/:slug"); + const destinationFirst = { ...target("/posts/one"), route: destinationRoute }; + const destinationSecond = { ...target("/posts/two"), route: destinationRoute }; + const sourceRoute = { + cacheabilityProbe: { canPrunePattern: true, routeMayResolve: true }, + kind: "app-page" as const, + pattern: "/source", + }; + const source = { ...target("/source"), route: sourceRoute }; + const probedPathnames: string[] = []; + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 2, + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + probedPathnames.push(pathname); + if (pathname === "/source") { + await new Promise((resolve) => setTimeout(resolve, 20)); + return Response.json({ + kind: "app-page", + pattern: destinationRoute.pattern, + rendererStatic: true, + routePathname: "/posts/source", + state: "static-candidate", + status: 200, + version: 1, + }); + } + return Response.json({ + kind: "app-page", + pattern: destinationRoute.pattern, + ...(pathname.endsWith("/one") + ? { scope: "pattern", state: "dynamic" } + : { rendererStatic: true, state: "static-candidate" }), + status: 200, + version: 1, + }); + }, + root, + targetUrl: "https://example.com", + targets: [destinationFirst, destinationSecond, source], + }); + + expect(result.failures).toEqual([]); + expect(probedPathnames).toEqual( + expect.arrayContaining(["/posts/one", "/source", "/posts/two"]), + ); + expect(result.probed).toBe(3); }); it("does not prune a terminal-capable pattern from one representation", async () => { @@ -732,6 +884,157 @@ describe("staged Worker cacheability probes", () => { }); }); + it("retains deferred representation ownership when the primary resolves elsewhere", async () => { + const root = createProbeRoot(); + const { html, rsc } = pairedRouteTargets(); + const resolveRequest = (headers: HeadersInit) => { + const isRsc = new Headers(headers).get("RSC") === "1"; + return { + kind: "app-page", + pattern: isRsc ? "/source" : "/html-target", + rendererStatic: true, + routePathname: isRsc ? "/source" : "/html-target", + state: "static-candidate", + status: 200, + version: 1, + }; + }; + const fetchImpl = vi.fn(async (_input, init) => + Response.json(resolveRequest(init?.headers ?? {})), + ); + + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [rsc, html], + }); + + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(new Headers(fetchImpl.mock.calls[0]![1]?.headers).get("RSC")).toBeNull(); + expect(resolveRequest(rsc.headers)).toMatchObject({ + pattern: "/source", + routePathname: "/source", + }); + expect(result.failures).toEqual([]); + expect(result.cacheableTargets).toEqual([html, rsc]); + expect(result.speculativeTargets).toEqual([rsc]); + expect(Object.keys(result.manifest.routes)).toHaveLength(2); + const sourceManifestRoute = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/source")]; + const targetManifestRoute = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/html-target")]; + expect(sourceManifestRoute).toEqual({ + kind: "app-page", + pattern: "/source", + state: "runtime-check", + }); + expect(targetManifestRoute).toEqual({ + kind: "app-page", + pattern: "/html-target", + state: "runtime-check", + staticRepresentation: "html", + }); + expect(cacheabilityManifestRouteState(sourceManifestRoute, "/source", "rsc-full")).toBe( + "runtime-check", + ); + expect(cacheabilityManifestRouteState(targetManifestRoute, "/html-target", "html")).toBe( + "static-candidate", + ); + }); + + it.each([ + { + change: "route pathname", + expectedPattern: "/source", + expectedRoutePathname: "/resolved", + pattern: "/source", + routePathname: "/resolved", + }, + { + change: "route pattern", + expectedPattern: "/destination/:slug", + expectedRoutePathname: "/source", + pattern: "/destination/:slug", + routePathname: "/source", + }, + ])( + "retains deferred representation ownership when only the $change changes", + async ({ expectedPattern, expectedRoutePathname, pattern, routePathname }) => { + const root = createProbeRoot(); + const { html, route, rsc } = pairedRouteTargets(); + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async () => + Response.json({ + kind: route.kind, + pattern, + rendererStatic: true, + routePathname, + state: "static-candidate", + status: 200, + version: 1, + }), + retries: 0, + root, + targetUrl: "https://example.com", + targets: [rsc, html], + }); + + expect(result).toMatchObject({ + cacheableTargets: [html, rsc], + failures: [], + probed: 1, + speculativeTargets: [rsc], + }); + const manifestRoute = + result.manifest.routes[cacheabilityManifestRouteKey(route.kind, route.pattern)]; + expect(cacheabilityManifestRouteState(manifestRoute, "/source", "rsc-full")).toBe( + "runtime-check", + ); + const resolvedManifestRoute = + result.manifest.routes[cacheabilityManifestRouteKey(route.kind, expectedPattern)]; + expect( + cacheabilityManifestRouteState(resolvedManifestRoute, expectedRoutePathname, "html"), + ).toBe("static-candidate"); + }, + ); + + it("enforces the manifest byte boundary when one probe resolves into two routes", async () => { + const { html, route, rsc } = pairedRouteTargets(); + const fetchImpl = vi.fn(async () => + Response.json({ + kind: route.kind, + pattern: "/destination/:slug", + rendererStatic: true, + routePathname: "/destination/value", + state: "static-candidate", + status: 200, + version: 1, + }), + ); + const runProbe = (root: string, maxBytes?: number) => + probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl, + ...(maxBytes === undefined ? {} : { manifestLimits: { maxBytes } }), + retries: 0, + root, + targetUrl: "https://example.com", + targets: [rsc, html], + }); + + const baseline = await runProbe(createProbeRoot()); + const exactBytes = Buffer.byteLength(JSON.stringify(baseline.manifest)); + await expect(runProbe(createProbeRoot(), exactBytes)).resolves.toMatchObject({ probed: 1 }); + await expect(runProbe(createProbeRoot(), exactBytes - 1)).rejects.toThrow( + `the limit is ${exactBytes - 1} bytes`, + ); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + it("records a rewritten App runtime path without a direct destination target", async () => { const root = createProbeRoot(); const source = { diff --git a/tests/cloudflare-cdn-worker.test.ts b/tests/cloudflare-cdn-worker.test.ts index f48e53e84..9e9d9aebf 100644 --- a/tests/cloudflare-cdn-worker.test.ts +++ b/tests/cloudflare-cdn-worker.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { configureWorkersCacheEntrypoints } from "../packages/cloudflare/src/cache/cdn-adapter-config.js"; import worker, { VinextCachedResponse, + VinextUncachedResponse, } from "../packages/cloudflare/src/cache/cdn-adapter.worker.js"; import { VINEXT_CDN_BUILD_ID_HEADER } from "../packages/cloudflare/src/cache/cdn-build-id.js"; import { @@ -46,6 +47,13 @@ function createEntrypoint(props: unknown, env: unknown = { binding: "value" }) { }) as VinextCachedResponse; } +function createUncachedEntrypoint(props: unknown, env: unknown = { binding: "value" }) { + return Object.assign(Object.create(VinextUncachedResponse.prototype), { + ctx: { props }, + env, + }) as VinextUncachedResponse; +} + function responseStageInvocation( props: unknown, requestUrl = "https://example.com/page", @@ -160,6 +168,7 @@ describe("Cloudflare CDN multi-stage Worker facade", () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); + vi.unstubAllGlobals(); }); it("exports the cached stage as a named WorkerEntrypoint class", () => { @@ -167,6 +176,11 @@ describe("Cloudflare CDN multi-stage Worker facade", () => { expect(typeof VinextCachedResponse.prototype.fetch).toBe("function"); }); + it("exports bypass rendering as a separate named WorkerEntrypoint class", () => { + expect(typeof VinextUncachedResponse).toBe("function"); + expect(typeof VinextUncachedResponse.prototype.fetch).toBe("function"); + }); + it("lazily invokes the response stage from named-entrypoint props", async () => { const request = new Request("https://example.com/page"); const props = { kind: "pages-page", resolvedUrl: "/page" }; @@ -214,6 +228,79 @@ describe("Cloudflare CDN multi-stage Worker facade", () => { }, ); + it("preserves immutable WebSocket upgrades while stamping bypass identity", async () => { + // No Next.js test port applies: preserving a Workers 101 response across + // configurable entrypoints is specific to the Cloudflare adapter. + const OriginalResponse = Response; + const webSocket = {} as WebSocket; + class WorkersResponse extends OriginalResponse { + readonly webSocket: WebSocket | null; + readonly #workersStatus: number; + + constructor(body?: BodyInit | null, init?: ResponseInit & { webSocket?: WebSocket | null }) { + const status = init?.status ?? 200; + super(body, { ...init, status: status === 101 ? 200 : status }); + this.#workersStatus = status; + this.webSocket = init?.webSocket ?? null; + } + + override get status(): number { + return this.#workersStatus; + } + } + vi.stubGlobal("Response", WorkersResponse); + vi.stubEnv("__VINEXT_RSC_BUILD_IDENTITY", "current-stage"); + const upgrade = new WorkersResponse(null, { status: 101, webSocket }); + vi.spyOn(upgrade.headers, "set").mockImplementation(() => { + throw new TypeError("Cannot modify immutable headers"); + }); + stages.response.mockResolvedValue(upgrade); + + const response = (await createUncachedEntrypoint({ + ...responseStageInvocation({ kind: "app-route" }), + expectedResponseStageBuildIdentity: "current-stage", + options: { cache: "vinext-cloudflare-v1:bypass" }, + }).fetch( + new Request("https://example.com/socket", { + headers: { Upgrade: "websocket" }, + }), + )) as WorkersResponse; + + expect(response).not.toBe(upgrade); + expect(response.status).toBe(101); + expect(response.webSocket).toBe(webSocket); + expect(response.headers.get(VINEXT_CDN_BUILD_ID_HEADER)).toBe("current-stage"); + }); + + it("fails closed when an immutable non-HTTP response cannot be stamped", async () => { + vi.stubEnv("__VINEXT_RSC_BUILD_IDENTITY", "current-stage"); + stages.request.mockImplementation((request, _env, _ctx, dispatch) => + dispatch(request, { kind: "app-route" }, { cache: "bypass" }), + ); + stages.response.mockResolvedValue(Response.error()); + const stageResponses: Response[] = []; + const binding = vi.fn(({ props }: { props: unknown }) => ({ + async fetch(request: Request) { + const response = await createUncachedEntrypoint(props).fetch(request); + stageResponses.push(response); + return response; + }, + })); + + const response = await worker.fetch( + new Request("https://example.com/error"), + {}, + { exports: { VinextUncachedResponse: binding } }, + ); + + expect(response.status).toBe(503); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(stageResponses).toHaveLength(1); + expect(stageResponses[0]!.status).toBe(503); + expect(stageResponses[0]!.headers.get(VINEXT_CDN_BUILD_ID_HEADER)).toBe("current-stage"); + expect(stages.response).toHaveBeenCalledOnce(); + }); + it.each([ ["missing", null, 503], ["different", "previous-stage", 503], @@ -435,6 +522,50 @@ describe("Cloudflare CDN multi-stage Worker facade", () => { expect(stages.response).not.toHaveBeenCalled(); }); + it("rejects cache intent sent to the wrong response entrypoint", async () => { + const request = new Request("https://example.com/page"); + const [cachedResponse, uncachedResponse] = await Promise.all([ + createEntrypoint({ + ...responseStageInvocation({ kind: "app-page" }), + options: { cache: "bypass" }, + }).fetch(request), + createUncachedEntrypoint(responseStageInvocation({ kind: "app-page" })).fetch(request), + ]); + + expect(cachedResponse.status).toBe(400); + expect(uncachedResponse.status).toBe(400); + expect(stages.response).not.toHaveBeenCalled(); + }); + + it("routes invalidation from uncached renders through the cache-bearing entrypoint", async () => { + const purge = vi.fn().mockResolvedValue({ success: true }); + const cachedBinding = vi.fn(() => ({ fetch: vi.fn(), purge })); + stages.response.mockImplementation((_request, _env, context) => { + const cache = Reflect.get(context, "cache") as { + purge(options: { tags: string[] }): unknown; + }; + return Promise.resolve(cache.purge({ tags: ["updated-tag"] })).then( + () => new Response("rendered"), + ); + }); + const entrypoint = Object.assign(Object.create(VinextUncachedResponse.prototype), { + ctx: { + exports: { VinextCachedResponse: cachedBinding }, + props: { + ...responseStageInvocation({ kind: "app-route" }), + options: { cache: "bypass" }, + }, + }, + env: {}, + }) as VinextUncachedResponse; + + const response = await entrypoint.fetch(new Request("https://example.com/action")); + + await expect(response.text()).resolves.toBe("rendered"); + expect(cachedBinding).toHaveBeenCalledWith({ props: {} }); + expect(purge).toHaveBeenCalledWith({ tags: ["updated-tag"] }); + }); + it("rejects unversioned cache intent when an expected identity is present", async () => { vi.stubEnv("__VINEXT_RSC_BUILD_IDENTITY", "current-stage"); const response = await createEntrypoint({ @@ -814,6 +945,56 @@ describe("Cloudflare CDN multi-stage Worker facade", () => { } }); + it("keeps browser cache directives off Workers Cache while restoring cold renders", async () => { + const cacheFacingRequests: Request[] = []; + const binding = vi.fn(({ props }: { props: unknown }) => ({ + fetch(request: Request) { + cacheFacingRequests.push(request); + return createEntrypoint(props).fetch(request); + }, + })); + stages.request.mockImplementation((request, _env, _ctx, dispatch) => + dispatch(request, { kind: "app-page" }, { cache: "shared" }), + ); + stages.response.mockImplementation((request) => + Response.json({ + cacheControl: request.headers.get("Cache-Control"), + pragma: request.headers.get("Pragma"), + cacheControlTransport: request.headers.get("x-vinext-internal-request-cache-control"), + pragmaTransport: request.headers.get("x-vinext-internal-request-pragma"), + }), + ); + + const response = await worker.fetch( + new Request("https://example.com/reload", { + headers: { + "Cache-Control": "no-cache", + Pragma: "no-cache", + "x-vinext-internal-request-cache-control": "forged-cache-control", + "x-vinext-internal-request-pragma": "forged-pragma", + }, + }), + {}, + { exports: { VinextCachedResponse: binding } }, + ); + + expect(cacheFacingRequests).toHaveLength(1); + expect(cacheFacingRequests[0]?.headers.get("Cache-Control")).toBeNull(); + expect(cacheFacingRequests[0]?.headers.get("Pragma")).toBeNull(); + expect(cacheFacingRequests[0]?.headers.get("x-vinext-internal-request-cache-control")).not.toBe( + "forged-cache-control", + ); + expect(cacheFacingRequests[0]?.headers.get("x-vinext-internal-request-pragma")).not.toBe( + "forged-pragma", + ); + await expect(response.json()).resolves.toEqual({ + cacheControl: "no-cache", + pragma: "no-cache", + cacheControlTransport: null, + pragmaTransport: null, + }); + }); + it("keys cached dispatches by route metadata and restores the user-facing URL", async () => { const cachedEntrypoint = createEntrypoint( responseStageInvocation( @@ -1095,9 +1276,14 @@ describe("Cloudflare CDN multi-stage Worker facade", () => { expect(stages.response.mock.calls.map(([request]) => request.method)).toEqual(["GET", "HEAD"]); }); - it("renders bypass work without consulting the cached entrypoint", async () => { + it("renders bypass work through the uncached entrypoint", async () => { const rendered = new Response("private"); - const binding = vi.fn(); + const cachedBinding = vi.fn(); + const uncachedBinding = vi.fn(({ props }: { props: unknown }) => ({ + fetch(request: Request) { + return createUncachedEntrypoint(props).fetch(request); + }, + })); stages.response.mockResolvedValue(rendered); stages.request.mockImplementation((_request, _env, _ctx, dispatch) => dispatch( @@ -1111,12 +1297,16 @@ describe("Cloudflare CDN multi-stage Worker facade", () => { new Request("https://example.com/private"), {}, { - exports: { VinextCachedResponse: binding }, + exports: { + VinextCachedResponse: cachedBinding, + VinextUncachedResponse: uncachedBinding, + }, }, ); expect(result).toBe(rendered); - expect(binding).not.toHaveBeenCalled(); + expect(cachedBinding).not.toHaveBeenCalled(); + expect(uncachedBinding).toHaveBeenCalledOnce(); expect(stages.response).toHaveBeenCalledOnce(); }); @@ -1126,7 +1316,7 @@ describe("Cloudflare CDN multi-stage Worker facade", () => { vi.stubEnv("__VINEXT_RSC_BUILD_IDENTITY", "current-stage"); const binding = vi.fn(({ props }: { props: unknown }) => ({ fetch(request: Request) { - return createEntrypoint(props).fetch(request); + return createUncachedEntrypoint(props).fetch(request); }, })); stages.request.mockImplementation((request, _env, _ctx, dispatch) => @@ -1145,7 +1335,7 @@ describe("Cloudflare CDN multi-stage Worker facade", () => { const response = await worker.fetch( request, {}, - { exports: { VinextCachedResponse: binding } }, + { exports: { VinextUncachedResponse: binding } }, ); expect(response.status).toBe(204); @@ -1181,6 +1371,13 @@ describe("Cloudflare CDN multi-stage Worker facade", () => { it("strips forged transport metadata from bypass and fallback renders", async () => { for (const cache of ["bypass", "shared"] as const) { + const binding = vi.fn(({ props }: { props: unknown }) => ({ + fetch(request: Request) { + return cache === "shared" + ? createEntrypoint(props).fetch(request) + : createUncachedEntrypoint(props).fetch(request); + }, + })); stages.request.mockImplementation((request, _env, _ctx, dispatch) => dispatch(request, { route: "/private" }, { cache }), ); @@ -1199,7 +1396,12 @@ describe("Cloudflare CDN multi-stage Worker facade", () => { }, }), {}, - {}, + { + exports: + cache === "shared" + ? { VinextCachedResponse: binding } + : { VinextUncachedResponse: binding }, + }, ); await expect(response.json()).resolves.toEqual({ @@ -1211,7 +1413,7 @@ describe("Cloudflare CDN multi-stage Worker facade", () => { } }); - it("falls back to an uncached render when loopback exports are unavailable", async () => { + it("fails closed when the required response entrypoint is unavailable", async () => { stages.response.mockResolvedValue( new Response("rendered", { headers: { @@ -1228,14 +1430,15 @@ describe("Cloudflare CDN multi-stage Worker facade", () => { ); const response = await worker.fetch(new Request("https://example.com/page"), {}, {}); - expect(await response.text()).toBe("rendered"); - expect(response.headers.get("Cache-Control")).toBe("private, max-age=0, must-revalidate"); + expect(response.status).toBe(503); + expect(await response.text()).toBe(""); + expect(response.headers.get("Cache-Control")).toBe("no-store"); expect(response.headers.get("CDN-Cache-Control")).toBeNull(); expect(response.headers.get("Cloudflare-CDN-Cache-Control")).toBeNull(); expect(response.headers.get("Cache-Tag")).toBeNull(); expect(response.headers.get("X-Vinext-Cache")).toBeNull(); expect(response.headers.get("X-Nextjs-Cache")).toBeNull(); - expect(stages.response).toHaveBeenCalledOnce(); + expect(stages.response).not.toHaveBeenCalled(); }); it("routes gateway purges through the cache-bearing entrypoint", async () => { @@ -1270,6 +1473,7 @@ describe("Workers Cache deployment configuration", () => { exports: { default: { type: "worker", cache: { enabled: false } }, VinextCachedResponse: { type: "worker", cache: { enabled: true } }, + VinextUncachedResponse: { type: "worker", cache: { enabled: false } }, }, }); }); @@ -1278,11 +1482,13 @@ describe("Workers Cache deployment configuration", () => { expect( configureWorkersCacheEntrypoints({ exports: { Counter: { type: "durable_object" } } }), ).toMatchObject({ exports: { Counter: { type: "durable_object" } } }); - expect(() => - configureWorkersCacheEntrypoints({ - exports: { VinextCachedResponse: { type: "durable_object" } }, - }), - ).toThrow(/reserved Worker export/); + for (const name of ["VinextCachedResponse", "VinextUncachedResponse"]) { + expect(() => + configureWorkersCacheEntrypoints({ + exports: { [name]: { type: "durable_object" } }, + }), + ).toThrow(/reserved Worker export/); + } }); it("preserves compatibility flags and rejects an explicit ctx.exports opt-out", () => { diff --git a/tests/pages-response-stage.test.ts b/tests/pages-response-stage.test.ts index debfaf196..c5f717b21 100644 --- a/tests/pages-response-stage.test.ts +++ b/tests/pages-response-stage.test.ts @@ -79,8 +79,8 @@ describe("Pages response-stage dispatch", () => { "max-age=0, no-cache", "public, no-store, max-age=60", 'no-cache="set-cookie"', - ])("keeps Cache-Control %s local", (cacheControl) => { - expect(shouldDispatch({ headers: { "Cache-Control": cacheControl } })).toBe(false); + ])("lets the host cache decide request Cache-Control %s", (cacheControl) => { + expect(shouldDispatch({ headers: { "Cache-Control": cacheControl } })).toBe(true); }); it.each(["max-age=0", "public, max-age=0, must-revalidate", "no-cacheable=true"])( diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 0977deb98..89e69f25f 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -1062,6 +1062,100 @@ describe("prerender path manifest", () => { }); }); + it("does not expand staged middleware probes beyond its pathname matcher", async () => { + // Ported from Next.js: test/e2e/app-dir/middleware-matching/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/middleware-matching/index.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( + "middleware.ts", + [ + "export const config = { matcher: [", + ' "/middleware-source",', + ' { source: "/en/default-locale-source", locale: false },', + ' { source: "/fr/domain-locale-source", locale: false },', + "] };", + "export default function middleware() {}", + ].join("\n"), + ); + for (const pathname of [ + "middleware-source", + "default-locale-source", + "domain-locale-source", + "outside", + ]) { + writeFile( + `app/${pathname}/page.tsx`, + "export const revalidate = 60; export default function Page() {}\n", + ); + } + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const manifest = await emitPrerenderPathManifest({ + nextConfig: await resolveNextConfig( + { + i18n: { + defaultLocale: "en", + domains: [{ defaultLocale: "fr", domain: "fr.example.com" }], + locales: ["en", "fr"], + }, + }, + tmpDir, + ), + requestRouting: "uncached-stage", + responseVary: "verbatim", + root: tmpDir, + }); + + expect(manifest?.routePatterns?.["/middleware-source"]?.cacheabilityProbe).toMatchObject({ + requestStageMayTerminate: true, + routeMayResolve: true, + }); + for (const pathname of ["/default-locale-source", "/domain-locale-source"]) { + expect(manifest?.routePatterns?.[pathname]?.cacheabilityProbe).toMatchObject({ + requestStageMayTerminate: true, + routeMayResolve: true, + }); + } + expect( + manifest?.routePatterns?.["/outside"]?.cacheabilityProbe?.requestStageMayTerminate, + ).toBeUndefined(); + expect( + manifest?.routePatterns?.["/outside"]?.cacheabilityProbe?.routeMayResolve, + ).toBeUndefined(); + }); + + it("uses runtime pathname decoding and locale provenance for middleware probe scope", async () => { + const { matchesMiddlewareWarmPath } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const i18n = { + defaultLocale: "en", + domains: [{ defaultLocale: "fr", domain: "fr.example.com" }], + locales: ["en", "fr"], + }; + + expect(matchesMiddlewareWarmPath("/%64ecoded", "/decoded", null)).toBe(true); + expect(matchesMiddlewareWarmPath("/你好", "/%E4%BD%A0%E5%A5%BD", null)).toBe(true); + expect( + matchesMiddlewareWarmPath("/localized", [{ locale: false, source: "/en/localized" }], i18n), + ).toBe(true); + expect( + matchesMiddlewareWarmPath("/localized", [{ locale: false, source: "/fr/localized" }], i18n), + ).toBe(true); + expect( + matchesMiddlewareWarmPath( + "/fr/localized", + [{ locale: false, source: "/en/fr/localized" }], + i18n, + ), + ).toBe(false); + }); + it("retains redirect sources only when routing runs in an uncached stage", async () => { // Next.js applies config redirects before rendering the filesystem route: // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts