diff --git a/packages/cloudflare/src/cache/cdn-adapter.runtime.ts b/packages/cloudflare/src/cache/cdn-adapter.runtime.ts index 57cfd472b..54b80bb57 100644 --- a/packages/cloudflare/src/cache/cdn-adapter.runtime.ts +++ b/packages/cloudflare/src/cache/cdn-adapter.runtime.ts @@ -42,13 +42,20 @@ import { } from "vinext/shims/cdn-cache"; import type { CacheHandlerValue, IncrementalCacheValue } from "vinext/shims/cache"; import { getRequestExecutionContext } from "vinext/shims/request-context"; +import { VINEXT_CDN_BUILD_ID_HEADER } from "./cdn-build-id.js"; const CACHEABLE_EDGE_DIRECTIVE_RE = /(?:^|,)\s*(?:s-maxage|max-age)\s*=/i; const EDGE_POLICY_HEADERS = ["CDN-Cache-Control", "Cloudflare-CDN-Cache-Control"] as const; +function getBuildIdentityResponseHeader(): CdnResponseHeaders { + const buildId = process.env.__VINEXT_BUILD_ID; + return buildId ? { [VINEXT_CDN_BUILD_ID_HEADER]: buildId } : {}; +} + /** Remove every response header whose cache semantics are owned by Cloudflare. */ function clearCloudflareCdnResponseHeaders(cacheControl: string): CdnResponseHeaders { return { + ...getBuildIdentityResponseHeader(), "Cache-Control": cacheControl, "CDN-Cache-Control": null, "Cloudflare-CDN-Cache-Control": null, @@ -172,6 +179,10 @@ export class CloudflareCdnCacheAdapter implements CdnCacheAdapter { // intentionally empty } + buildResponseIdentityHeaders(): CdnResponseHeaders { + return getBuildIdentityResponseHeader(); + } + buildResponseHeaders(input: CdnCacheableHeaderInput): CdnResponseHeaders { // No cacheable policy → nobody stores it. if (!input.cacheControl) { @@ -188,6 +199,7 @@ export class CloudflareCdnCacheAdapter implements CdnCacheAdapter { // SWR policy on CDN-Cache-Control (edge caches + revalidates); the browser // is told to revalidate every reuse so it never serves a stale stored copy. const headers: CdnResponseHeaders = { + ...getBuildIdentityResponseHeader(), "Cache-Control": BROWSER_REVALIDATE, "CDN-Cache-Control": toEdgeCacheControl(input.cacheControl), "Cloudflare-CDN-Cache-Control": null, diff --git a/packages/cloudflare/src/cache/cdn-adapter.ts b/packages/cloudflare/src/cache/cdn-adapter.ts index bcd4978b8..814e64c46 100644 --- a/packages/cloudflare/src/cache/cdn-adapter.ts +++ b/packages/cloudflare/src/cache/cdn-adapter.ts @@ -23,6 +23,7 @@ export function cdnAdapter(options?: Record) { adapter: fileURLToPath(import.meta.resolve("./cdn-adapter.runtime.js")), options, capabilities: { + buildIdentity: "response-header" as const, responseVary: "verbatim" as const, }, }; diff --git a/packages/cloudflare/src/cache/cdn-build-id.ts b/packages/cloudflare/src/cache/cdn-build-id.ts new file mode 100644 index 000000000..fc31b1065 --- /dev/null +++ b/packages/cloudflare/src/cache/cdn-build-id.ts @@ -0,0 +1,2 @@ +/** Build identity stamped by the Cloudflare CDN adapter on page responses. */ +export const VINEXT_CDN_BUILD_ID_HEADER = "X-Vinext-Build-Id"; diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index fb1de9efa..fcb643d26 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -21,6 +21,7 @@ import { } from "vinext/internal/server/app-rsc-cache-busting"; import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache"; import { normalizePathTrailingSlash } from "vinext/shims/url-utils"; +import { VINEXT_CDN_BUILD_ID_HEADER } from "./cache/cdn-build-id.js"; export type CdnWarmOptions = { targetUrl: string; @@ -31,6 +32,8 @@ export type CdnWarmOptions = { loadingShellPaths?: readonly string[]; /** Build identity that the warmed RSC response must have been rendered by. */ expectedRscBuildId?: string; + /** Application build identity stamped by the configured CDN adapter. */ + expectedBuildId?: string; deploymentId?: string; headers?: HeadersInit; concurrency?: number; @@ -49,6 +52,8 @@ export const DEFAULT_CDN_WARM_TIMEOUT_MS = 10_000; export type PrerenderCdnWarmOptions = Omit & { root: string; includeFallbackShells?: boolean; + /** Use the manifest build ID unless the configured adapter cannot expose it. */ + validateBuildIdentity?: boolean; }; export type CdnWarmResult = { @@ -57,9 +62,17 @@ export type CdnWarmResult = { skipped: number; failed: number; failures: Array<{ path: string; error: string }>; + retryPlan: CdnWarmRequestPlan; +}; + +export type CdnWarmRequestPlan = { + loadingShellPaths: string[]; + paths: string[]; + rscPaths: string[]; }; export type PrerenderWarmPlan = { + buildId?: string; deploymentId?: string; loadingShellPaths: string[]; paths: string[]; @@ -195,6 +208,7 @@ export function readPrerenderWarmPlan( } } return { + buildId: manifest.buildId, ...(manifest.deploymentId ? { deploymentId: manifest.deploymentId } : {}), loadingShellPaths: supportsCanonicalRsc ? (manifest.loadingShellPaths ?? []).map(applyConfig) @@ -281,9 +295,10 @@ async function fetchWithTimeout( type WarmTarget = { headers?: HeadersInit; - kind: "html" | "rsc"; + kind: "html" | "rsc-full" | "rsc-loading-shell"; label: string; pathname: string; + sourcePathname: string; }; class CdnWarmProgress { @@ -312,15 +327,48 @@ class CdnWarmProgress { const REQUIRED_RSC_VARY_HEADERS = VINEXT_RSC_VARY_HEADER.split(",").map((name) => name.trim().toLowerCase(), ); -const ADMITTED_CF_CACHE_STATUSES = new Set([ - "HIT", - "MISS", - "EXPIRED", - "REVALIDATED", - "UPDATING", - "STALE", -]); -const NON_CACHEABLE_CF_CACHE_STATUSES = new Set(["BYPASS", "DYNAMIC"]); +const ADMITTED_CF_CACHE_STATUSES = new Set(["HIT", "MISS", "EXPIRED", "REVALIDATED", "UPDATING"]); +const NON_CACHEABLE_CF_CACHE_STATUSES = new Set(["BYPASS"]); +const CDN_CACHE_POLICY_HEADERS = [ + "Cloudflare-CDN-Cache-Control", + "CDN-Cache-Control", + "Cache-Control", +] as const; + +function splitCacheControlDirectives(value: string): string[] { + const directives: string[] = []; + let start = 0; + let quoted = false; + let escaped = false; + for (let index = 0; index < value.length; index++) { + const character = value[index]; + if (escaped) { + escaped = false; + } else if (quoted && character === "\\") { + escaped = true; + } else if (character === '"') { + quoted = !quoted; + } else if (character === "," && !quoted) { + directives.push(value.slice(start, index)); + start = index + 1; + } + } + directives.push(value.slice(start)); + return directives; +} + +function hasFieldQualifiedSetCookie(value: string): boolean { + return splitCacheControlDirectives(value).some((directive) => { + const separator = directive.indexOf("="); + if (separator === -1) return false; + const name = directive.slice(0, separator).trim().toLowerCase(); + if (name !== "private" && name !== "no-cache") return false; + const rawFields = directive.slice(separator + 1).trim(); + const fields = + rawFields.startsWith('"') && rawFields.endsWith('"') ? rawFields.slice(1, -1) : rawFields; + return fields.split(",").some((field) => field.trim().toLowerCase() === "set-cookie"); + }); +} type WarmValidation = | { outcome: "warmed" } @@ -328,25 +376,63 @@ type WarmValidation = | { outcome: "failed"; error: string }; function validateCachePolicy(response: Response, requireCacheStatus: boolean): WarmValidation { - const nonCacheableHeaders = [ - "Cache-Control", - "CDN-Cache-Control", - "Cloudflare-CDN-Cache-Control", - ].filter((name) => { - const value = response.headers.get(name); - return value !== null && isNonCacheableCacheControl(value); - }); + const effectivePolicy = CDN_CACHE_POLICY_HEADERS.map((name) => ({ + name, + value: response.headers.get(name), + })).find( + (entry): entry is { name: (typeof CDN_CACHE_POLICY_HEADERS)[number]; value: string } => + entry.value !== null, + ); + const nonCacheableHeaders = + effectivePolicy && isNonCacheableCacheControl(effectivePolicy.value) + ? [effectivePolicy.name] + : []; const hasSetCookie = response.headers.has("Set-Cookie"); const cacheStatus = response.headers.get("CF-Cache-Status")?.trim().toUpperCase(); - if (nonCacheableHeaders.length > 0 || hasSetCookie) { + // Cloudflare-CDN-Cache-Control is consumed at the edge and is deliberately + // not forwarded to clients. A cacheable Cloudflare-specific policy can + // therefore coexist with a downstream `no-store` policy or a Set-Cookie + // header that is stripped from the cached object. CF-Cache-Status is the + // authoritative admission result: MISS means the response was eligible for + // cache, while response-time rejection is reported as BYPASS. Downstream + // freshness cannot reveal a stripped higher-priority edge policy. + if (cacheStatus && ADMITTED_CF_CACHE_STATUSES.has(cacheStatus)) { + if (hasSetCookie && (!effectivePolicy || !hasFieldQualifiedSetCookie(effectivePolicy.value))) { + return { + outcome: "failed", + error: "response sets a cookie without an observable field-qualified cache policy", + }; + } + return { outcome: "warmed" }; + } + + if (cacheStatus && NON_CACHEABLE_CF_CACHE_STATUSES.has(cacheStatus)) { const reason = nonCacheableHeaders.length > 0 ? `${nonCacheableHeaders.join(", ")} opts out of caching` - : "response sets a cookie"; - if (cacheStatus && NON_CACHEABLE_CF_CACHE_STATUSES.has(cacheStatus)) { + : hasSetCookie + ? "response sets a cookie" + : `CF-Cache-Status is ${cacheStatus}`; + return { outcome: "skipped", reason }; + } + + if (cacheStatus === "DYNAMIC") { + if (nonCacheableHeaders.length > 0 || hasSetCookie) { + const reason = + nonCacheableHeaders.length > 0 + ? `${nonCacheableHeaders.join(", ")} opts out of caching` + : "response sets a cookie"; return { outcome: "skipped", reason }; } + return { outcome: "failed", error: "CF-Cache-Status is DYNAMIC" }; + } + + if (nonCacheableHeaders.length > 0 || hasSetCookie) { + const reason = + nonCacheableHeaders.length > 0 + ? `${nonCacheableHeaders.join(", ")} opts out of caching` + : "response sets a cookie"; return { outcome: "failed", error: `${reason}, but CF-Cache-Status is ${cacheStatus ?? "missing"}`, @@ -358,13 +444,30 @@ function validateCachePolicy(response: Response, requireCacheStatus: boolean): W ? { outcome: "failed", error: "response is missing CF-Cache-Status" } : { outcome: "warmed" }; } - if (!ADMITTED_CF_CACHE_STATUSES.has(cacheStatus)) { - return { outcome: "failed", error: `CF-Cache-Status is ${cacheStatus}` }; + return { outcome: "failed", error: `CF-Cache-Status is ${cacheStatus}` }; +} + +function validateBuildIdentity( + response: Response, + expectedBuildId?: string, +): WarmValidation | null { + if ( + expectedBuildId !== undefined && + response.headers.get(VINEXT_CDN_BUILD_ID_HEADER) !== expectedBuildId + ) { + return { + outcome: "failed", + error: `response ${VINEXT_CDN_BUILD_ID_HEADER} does not match build ${expectedBuildId}`, + }; } - return { outcome: "warmed" }; + return null; } -function validateRscWarmResponse(response: Response, expectedRscBuildId?: string): WarmValidation { +function validateRscWarmResponse( + response: Response, + expectedBuildId?: string, + expectedRscBuildId?: string, +): WarmValidation { if (response.redirected || response.status < 200 || response.status >= 300) { return { outcome: "failed", @@ -374,6 +477,8 @@ function validateRscWarmResponse(response: Response, expectedRscBuildId?: string if (!response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE)) { return { outcome: "failed", error: `expected ${VINEXT_RSC_CONTENT_TYPE} response` }; } + const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId); + if (buildIdentityValidation) return buildIdentityValidation; if ( expectedRscBuildId !== undefined && response.headers.get(VINEXT_RSC_BUILD_ID_HEADER) !== expectedRscBuildId @@ -383,6 +488,8 @@ function validateRscWarmResponse(response: Response, expectedRscBuildId?: string error: `response ${VINEXT_RSC_BUILD_ID_HEADER} does not match build ${expectedRscBuildId}`, }; } + const cachePolicyValidation = validateCachePolicy(response, true); + if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation; const vary = new Set( (response.headers.get("Vary") ?? "") .split(",") @@ -397,17 +504,54 @@ function validateRscWarmResponse(response: Response, expectedRscBuildId?: string if (extraVary) { return { outcome: "failed", error: `response Vary has unsupported field ${extraVary}` }; } - return validateCachePolicy(response, true); + return { outcome: "warmed" }; } -function validateHtmlWarmResponse(response: Response): WarmValidation { +function validateHtmlWarmResponse(response: Response, expectedBuildId?: string): WarmValidation { if (response.redirected || response.status < 200 || response.status >= 300) { return { outcome: "failed", error: response.redirected ? "redirected response" : `HTTP ${response.status}`, }; } - return validateCachePolicy(response, true); + const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId); + if (buildIdentityValidation) return buildIdentityValidation; + const cachePolicyValidation = validateCachePolicy(response, true); + if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation; + return { outcome: "warmed" }; +} + +function shouldRetryValidationFailure( + response: Response, + target: WarmTarget, + options: { + expectedBuildId?: string; + expectedRscBuildId?: string; + retryNotFound: boolean; + retryPropagationFailures: boolean; + }, +): boolean { + if (!options.retryPropagationFailures) { + return isRetryableStatus(response.status, options.retryNotFound); + } + + const expectedIdentities = [ + options.expectedBuildId === undefined + ? null + : response.headers.get(VINEXT_CDN_BUILD_ID_HEADER) === options.expectedBuildId, + target.kind === "html" || options.expectedRscBuildId === undefined + ? null + : response.headers.get(VINEXT_RSC_BUILD_ID_HEADER) === options.expectedRscBuildId, + ].filter((matches): matches is boolean => matches !== null); + if (expectedIdentities.some((matches) => !matches)) return true; + + // A matching identity proves routing reached the uploaded Worker. From that + // point, retry only transient HTTP failures; response-shape and admission + // failures are deterministic for that build. + if (expectedIdentities.length > 0) { + return isRetryableStatus(response.status, false); + } + return isRetryableStatus(response.status, options.retryNotFound); } async function warmOnePath( @@ -415,18 +559,20 @@ async function warmOnePath( options: Required> & { fetchImpl: typeof fetch; headers?: HeadersInit; + expectedBuildId?: string; expectedRscBuildId?: string; - retryAllValidationErrors: boolean; + retryPropagationFailures: boolean; retryDelayMs: number; retryNotFound: boolean; }, ): Promise< | { path: string; ok: true; skipped: false } | { path: string; ok: true; skipped: true; reason: string } - | { path: string; ok: false; error: string } + | { path: string; ok: false; error: string; retryable: boolean } > { const url = buildWarmupUrl(options.targetUrl, target.pathname); let lastError = "request failed before the first attempt"; + let lastRetryable = true; const canRetry = (attempt: number): boolean => attempt < options.retries; @@ -456,8 +602,12 @@ async function warmOnePath( ); } - if (target.kind === "rsc") { - const validation = validateRscWarmResponse(response, options.expectedRscBuildId); + if (target.kind !== "html") { + const validation = validateRscWarmResponse( + response, + options.expectedBuildId, + options.expectedRscBuildId, + ); if (validation.outcome === "warmed") { return { path: target.label, ok: true, skipped: false }; } @@ -465,17 +615,14 @@ async function warmOnePath( return { path: target.label, ok: true, skipped: true, reason: validation.reason }; } lastError = validation.error; - if ( - !options.retryAllValidationErrors && - !isRetryableStatus(response.status, options.retryNotFound) - ) - break; + lastRetryable = shouldRetryValidationFailure(response, target, options); + if (!lastRetryable) break; if (!canRetry(attempt)) break; await waitBeforeRetry(); continue; } - const validation = validateHtmlWarmResponse(response); + const validation = validateHtmlWarmResponse(response, options.expectedBuildId); if (validation.outcome === "warmed") { return { path: target.label, ok: true, skipped: false }; } @@ -483,12 +630,10 @@ async function warmOnePath( return { path: target.label, ok: true, skipped: true, reason: validation.reason }; } lastError = validation.error; - if ( - !options.retryAllValidationErrors && - !isRetryableStatus(response.status, options.retryNotFound) - ) - break; + lastRetryable = shouldRetryValidationFailure(response, target, options); + if (!lastRetryable) break; } catch (error) { + lastRetryable = true; if (error instanceof DOMException && error.name === "AbortError") { lastError = `timed out after ${options.timeoutMs}ms`; } else { @@ -499,7 +644,7 @@ async function warmOnePath( await waitBeforeRetry(); } - return { path: target.label, ok: false, error: lastError }; + return { path: target.label, ok: false, error: lastError, retryable: lastRetryable }; } async function runWithConcurrency( @@ -526,19 +671,23 @@ async function runWithConcurrency( export async function warmCdnCache(options: CdnWarmOptions): Promise { const requests: WarmTarget[] = []; const htmlRequests: WarmTarget[] = []; + const fullRscPaths = new Set(options.rscPaths ?? []); const loadingShellPaths = new Set(options.loadingShellPaths ?? []); const commonHeaders = new Headers(options.headers); - for (const pathname of options.rscPaths ?? []) { - const rscHeaders = new Headers(commonHeaders); - for (const [name, value] of createCanonicalRscRequestHeaders(options.deploymentId)) { - rscHeaders.set(name, value); + for (const pathname of new Set([...fullRscPaths, ...loadingShellPaths])) { + if (fullRscPaths.has(pathname)) { + const rscHeaders = new Headers(commonHeaders); + for (const [name, value] of createCanonicalRscRequestHeaders(options.deploymentId)) { + rscHeaders.set(name, value); + } + requests.push({ + headers: rscHeaders, + kind: "rsc-full", + label: `${pathname} (RSC full)`, + pathname: createCanonicalRscRequestUrl(pathname), + sourcePathname: pathname, + }); } - requests.push({ - headers: rscHeaders, - kind: "rsc", - label: `${pathname} (RSC full)`, - pathname: createCanonicalRscRequestUrl(pathname), - }); if (loadingShellPaths.has(pathname)) { const loadingHeaders = new Headers(commonHeaders); @@ -549,9 +698,10 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise { const isPropagationRequest = retryMode !== "normal"; const retries = - retryMode === "propagation-gate" - ? propagationRetries - : retryMode === "propagation-retry" - ? Math.max(0, propagationRetries - 1) - : retryMode === "propagation-pass" - ? 0 - : normalRetries; + retryMode === "propagation-retry" + ? Math.max(0, propagationRetries - 1) + : retryMode === "propagation-pass" + ? 0 + : normalRetries; return warmOnePath(target, { targetUrl: options.targetUrl, timeoutMs, retries, fetchImpl, headers: options.headers, + expectedBuildId: options.expectedBuildId, expectedRscBuildId: options.expectedRscBuildId, - retryAllValidationErrors: isPropagationRequest, + retryPropagationFailures: isPropagationRequest, retryDelayMs: isPropagationRequest ? propagationRetryDelayMs : normalRetryDelayMs, retryNotFound: isPropagationRequest, }); @@ -636,92 +794,53 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise ({ index, result: results[index], target })) - .filter(({ result }) => !result.ok); - if (failed.length === 0 || propagationRetries === 0) return results; + .filter( + ( + entry, + ): entry is { + index: number; + result: { path: string; ok: false; error: string; retryable: boolean }; + target: WarmTarget; + } => !entry.result.ok, + ); + const retryableFailed = failed.filter(({ result }) => result.retryable); + if (retryableFailed.length === 0 || propagationRetries === 0) return results; progress.finish(); console.log( - ` CDN warmup: retrying ${failed.length} failed request(s) after completing the initial pass...`, + ` CDN warmup: retrying ${retryableFailed.length} failed request(s) after completing the initial pass...`, ); const retried = await runWithConcurrency( - failed.map(({ target }) => target), + retryableFailed.map(({ target }) => target), concurrency, (target) => warmRequest(target, "propagation-retry", false), ); - for (const [retryIndex, { index }] of failed.entries()) { + for (const [retryIndex, { index }] of retryableFailed.entries()) { results[index] = retried[retryIndex]; } return results; }; - /* - * The two gates below establish that the uploaded RSC and HTML handlers are - * reachable before the large concurrent pass starts. Remaining requests get - * one attempt first, then only failures consume their retry budget after the - * queue has completed. This lets a large successful queue provide additional - * propagation time without fetching successful cache keys twice. - */ - - const skipRequests = ( - targets: readonly WarmTarget[], - error: string, - ): Array<{ path: string; ok: false; error: string }> => - targets.map((target) => { - progress.update(++completedRequests, requests.length, target.label); - return { path: target.label, ok: false as const, error }; - }); - - const warmAfterHtmlGate = async ( - confirmed: Awaited>[], - remaining: readonly WarmTarget[], - ): Promise>[]> => { - const htmlGateIndex = remaining.findIndex((target) => target.kind === "html"); - if (htmlGateIndex < 0) { - return [...confirmed, ...(await warmPropagatingPass(remaining))]; - } - - const htmlGateResult = await warmRequest(remaining[htmlGateIndex], "propagation-gate"); - const afterHtmlGate = remaining.filter((_target, index) => index !== htmlGateIndex); - if (!htmlGateResult.ok) { - return [ - ...confirmed, - htmlGateResult, - ...skipRequests( - afterHtmlGate, - `skipped because the first HTML request did not reach the uploaded build: ${htmlGateResult.error}`, - ), - ]; - } - return [...confirmed, htmlGateResult, ...(await warmPropagatingPass(afterHtmlGate))]; - }; - - const gateIndex = propagatingTarget ? requests.findIndex((target) => target.kind === "rsc") : -1; let results: Awaited>[]; - if (gateIndex >= 0) { - const gateResult = await warmRequest(requests[gateIndex], "propagation-gate"); - const remaining = requests.filter((_target, index) => index !== gateIndex); - if (gateResult.ok) { - results = await warmAfterHtmlGate([gateResult], remaining); - } else { - results = [ - gateResult, - ...skipRequests( - remaining, - "skipped because the uploaded RSC build identity was not confirmed", - ), - ]; - } - } else if (propagatingTarget) { - results = await warmAfterHtmlGate([], requests); + if (propagatingTarget) { + results = await warmPropagatingPass(requests); } else { results = await runWithConcurrency(requests, concurrency, (target) => warmRequest(target)); } progress.finish(); - const failures = results - .filter((result): result is { path: string; ok: false; error: string } => !result.ok) - .map(({ path, error }) => ({ path, error })); + const failedRequests = requests + .map((target, index) => ({ result: results[index], target })) + .filter( + ( + entry, + ): entry is { + result: { path: string; ok: false; error: string; retryable: boolean }; + target: WarmTarget; + } => !entry.result.ok, + ); + const failures = failedRequests.map(({ result: { path, error } }) => ({ path, error })); const skippedResults = results.filter((result) => result.ok && result.skipped); const warmed = results.length - failures.length - skippedResults.length; @@ -746,6 +865,17 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise target.kind === "rsc-loading-shell") + .map(({ target }) => target.sourcePathname), + paths: failedRequests + .filter(({ target }) => target.kind === "html") + .map(({ target }) => target.sourcePathname), + rscPaths: failedRequests + .filter(({ target }) => target.kind === "rsc-full") + .map(({ target }) => target.sourcePathname), + }, }; if (options.strict && failures.length > 0) { @@ -765,5 +895,11 @@ export async function warmCdnCacheFromPrerender( includeFallbackShells: options.includeFallbackShells, strict: options.strict, }); - return warmCdnCache({ ...options, ...plan }); + const { buildId, ...warmPlan } = plan; + return warmCdnCache({ + ...options, + ...warmPlan, + expectedBuildId: + options.expectedBuildId ?? (options.validateBuildIdentity === false ? undefined : buildId), + }); } diff --git a/packages/cloudflare/src/deploy-help.ts b/packages/cloudflare/src/deploy-help.ts index c62ca24a8..c1fb92edf 100644 --- a/packages/cloudflare/src/deploy-help.ts +++ b/packages/cloudflare/src/deploy-help.ts @@ -27,7 +27,8 @@ export function formatDeployHelp(): string { --warm-cdn-concurrency Maximum number of CDN warmup requests in parallel (default: 25) --warm-cdn-timeout Per-request CDN warmup timeout (default: 10000) - --warm-cdn-retries Retries for transient CDN warmup failures (default: 1) + --warm-cdn-retries Retries per failed CDN warmup request (default: 1; + staged-version propagation default: 60) --warm-cdn-strict Fail deploy when any CDN warmup request fails --warm-cdn-no-promote Leave the warmed Worker version staged at 0% traffic --warm-cdn-promotion-delay diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 93b327d18..29213c0e8 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -31,6 +31,7 @@ import { findVinextPrerenderConfigInPlugins, findVinextRouteRootConfigInPlugins, formatVinextPrerenderLabel, + hasBuildIdentityResponseHeader, hasVerbatimResponseVary, resolveVinextPrerenderDecision, type ResolvedVinextPrerenderConfig, @@ -45,7 +46,12 @@ import { type ProjectInfo, } from "vinext/internal/utils/project"; import { parseWranglerConfig, runTPR } from "./tpr.js"; -import { readPrerenderWarmPlan, warmCdnCache, type CdnWarmOptions } from "./cdn-warm.js"; +import { + readPrerenderWarmPlan, + warmCdnCache, + type CdnWarmOptions, + type CdnWarmRequestPlan, +} from "./cdn-warm.js"; import { formatMissingCacheAdapterError, formatImageOptimizationHint, @@ -554,6 +560,10 @@ export async function runWranglerDeploy( return deployedUrl ?? "(URL not detected in wrangler output)"; } +export function hasCdnWarmRequests(plan: CdnWarmRequestPlan): boolean { + return plan.paths.length + plan.rscPaths.length + plan.loadingShellPaths.length > 0; +} + export async function deployWithCdnWarmup( root: string, paths: readonly string[], @@ -570,23 +580,38 @@ export async function deployWithCdnWarmup( | "warmCdnPromote" | "warmCdnPromotionDelay" > & - Pick, + Pick< + CdnWarmOptions, + "deploymentId" | "expectedBuildId" | "expectedRscBuildId" | "loadingShellPaths" | "rscPaths" + >, ): Promise { + if (options.warmCdnStrict && paths.length > 0 && options.expectedBuildId === undefined) { + throw new Error( + "Strict CDN HTML warmup requires a CDN adapter that declares build-identity response headers. " + + "Configure that adapter capability or rerun without --warm-cdn-strict.", + ); + } const upload = runWranglerVersionUpload(root, options); const warmUploadedVersion = ( targetUrl: string, headers?: HeadersInit, propagatingTarget = false, + plan: CdnWarmRequestPlan = { + loadingShellPaths: [...(options.loadingShellPaths ?? [])], + paths: [...paths], + rscPaths: [...(options.rscPaths ?? [])], + }, ) => warmCdnCache({ targetUrl, - paths, + paths: plan.paths, headers, propagatingTarget, deploymentId: options.deploymentId, + expectedBuildId: options.expectedBuildId, expectedRscBuildId: options.expectedRscBuildId, - loadingShellPaths: options.loadingShellPaths, - rscPaths: options.rscPaths, + loadingShellPaths: plan.loadingShellPaths, + rscPaths: plan.rscPaths, concurrency: options.warmCdnConcurrency, timeoutMs: options.warmCdnTimeout, retries: options.warmCdnRetries, @@ -596,9 +621,20 @@ export async function deployWithCdnWarmup( const wranglerConfig = parseWranglerConfig(root, options.config); const deploymentStatus = readWranglerDeploymentStatus(root, options); const stagingTraffic = getZeroPercentStagingTraffic(deploymentStatus, upload.versionId); + const canVerifyStagedHtml = options.expectedBuildId !== undefined; + if (!canVerifyStagedHtml && paths.length > 0) { + console.warn( + ` CDN warmup: skipping ${paths.length} HTML request(s) because the CDN adapter does not declare build-identity response headers.`, + ); + } let staged: ReturnType | null = null; let triggersDeployedUrl: string | null = null; - let warmedBeforePromotion = false; + let stagedCacheFilled = false; + let remainingWarmPlan: CdnWarmRequestPlan = { + loadingShellPaths: [...(options.loadingShellPaths ?? [])], + paths: canVerifyStagedHtml ? [...paths] : [], + rscPaths: [...(options.rscPaths ?? [])], + }; let triggersApplied = false; function applyTriggers(): void { @@ -623,8 +659,24 @@ export async function deployWithCdnWarmup( const headers = buildVersionOverrideHeaders(workerName, upload.versionId); if (targetUrl && headers) { try { - const warmResult = await warmUploadedVersion(targetUrl, headers, true); - warmedBeforePromotion = warmResult.failed === 0; + const stagedWarmPlan: CdnWarmRequestPlan = { + loadingShellPaths: remainingWarmPlan.loadingShellPaths, + paths: remainingWarmPlan.paths, + rscPaths: remainingWarmPlan.rscPaths, + }; + const stagedWarmRequests = + stagedWarmPlan.paths.length + + stagedWarmPlan.rscPaths.length + + stagedWarmPlan.loadingShellPaths.length; + if (stagedWarmRequests > 0) { + const warmResult = await warmUploadedVersion(targetUrl, headers, true, stagedWarmPlan); + stagedCacheFilled = warmResult.warmed > 0; + remainingWarmPlan = { + loadingShellPaths: warmResult.retryPlan.loadingShellPaths, + paths: warmResult.retryPlan.paths, + rscPaths: warmResult.retryPlan.rscPaths, + }; + } } catch (error) { throw withStagedVersionCleanupNote(error); } @@ -661,7 +713,7 @@ export async function deployWithCdnWarmup( let deployed: ReturnType; try { - if (warmedBeforePromotion) { + if (stagedCacheFilled) { const promotionDelay = options.warmCdnPromotionDelay ?? DEFAULT_CDN_WARM_PROMOTION_DELAY_MS; if (promotionDelay > 0) { console.log( @@ -674,17 +726,21 @@ export async function deployWithCdnWarmup( root, [{ versionId: upload.versionId, percentage: 100 }], options, - warmedBeforePromotion ? "promote-warmed" : "promote-uploaded", + stagedCacheFilled ? "promote-warmed" : "promote-uploaded", ); } catch (error) { throw staged ? withStagedVersionCleanupNote(error) : error; } - if (!warmedBeforePromotion) { - try { - applyTriggers(); - } catch (error) { - throw withPromotedVersionTriggerNote(error); - } + try { + applyTriggers(); + } catch (error) { + throw withPromotedVersionTriggerNote(error); + } + const remainingWarmRequests = + remainingWarmPlan.paths.length + + remainingWarmPlan.rscPaths.length + + remainingWarmPlan.loadingShellPaths.length; + if (remainingWarmRequests > 0) { const targetUrl = resolveCdnWarmupTargetUrl( root, deployed.deployedUrl ?? triggersDeployedUrl, @@ -692,7 +748,7 @@ export async function deployWithCdnWarmup( ); if (targetUrl) { try { - await warmUploadedVersion(targetUrl, undefined, true); + await warmUploadedVersion(targetUrl, undefined, true, remainingWarmPlan); } catch (error) { throw withPromotedVersionWarmupNote(error); } @@ -731,7 +787,7 @@ export function resolveCdnWarmupTargetUrl( ): string | null { const config = parseWranglerConfig(root, options?.config); const env = getWranglerTargetEnv(options ?? {}); - const customDomain = (env ? config?.env?.[env]?.customDomain : undefined) ?? config?.customDomain; + const customDomain = env ? config?.env?.[env]?.customDomain : config?.customDomain; if (customDomain) { return `https://${customDomain}`; } @@ -940,6 +996,7 @@ export async function deploy(options: DeployOptions): Promise { nextOutput: nextConfig.output, }); const hasStrictResponseVary = hasVerbatimResponseVary(viteConfigMetadata.cacheConfig); + const hasBuildIdentityHeader = hasBuildIdentityResponseHeader(viteConfigMetadata.cacheConfig); const shouldEmitPrerenderPathManifest = options.warmCdnCache || (!options.skipBuild && prerenderDecision); @@ -1021,10 +1078,11 @@ export async function deploy(options: DeployOptions): Promise { includeFallbackShells: options.warmCdnIncludeFallbacks, strict: options.warmCdnStrict, }); - if (warmPlan.paths.length > 0) { + if (hasCdnWarmRequests(warmPlan)) { url = await deployWithCdnWarmup(root, warmPlan.paths, { ...wranglerOptions, deploymentId: warmPlan.deploymentId, + expectedBuildId: hasBuildIdentityHeader ? warmPlan.buildId : undefined, expectedRscBuildId: warmPlan.rscBuildId, loadingShellPaths: warmPlan.loadingShellPaths, rscPaths: warmPlan.rscPaths, diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 5128b9eb7..0324ba5ec 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -6,8 +6,8 @@ import { resolveNextConfig, type ResolvedNextConfig, } from "../config/next-config.js"; -import { appRouter } from "../routing/app-router.js"; -import { apiRouter, pagesRouter } from "../routing/pages-router.js"; +import { appRouter, matchAppRoute } from "../routing/app-router.js"; +import { apiRouter, matchRoute, pagesRouter } from "../routing/pages-router.js"; import { normalizeStaticPathsEntry, type StaticPathsEntry } from "../routing/route-pattern.js"; import { getAppRouteRenderEntryPath, @@ -24,6 +24,8 @@ import { VINEXT_PRERENDER_SECRET_HEADER } from "../server/headers.js"; import type { VinextRouteRootConfig } from "../config/prerender.js"; import { enterPrerenderPhase } from "./prerender-phase.js"; import type { CdnCacheAdapterCapabilities } from "../cache/cache-adapters-virtual.js"; +import { pagesRouteHasPriorityOverAppRoute } from "../server/hybrid-route-priority.js"; +import { extractLocaleFromUrl } from "../server/pages-i18n.js"; export type PrerenderPathManifest = { basePath?: string; @@ -168,6 +170,18 @@ function appRouteMayHaveGenerateStaticParams(route: Awaited fileHasNamedExport(layoutPath, "generateStaticParams")); } +function appRouteHasMainTreeLoadingBoundary( + route: Awaited>[number], +): boolean { + return ( + route.loadingPath != null || + (route.loadingPaths?.some( + (_loadingPath, index) => (route.loadingTreePositions?.[index] ?? 0) > 0, + ) ?? + false) + ); +} + async function shouldStartPathDiscoveryServer(options: { appDir: string | null; pagesDir: string | null; @@ -314,12 +328,7 @@ async function collectAppPaths(options: { const { type } = classifyAppRoute(renderEntryPath, route.routePath, route.isDynamic); if (type === "api") continue; - const hasMainTreeLoadingBoundary = - route.loadingPath != null || - (route.loadingPaths?.some( - (_loadingPath, index) => (route.loadingTreePositions?.[index] ?? 0) > 0, - ) ?? - false); + const hasMainTreeLoadingBoundary = appRouteHasMainTreeLoadingBoundary(route); const addDiscoveredPath = (pathname: string): void => { addPath(paths, seen, pathname); if (hasMainTreeLoadingBoundary) { @@ -373,6 +382,58 @@ async function collectAppPaths(options: { return { loadingShellPaths, paths }; } +async function resolveAppRscWarmPaths(options: { + appDir: string; + i18n: ResolvedNextConfig["i18n"]; + pagesDir: string | null; + pageExtensions: readonly string[]; + paths: readonly string[]; +}): Promise<{ loadingShellPaths: string[]; rscPaths: string[] }> { + const appRoutes = await appRouter(options.appDir, options.pageExtensions); + const [pageRoutes, apiRoutes] = options.pagesDir + ? await Promise.all([ + pagesRouter(options.pagesDir, options.pageExtensions), + apiRouter(options.pagesDir, options.pageExtensions), + ]) + : [[], []]; + + const rscPaths: string[] = []; + const loadingShellPaths: string[] = []; + for (const pathname of options.paths) { + const appMatch = matchAppRoute(pathname, appRoutes); + if (!appMatch) continue; + // The trie returns the exact object from appRoutes. Its public matcher type + // 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]; + + const appRenderEntryPath = getAppRouteRenderEntryPath(matchedAppRoute); + if (!appRenderEntryPath) continue; + + // Pages Router i18n prefixes are routing metadata rather than part of the + // filesystem route. Production strips them before matching Pages/API + // routes, while the App Router still matches the original pathname. + const pagesPathname = options.i18n + ? extractLocaleFromUrl(pathname, options.i18n).url + : pathname; + // The App-to-Pages production bridge selects the API handler from the raw + // request pathname before the Pages matcher strips i18n metadata. A + // locale-prefixed `/fr/api/*` path therefore remains a page candidate, + // rather than becoming a Pages API request after normalization. + const isPagesApiRequest = pathname === "/api" || pathname.startsWith("/api/"); + const pagesMatch = matchRoute(pagesPathname, isPagesApiRequest ? apiRoutes : pageRoutes); + if (pagesMatch && pagesRouteHasPriorityOverAppRoute(pagesMatch.route, matchedAppRoute)) { + continue; + } + + rscPaths.push(pathname); + if (appRouteHasMainTreeLoadingBoundary(matchedAppRoute)) { + loadingShellPaths.push(pathname); + } + } + return { loadingShellPaths, rscPaths }; +} + async function startPathDiscoveryServer(options: { serverDir: string; pagesBundlePath?: string; @@ -491,6 +552,20 @@ export async function emitPrerenderPathManifest( } }); + const appOwnedWarmPaths = + options.responseVary && appDir + ? await resolveAppRscWarmPaths({ + appDir, + i18n: config.i18n, + pagesDir, + pageExtensions: config.pageExtensions, + paths: discoveredAppPaths, + }) + : { + loadingShellPaths: discoveredLoadingShellPaths, + rscPaths: discoveredAppPaths, + }; + const manifest: PrerenderPathManifest = { ...(config.basePath ? { basePath: config.basePath } : {}), buildId: config.buildId, @@ -498,8 +573,8 @@ export async function emitPrerenderPathManifest( ...(pagesDir ? { pagesPaths: discoveredPagesPaths } : {}), ...(rscBuildId ? { rscBuildId } : {}), ...(options.responseVary ? { responseVary: options.responseVary } : {}), - ...(options.responseVary ? { rscPaths: discoveredAppPaths } : {}), - ...(options.responseVary ? { loadingShellPaths: discoveredLoadingShellPaths } : {}), + ...(options.responseVary ? { rscPaths: appOwnedWarmPaths.rscPaths } : {}), + ...(options.responseVary ? { loadingShellPaths: appOwnedWarmPaths.loadingShellPaths } : {}), trailingSlash: config.trailingSlash, paths, }; diff --git a/packages/vinext/src/cache/cache-adapters-virtual.ts b/packages/vinext/src/cache/cache-adapters-virtual.ts index c89d135bc..a6a325d89 100644 --- a/packages/vinext/src/cache/cache-adapters-virtual.ts +++ b/packages/vinext/src/cache/cache-adapters-virtual.ts @@ -24,6 +24,15 @@ import { flattenPluginOptions } from "../utils/plugin-options.js"; * registration module and forwarded to the adapter factory at runtime. */ export type CdnCacheAdapterCapabilities = { + /** + * Page responses include the current application build identity in the + * framework-owned `X-Vinext-Build-Id` response header, including responses + * that are ultimately marked non-cacheable. + * + * Deploy adapters use this guarantee to distinguish the newly uploaded + * Worker from an older version while staged traffic is propagating. + */ + buildIdentity?: "response-header"; /** * The shared cache selects response variants using every request header * named by `Vary`, comparing the header values verbatim. @@ -50,6 +59,10 @@ export function hasVerbatimResponseVary(cache?: VinextCacheConfig | null): boole return cache?.cdn?.capabilities?.responseVary === "verbatim"; } +export function hasBuildIdentityResponseHeader(cache?: VinextCacheConfig | null): boolean { + return cache?.cdn?.capabilities?.buildIdentity === "response-header"; +} + /** * The `cache` option of the vinext() plugin: declaratively register cache * handlers instead of calling `setDataCacheHandler()` / `setCdnCacheAdapter()` diff --git a/packages/vinext/src/config/prerender.ts b/packages/vinext/src/config/prerender.ts index 686f16bec..1e810c903 100644 --- a/packages/vinext/src/config/prerender.ts +++ b/packages/vinext/src/config/prerender.ts @@ -3,6 +3,7 @@ import { flattenPluginOptions } from "../utils/plugin-options.js"; import { isUnknownRecord } from "../utils/record.js"; export { findVinextCacheConfigInPlugins, + hasBuildIdentityResponseHeader, hasVerbatimResponseVary, loadVinextCacheConfigFromViteConfig, VINEXT_CACHE_CONFIG_PLUGIN_PROPERTY, diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts index cea798bba..8583432aa 100644 --- a/packages/vinext/src/server/app-router-entry.ts +++ b/packages/vinext/src/server/app-router-entry.ts @@ -32,6 +32,7 @@ import rscHandler, { import { runWithExecutionContext, type ExecutionContextLike } from "vinext/shims/request-context"; // @ts-expect-error -- virtual module resolved by vinext at build time import { registerConfiguredCacheAdapters } from "virtual:vinext-cache-adapters"; +import { applyCdnResponseIdentityHeaders } from "./cache-control.js"; // @ts-expect-error -- virtual module resolved by vinext at build time import { registerConfiguredImageOptimizer } from "virtual:vinext-image-adapters"; import { @@ -82,7 +83,7 @@ export default { env?: WorkerAssetEnv, ctx?: ExecutionContextLike, ): Promise { - return handleRequest(request, env, ctx); + return applyCdnResponseIdentityHeaders(await handleRequest(request, env, ctx), request); }, }; diff --git a/packages/vinext/src/server/cache-control.ts b/packages/vinext/src/server/cache-control.ts index 8be23272a..f767506d2 100644 --- a/packages/vinext/src/server/cache-control.ts +++ b/packages/vinext/src/server/cache-control.ts @@ -75,6 +75,36 @@ export function applyCdnResponseHeaders(headers: Headers, input: CdnCacheableHea } } +/** Apply adapter-owned build identity to an HTML or RSC page response. */ +export function applyCdnResponseIdentityHeaders(response: Response, request: Request): Response { + const accept = request.headers.get("Accept")?.toLowerCase() ?? ""; + if (request.headers.get("RSC") !== "1" && !accept.includes("text/html")) return response; + const map = getCdnCacheAdapter().buildResponseIdentityHeaders?.(); + if (!map || Object.keys(map).length === 0) return response; + + try { + applyResponseHeaderMap(response.headers, map); + return response; + } catch { + // Response.redirect() has immutable headers. Recreate only those responses + // that need adapter identity so the outer runtime boundary can stamp them. + const headers = new Headers(response.headers); + applyResponseHeaderMap(headers, map); + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); + } +} + +function applyResponseHeaderMap(headers: Headers, map: Record): void { + for (const [name, value] of Object.entries(map)) { + if (value === null) headers.delete(name); + else headers.set(name, value); + } +} + /** * Matches Next.js's `getCacheControlHeader` stale window semantics while * preserving vinext's legacy unbounded SWR header when no expire ceiling is diff --git a/packages/vinext/src/server/pages-router-entry.ts b/packages/vinext/src/server/pages-router-entry.ts index de20bc606..ac27fd284 100644 --- a/packages/vinext/src/server/pages-router-entry.ts +++ b/packages/vinext/src/server/pages-router-entry.ts @@ -43,6 +43,7 @@ import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; // @ts-expect-error -- virtual module resolved by vinext at build time import { registerConfiguredCacheAdapters } from "virtual:vinext-cache-adapters"; +import { applyCdnResponseIdentityHeaders } from "./cache-control.js"; // @ts-expect-error -- virtual module resolved by vinext at build time import { registerConfiguredImageOptimizer } from "virtual:vinext-image-adapters"; // @ts-expect-error -- virtual module resolved by vinext at build time @@ -102,7 +103,7 @@ export default { env?: PagesWorkerEnv, ctx?: PagesWorkerExecutionContext, ): Promise { - return handleRequest(request, env, ctx); + return applyCdnResponseIdentityHeaders(await handleRequest(request, env, ctx), request); }, }; diff --git a/packages/vinext/src/shims/cdn-cache.ts b/packages/vinext/src/shims/cdn-cache.ts index dbf875d3c..2693368c2 100644 --- a/packages/vinext/src/shims/cdn-cache.ts +++ b/packages/vinext/src/shims/cdn-cache.ts @@ -130,6 +130,13 @@ export type CdnCacheAdapter = { */ buildResponseHeaders(input: CdnCacheableHeaderInput): CdnResponseHeaders; + /** + * Build adapter-owned headers that identify the deployed application build. + * Core applies these at the outer page-response boundary, including response + * paths that do not pass through cache-policy finalization. + */ + buildResponseIdentityHeaders?(): CdnResponseHeaders; + /** * Whether existing response headers explicitly opt out of storage. Adapters * that split browser and provider cache policy should implement this so they diff --git a/tests/cache-adapters-config.test.ts b/tests/cache-adapters-config.test.ts index 7ade2e268..291c8f53c 100644 --- a/tests/cache-adapters-config.test.ts +++ b/tests/cache-adapters-config.test.ts @@ -16,6 +16,7 @@ import { findVinextCacheConfigInPlugins, loadVinextCacheConfigFromViteConfig, generateCacheAdaptersModule, + hasBuildIdentityResponseHeader, hasVerbatimResponseVary, VINEXT_CACHE_CONFIG_PLUGIN_PROPERTY, VIRTUAL_CACHE_ADAPTERS, @@ -277,8 +278,13 @@ describe("cdnAdapter builder + factory", () => { expect(path.isAbsolute(descriptor.adapter)).toBe(true); expect(descriptor.adapter.endsWith("cdn-adapter.runtime.js")).toBe(true); expect(descriptor.options).toBeUndefined(); - expect(descriptor.capabilities).toEqual({ responseVary: "verbatim" }); + expect(descriptor.capabilities).toEqual({ + buildIdentity: "response-header", + responseVary: "verbatim", + }); + expect(hasBuildIdentityResponseHeader({ cdn: descriptor })).toBe(true); expect(hasVerbatimResponseVary({ cdn: descriptor })).toBe(true); + expect(hasBuildIdentityResponseHeader({ cdn: { adapter: "custom-cache" } })).toBe(false); expect(hasVerbatimResponseVary({ cdn: { adapter: "url-only-cache" } })).toBe(false); }); diff --git a/tests/cloudflare-cdn-cache.test.ts b/tests/cloudflare-cdn-cache.test.ts index a995002df..9d2f37a13 100644 --- a/tests/cloudflare-cdn-cache.test.ts +++ b/tests/cloudflare-cdn-cache.test.ts @@ -21,7 +21,9 @@ import { finalizeAppPageRscCacheResponse, } from "../packages/vinext/src/server/app-page-cache-finalizer.js"; import { finalizeAppRscResponse } from "../packages/vinext/src/server/app-rsc-response-finalizer.js"; +import { applyCdnResponseIdentityHeaders } from "../packages/vinext/src/server/cache-control.js"; import type { RequestContext } from "../packages/vinext/src/config/request-context.js"; +import { VINEXT_CDN_BUILD_ID_HEADER } from "../packages/cloudflare/src/cache/cdn-build-id.js"; const CDN_KEY = Symbol.for("vinext.cdnCacheAdapter"); @@ -68,7 +70,10 @@ function finalizePendingDynamicRscResponse(): Response { } beforeEach(resetActiveAdapter); -afterEach(resetActiveAdapter); +afterEach(() => { + resetActiveAdapter(); + vi.unstubAllEnvs(); +}); // ─── Adapter behavior ──────────────────────────────────────────────────── @@ -79,6 +84,31 @@ describe("CloudflareCdnCacheAdapter", () => { expect(adapter.ownsBackgroundRevalidation).toBe(false); }); + it("stamps the application build identity on cacheable and no-store responses", () => { + vi.stubEnv("__VINEXT_BUILD_ID", "build-a"); + + expect(adapter.buildResponseHeaders({ cacheControl: "s-maxage=60" })).toMatchObject({ + [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", + }); + expect(adapter.buildResponseHeaders({ cacheControl: "no-store" })).toMatchObject({ + [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", + }); + }); + + it("stamps build identity at the outer response boundary, including redirects", () => { + vi.stubEnv("__VINEXT_BUILD_ID", "build-a"); + setCdnCacheAdapter(adapter); + + const response = applyCdnResponseIdentityHeaders( + Response.redirect("https://example.com/target", 307), + new Request("https://example.com/source", { headers: { Accept: "text/html" } }), + ); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("https://example.com/target"); + expect(response.headers.get(VINEXT_CDN_BUILD_ID_HEADER)).toBe("build-a"); + }); + it("get returns null so the origin always renders fresh", async () => { expect(await adapter.get()).toBeNull(); }); diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 7e6835ccd..4f1ef458b 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -6,6 +6,7 @@ import { VINEXT_RSC_BUILD_ID_HEADER, VINEXT_RSC_VARY_HEADER, } from "../packages/vinext/src/server/app-rsc-cache-busting.js"; +import { VINEXT_CDN_BUILD_ID_HEADER } from "../packages/cloudflare/src/cache/cdn-build-id.js"; const execFileSyncMock = vi.hoisted(() => vi.fn()); const delayMock = vi.hoisted(() => vi.fn()); @@ -42,7 +43,24 @@ function formatFetchUrl(url: Parameters[0]): string { function cacheableHtml(body = "ok"): Response { return new Response(body, { - headers: { "cf-cache-status": "MISS", "content-type": "text/html" }, + headers: { + "cf-cache-status": "MISS", + "content-type": "text/html", + [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a", + }, + }); +} + +function cacheableRsc(body = "flight"): Response { + return new Response(body, { + headers: { + "cache-control": "public, max-age=0, must-revalidate", + "cdn-cache-control": "public, max-age=60", + "cf-cache-status": "MISS", + "content-type": "text/x-component", + [VINEXT_RSC_BUILD_ID_HEADER]: "app-build-a", + vary: VINEXT_RSC_VARY_HEADER, + }, }); } @@ -62,6 +80,18 @@ describe("Cloudflare CDN warmup deploy flow", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + it("recognizes warm plans that contain only canonical RSC requests", async () => { + const { hasCdnWarmRequests } = await import("../packages/cloudflare/src/deploy.js"); + + expect(hasCdnWarmRequests({ loadingShellPaths: [], paths: [], rscPaths: ["/dashboard"] })).toBe( + true, + ); + expect(hasCdnWarmRequests({ loadingShellPaths: ["/dashboard"], paths: [], rscPaths: [] })).toBe( + true, + ); + expect(hasCdnWarmRequests({ loadingShellPaths: [], paths: [], rscPaths: [] })).toBe(false); + }); + it("warms the production custom domain through a 0% staged version override", async () => { const events: string[] = []; delayMock.mockImplementation(async (milliseconds: number) => { @@ -83,12 +113,18 @@ describe("Cloudflare CDN warmup deploy flow", () => { headers: isRsc ? { "cache-control": "public, max-age=0, must-revalidate", + "cdn-cache-control": "public, max-age=60", "cf-cache-status": "MISS", "content-type": "text/x-component", + [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a", [VINEXT_RSC_BUILD_ID_HEADER]: "build-a", vary: VINEXT_RSC_VARY_HEADER, } - : { "cf-cache-status": "MISS", "content-type": "text/html" }, + : { + "cf-cache-status": "MISS", + "content-type": "text/html", + [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a", + }, }); }); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { @@ -124,6 +160,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { const url = await deployWithCdnWarmup(tmpDir, ["/", "/about"], { deploymentId: "dpl_123", + expectedBuildId: "app-build-a", expectedRscBuildId: "build-a", loadingShellPaths: ["/about"], rscPaths: ["/about"], @@ -162,12 +199,12 @@ describe("Cloudflare CDN warmup deploy flow", () => { ); expect(fetch).toHaveBeenNthCalledWith( 2, - new URL("https://app.example.com/"), + new URL("https://app.example.com/about?_rsc=9qLBDIU2NgN178cB"), expect.any(Object), ); expect(fetch).toHaveBeenNthCalledWith( 3, - new URL("https://app.example.com/about?_rsc=9qLBDIU2NgN178cB"), + new URL("https://app.example.com/"), expect.any(Object), ); expect(fetch).toHaveBeenNthCalledWith( @@ -185,18 +222,18 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(rscHeaders.get("next-router-prefetch")).toBeNull(); expect(rscHeaders.get("next-router-state-tree")).toBeNull(); expect(rscHeaders.get("next-url")).toBeNull(); - const firstHtmlHeaders = new Headers(vi.mocked(fetch).mock.calls[1]![1]?.headers); - expect(firstHtmlHeaders.get("Cloudflare-Workers-Version-Overrides")).toBe( - 'my-worker="22222222-2222-4222-8222-222222222222"', - ); - expect(firstHtmlHeaders.get("accept")).toBe("text/html"); - const loadingHeaders = new Headers(vi.mocked(fetch).mock.calls[2]![1]?.headers); + const loadingHeaders = new Headers(vi.mocked(fetch).mock.calls[1]![1]?.headers); expect(loadingHeaders.get("Cloudflare-Workers-Version-Overrides")).toBe( 'my-worker="22222222-2222-4222-8222-222222222222"', ); expect(loadingHeaders.get("next-router-prefetch")).toBe("1"); expect(loadingHeaders.get("next-router-segment-prefetch")).toBe("1"); expect(loadingHeaders.get("x-vinext-rsc-render-mode")).toBe("prefetch-loading-shell"); + const firstHtmlHeaders = new Headers(vi.mocked(fetch).mock.calls[2]![1]?.headers); + expect(firstHtmlHeaders.get("Cloudflare-Workers-Version-Overrides")).toBe( + 'my-worker="22222222-2222-4222-8222-222222222222"', + ); + expect(firstHtmlHeaders.get("accept")).toBe("text/html"); expect(execFileSyncMock).toHaveBeenNthCalledWith( 4, process.execPath, @@ -215,8 +252,8 @@ describe("Cloudflare CDN warmup deploy flow", () => { "stage", "triggers", "fetch:https://app.example.com/about?_rsc", - "fetch:https://app.example.com/", "fetch:https://app.example.com/about?_rsc=9qLBDIU2NgN178cB", + "fetch:https://app.example.com/", "fetch:https://app.example.com/about", "delay:15000", "promote", @@ -225,9 +262,8 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(delayMock).toHaveBeenCalledWith(15_000); }); - it("falls back to post-promotion warming after a non-strict staged failure", async () => { + it("does not replay successful staged fills after a non-strict partial failure", async () => { const events: string[] = []; - let promotedRscAttempts = 0; writeFile( "wrangler.jsonc", JSON.stringify({ name: "my-worker", custom_domains: ["app.example.com"] }), @@ -235,20 +271,26 @@ describe("Cloudflare CDN warmup deploy flow", () => { vi.mocked(fetch).mockImplementation(async (_url, init) => { const headers = new Headers(init?.headers); const isRsc = headers.get("rsc") === "1"; + const isLoadingShell = headers.has("next-router-segment-prefetch"); const staged = headers.has("Cloudflare-Workers-Version-Overrides"); - events.push(`fetch:${staged ? "staged" : "promoted"}:${isRsc ? "rsc" : "html"}`); - if (isRsc && !staged) promotedRscAttempts++; + const kind = isLoadingShell ? "loading" : isRsc ? "rsc" : "html"; + events.push(`fetch:${staged ? "staged" : "promoted"}:${kind}`); return new Response(isRsc ? "flight" : "html", { headers: isRsc ? { "cache-control": "public, max-age=0, must-revalidate", + "cdn-cache-control": "public, max-age=60", "cf-cache-status": "MISS", "content-type": "text/x-component", - [VINEXT_RSC_BUILD_ID_HEADER]: - staged || promotedRscAttempts === 1 ? "old-build" : "new-build", + [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a", + [VINEXT_RSC_BUILD_ID_HEADER]: staged && isLoadingShell ? "old-build" : "new-build", vary: VINEXT_RSC_VARY_HEADER, } - : { "cf-cache-status": "MISS", "content-type": "text/html" }, + : { + "cf-cache-status": "MISS", + "content-type": "text/html", + [VINEXT_CDN_BUILD_ID_HEADER]: "app-build-a", + }, }); }); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { @@ -279,7 +321,9 @@ describe("Cloudflare CDN warmup deploy flow", () => { const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); await deployWithCdnWarmup(tmpDir, ["/about"], { + expectedBuildId: "app-build-a", expectedRscBuildId: "new-build", + loadingShellPaths: ["/about"], rscPaths: ["/about"], warmCdnRetries: 1, }); @@ -290,11 +334,75 @@ describe("Cloudflare CDN warmup deploy flow", () => { "stage", "triggers", "fetch:staged:rsc", - "fetch:staged:rsc", + "fetch:staged:loading", + "fetch:staged:html", + "fetch:staged:loading", + "promote", + "fetch:promoted:loading", + ]); + expect(delayMock).toHaveBeenCalledOnce(); + expect(delayMock).toHaveBeenCalledWith(15_000); + }); + + it("falls back after every staged response comes from the wrong build", async () => { + const events: string[] = []; + writeFile( + "wrangler.jsonc", + JSON.stringify({ name: "my-worker", custom_domains: ["app.example.com"] }), + ); + vi.mocked(fetch).mockImplementation(async (_url, init) => { + const staged = new Headers(init?.headers).has("Cloudflare-Workers-Version-Overrides"); + events.push(`fetch:${staged ? "staged" : "promoted"}`); + return new Response("html", { + headers: { + "cdn-cache-control": "public, max-age=60", + "cf-cache-status": "MISS", + "content-type": "text/html", + [VINEXT_CDN_BUILD_ID_HEADER]: staged ? "old-build" : "new-build", + }, + }); + }); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) { + events.push("upload"); + return "Uploaded version 22222222-2222-4222-8222-222222222222\n"; + } + if (args.includes("status")) { + events.push("status"); + return JSON.stringify({ + versions: [{ version_id: "11111111-1111-4111-8111-111111111111", percentage: 100 }], + }); + } + if (args.includes("11111111-1111-4111-8111-111111111111@100%")) { + events.push("stage"); + return "Staged version\nhttps://app.example.com\n"; + } + if (args.includes("22222222-2222-4222-8222-222222222222@100%")) { + events.push("promote"); + return "Deployed version\nhttps://app.example.com\n"; + } + if (args.includes("triggers")) { + events.push("triggers"); + return "Triggers deployed\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await deployWithCdnWarmup(tmpDir, ["/about"], { + expectedBuildId: "new-build", + warmCdnRetries: 1, + }); + + expect(events).toEqual([ + "upload", + "status", + "stage", + "triggers", + "fetch:staged", + "fetch:staged", "promote", - "fetch:promoted:rsc", - "fetch:promoted:rsc", - "fetch:promoted:html", + "fetch:promoted", ]); expect(delayMock).not.toHaveBeenCalled(); }); @@ -337,6 +445,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { await deployWithCdnWarmup(tmpDir, ["/"], { env: "staging", + expectedBuildId: "app-build-a", warmCdnConcurrency: 1, warmCdnPromotionDelay: 2_500, }); @@ -352,6 +461,67 @@ describe("Cloudflare CDN warmup deploy flow", () => { } }); + it("does not inherit the production custom domain for a named environment", async () => { + writeFile( + "wrangler.jsonc", + JSON.stringify({ + name: "my-worker", + custom_domains: ["app.example.com"], + env: { staging: { name: "my-worker-staging" } }, + }), + ); + const { resolveCdnWarmupTargetUrl } = await import("../packages/cloudflare/src/deploy.js"); + + expect( + resolveCdnWarmupTargetUrl(tmpDir, "https://my-worker-staging.example.workers.dev", { + env: "staging", + }), + ).toBe("https://my-worker-staging.example.workers.dev"); + }); + + it("skips unverifiable HTML warmup for adapters without build identity", async () => { + const events: string[] = []; + writeFile( + "wrangler.jsonc", + JSON.stringify({ name: "my-worker", custom_domains: ["app.example.com"] }), + ); + vi.mocked(fetch).mockImplementation(async (url) => { + events.push(`fetch:${formatFetchUrl(url)}`); + return cacheableHtml(); + }); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) { + events.push("upload"); + return "Uploaded version 22222222-2222-4222-8222-222222222222\n"; + } + if (args.includes("status")) { + events.push("status"); + return JSON.stringify({ + versions: [{ version_id: "11111111-1111-4111-8111-111111111111", percentage: 100 }], + }); + } + if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@0%")) { + events.push("stage"); + return "Staged version\nhttps://stable.example.workers.dev\n"; + } + if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@100%")) { + events.push("promote"); + return "Deployed version\nhttps://stable.example.workers.dev\n"; + } + if (args.includes("triggers")) { + events.push("triggers"); + return "Triggers deployed\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await deployWithCdnWarmup(tmpDir, ["/about"], { warmCdnConcurrency: 1 }); + + expect(events).toEqual(["upload", "status", "stage", "triggers", "promote"]); + expect(fetch).not.toHaveBeenCalled(); + }); + it("applies triggers before post-promotion fallback warmup", async () => { const events: string[] = []; writeFile( @@ -363,7 +533,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { ); vi.mocked(fetch).mockImplementation(async (url) => { events.push(`fetch:${formatFetchUrl(url)}`); - return cacheableHtml(); + return cacheableRsc(); }); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { if (args.includes("upload")) { @@ -392,6 +562,8 @@ describe("Cloudflare CDN warmup deploy flow", () => { const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); await deployWithCdnWarmup(tmpDir, ["/"], { + expectedRscBuildId: "app-build-a", + rscPaths: ["/"], warmCdnConcurrency: 1, }); @@ -400,7 +572,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { "status", "promote", "triggers", - "fetch:https://app.example.com/", + "fetch:https://app.example.com/?_rsc", ]); }); @@ -442,6 +614,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { await expect( deployWithCdnWarmup(tmpDir, ["/about"], { + expectedBuildId: "app-build-a", warmCdnConcurrency: 1, warmCdnPromote: false, }), @@ -457,6 +630,39 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(delayMock).not.toHaveBeenCalled(); }); + it("rejects strict HTML warmup without verifiable build identity before upload", async () => { + writeFile( + "wrangler.jsonc", + JSON.stringify({ name: "my-worker", custom_domains: ["app.example.com"] }), + ); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) { + return "Uploaded version 22222222-2222-4222-8222-222222222222\n"; + } + if (args.includes("status")) { + return JSON.stringify({ + versions: [{ version_id: "11111111-1111-4111-8111-111111111111", percentage: 100 }], + }); + } + if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@0%")) { + return "Staged version\nhttps://stable.example.workers.dev\n"; + } + if (args.includes("triggers")) return "Triggers deployed\n"; + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await expect( + deployWithCdnWarmup(tmpDir, ["/about"], { + warmCdnConcurrency: 1, + warmCdnPromote: false, + warmCdnStrict: true, + }), + ).rejects.toThrow("Strict CDN HTML warmup requires a CDN adapter"); + expect(execFileSyncMock).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + it("replaces a stale 0% version and uses the triggers URL for pre-promotion warmup", async () => { const events: string[] = []; writeFile( @@ -504,6 +710,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); const url = await deployWithCdnWarmup(tmpDir, ["/cached/intro"], { + expectedBuildId: "app-build-a", warmCdnConcurrency: 1, }); @@ -553,6 +760,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); await deployWithCdnWarmup(tmpDir, ["/"], { + expectedBuildId: "app-build-a", name: "cli-worker", warmCdnConcurrency: 1, }); @@ -596,6 +804,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { await expect( deployWithCdnWarmup(tmpDir, ["/"], { + expectedBuildId: "app-build-a", warmCdnConcurrency: 1, warmCdnRetries: 0, warmCdnStrict: true, @@ -734,6 +943,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { await expect( deployWithCdnWarmup(tmpDir, ["/"], { + expectedBuildId: "app-build-a", warmCdnRetries: 0, warmCdnStrict: true, }), @@ -762,9 +972,12 @@ describe("Cloudflare CDN warmup deploy flow", () => { }); const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); - await expect(deployWithCdnWarmup(tmpDir, ["/"], { warmCdnStrict: true })).rejects.toThrow( - "already promoted to 100% and its Worker triggers/routes were updated", - ); + await expect( + deployWithCdnWarmup(tmpDir, ["/"], { + expectedBuildId: "app-build-a", + warmCdnStrict: true, + }), + ).rejects.toThrow("already promoted to 100% and its Worker triggers/routes were updated"); expect(fetch).not.toHaveBeenCalled(); }); }); diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index 639229a4a..fba4bd264 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -14,6 +14,7 @@ import { VINEXT_RSC_BUILD_ID_HEADER, VINEXT_RSC_VARY_HEADER, } from "../packages/vinext/src/server/app-rsc-cache-busting.js"; +import { VINEXT_CDN_BUILD_ID_HEADER } from "../packages/cloudflare/src/cache/cdn-build-id.js"; let tmpDir: string; @@ -30,6 +31,7 @@ function cacheableRsc(body = "flight"): Response { "cdn-cache-control": "public, max-age=60", "cf-cache-status": "MISS", "content-type": "text/x-component", + [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", [VINEXT_RSC_BUILD_ID_HEADER]: "rsc-build-a", vary: VINEXT_RSC_VARY_HEADER, }, @@ -43,6 +45,7 @@ function cacheableHtml(body = "html"): Response { "cdn-cache-control": "public, max-age=60", "cf-cache-status": "MISS", "content-type": "text/html", + [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", }, }); } @@ -93,6 +96,7 @@ describe("Cloudflare CDN warmup", () => { ); expect(readPrerenderWarmPlan(tmpDir)).toEqual({ + buildId: "build-a", deploymentId: "dpl_123", loadingShellPaths: ["/docs/dashboard/"], paths: ["/docs/dashboard/", "/docs/dynamic/", "/docs/pages/"], @@ -115,6 +119,7 @@ describe("Cloudflare CDN warmup", () => { ); expect(readPrerenderWarmPlan(tmpDir)).toEqual({ + buildId: "build-a", loadingShellPaths: [], paths: ["/dashboard"], rscPaths: [], @@ -187,6 +192,7 @@ describe("Cloudflare CDN warmup", () => { const result = await warmCdnCache({ deploymentId: "dpl_123", + expectedBuildId: "build-a", expectedRscBuildId: "rsc-build-a", fetchImpl: fetchImpl as typeof fetch, loadingShellPaths: ["/search?q=x"], @@ -195,7 +201,14 @@ describe("Cloudflare CDN warmup", () => { targetUrl: "https://app.example.com", }); - expect(result).toEqual({ total: 3, warmed: 3, skipped: 0, failed: 0, failures: [] }); + expect(result).toEqual({ + total: 3, + warmed: 3, + skipped: 0, + failed: 0, + failures: [], + retryPlan: { loadingShellPaths: [], paths: [], rscPaths: [] }, + }); expect(fetchImpl).toHaveBeenCalledTimes(3); const fullCall = fetchImpl.mock.calls.find((call) => { const headers = new Headers(call[1]?.headers); @@ -265,10 +278,17 @@ describe("Cloudflare CDN warmup", () => { strict: true, targetUrl: "https://app.example.com", }), - ).resolves.toEqual({ total: 2, warmed: 0, skipped: 2, failed: 0, failures: [] }); + ).resolves.toEqual({ + total: 2, + warmed: 0, + skipped: 2, + failed: 0, + failures: [], + retryPlan: { loadingShellPaths: [], paths: [], rscPaths: [] }, + }); }); - it("fails contradictory cache policy instead of claiming a warm", async () => { + it("uses Cloudflare cache-control precedence when validating admission", async () => { const fetchImpl = vi.fn(async () => { const response = cacheableRsc(); response.headers.set("cache-control", "no-store"); @@ -284,7 +304,217 @@ describe("Cloudflare CDN warmup", () => { strict: true, targetUrl: "https://app.example.com", }), - ).rejects.toThrow("opts out of caching, but CF-Cache-Status is MISS"); + ).resolves.toMatchObject({ warmed: 1, failed: 0 }); + }); + + it("trusts Cloudflare admission when a stripped edge policy overrides downstream no-store", async () => { + const fetchImpl = vi.fn(async () => { + const response = cacheableRsc(); + response.headers.set("cdn-cache-control", "no-store"); + return response; + }); + + await expect( + warmCdnCache({ + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: [], + rscPaths: ["/broken"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).resolves.toMatchObject({ warmed: 1, failed: 0 }); + }); + + it("accepts admitted field-qualified cookie policies and stripped cached cookies", async () => { + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const response = + new Headers(init?.headers).get("rsc") === "1" ? cacheableRsc() : cacheableHtml(); + response.headers.set( + "cdn-cache-control", + 'public, max-age=60, private="set-cookie", no-cache="set-cookie"', + ); + response.headers.set("set-cookie", "session=first-response-only; Path=/"); + return response; + }); + + await expect( + warmCdnCache({ + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: ["/cookie-policy"], + rscPaths: ["/cookie-policy"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).resolves.toMatchObject({ warmed: 2, failed: 0 }); + }); + + it("rejects plain Set-Cookie MISS responses without proof the cookie was stripped", async () => { + const fetchImpl = vi.fn(async () => { + const response = cacheableHtml(); + response.headers.set("set-cookie", "session=uncacheable; Path=/"); + return response; + }); + + await expect( + warmCdnCache({ + fetchImpl: fetchImpl as typeof fetch, + paths: ["/plain-cookie"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).rejects.toThrow("response sets a cookie without an observable field-qualified cache policy"); + }); + + it("requires an exact set-cookie field name in field-qualified cache policy", async () => { + const fetchImpl = vi.fn(async () => { + const response = cacheableHtml(); + response.headers.set( + "cdn-cache-control", + 'public, max-age=60, private="not-set-cookie, x-set-cookie"', + ); + response.headers.set("set-cookie", "session=uncacheable; Path=/"); + return response; + }); + + await expect( + warmCdnCache({ + fetchImpl: fetchImpl as typeof fetch, + paths: ["/substring-cookie-policy"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).rejects.toThrow("response sets a cookie without an observable field-qualified cache policy"); + }); + + it("skips hidden Cloudflare-specific cache opt-outs reported as BYPASS", async () => { + const fetchImpl = vi.fn(async () => { + const response = cacheableRsc(); + response.headers.set("cf-cache-status", "BYPASS"); + return response; + }); + + await expect( + warmCdnCache({ + expectedBuildId: "build-a", + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: [], + rscPaths: ["/hidden-opt-out"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).resolves.toMatchObject({ warmed: 0, skipped: 1, failed: 0 }); + }); + + it("rejects cache statuses that cannot prove a reusable fill", async () => { + const staleFetch = vi.fn(async () => { + const response = cacheableRsc(); + response.headers.set("cf-cache-status", "STALE"); + return response; + }); + await expect( + warmCdnCache({ + expectedRscBuildId: "rsc-build-a", + fetchImpl: staleFetch as typeof fetch, + paths: [], + rscPaths: ["/stale"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).rejects.toThrow("CF-Cache-Status is STALE"); + }); + + it("rejects DYNAMIC when the route response itself is cacheable", async () => { + const fetchImpl = vi.fn(async () => { + const response = cacheableRsc(); + response.headers.set("cf-cache-status", "DYNAMIC"); + return response; + }); + + await expect( + warmCdnCache({ + expectedBuildId: "build-a", + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: [], + rscPaths: ["/request-ineligible"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).rejects.toThrow("CF-Cache-Status is DYNAMIC"); + }); + + it("skips DYNAMIC only when the route response opts out of caching", async () => { + const fetchImpl = vi.fn( + async () => + new Response("flight", { + headers: { + "cache-control": "no-store", + "cf-cache-status": "DYNAMIC", + "content-type": "text/x-component", + [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", + [VINEXT_RSC_BUILD_ID_HEADER]: "rsc-build-a", + vary: VINEXT_RSC_VARY_HEADER, + }, + }), + ); + + await expect( + warmCdnCache({ + expectedBuildId: "build-a", + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: [], + rscPaths: ["/response-ineligible"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).resolves.toMatchObject({ warmed: 0, skipped: 1, failed: 0 }); + }); + + it("trusts admitted freshness when a higher-priority edge policy is hidden", async () => { + const fetchImpl = vi.fn(async () => { + const response = cacheableRsc(); + response.headers.set("cdn-cache-control", "public, max-age=0, stale-while-revalidate=60"); + return response; + }); + await expect( + warmCdnCache({ + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: [], + rscPaths: ["/immediately-stale"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).resolves.toMatchObject({ warmed: 1, failed: 0 }); + }); + + it("skips non-cacheable responses for adapters without build identity", async () => { + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const isRsc = new Headers(init?.headers).get("rsc") === "1"; + return new Response(isRsc ? "flight" : "html", { + headers: { + "cache-control": "no-store", + "cf-cache-status": "BYPASS", + "content-type": isRsc ? "text/x-component" : "text/html", + ...(isRsc ? { [VINEXT_RSC_BUILD_ID_HEADER]: "rsc-build-a" } : {}), + }, + }); + }); + + await expect( + warmCdnCache({ + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: ["/dynamic"], + rscPaths: ["/dynamic"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).resolves.toMatchObject({ warmed: 0, skipped: 2, failed: 0 }); }); it("requires CDN admission evidence for HTML responses", async () => { @@ -300,7 +530,98 @@ describe("Cloudflare CDN warmup", () => { ).rejects.toThrow("response is missing CF-Cache-Status"); }); - it("retries staged-target failures after the initial queue has completed", async () => { + it("rejects responses rendered by a different application build", async () => { + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const response = + new Headers(init?.headers).get("rsc") === "1" ? cacheableRsc() : cacheableHtml(); + response.headers.set(VINEXT_CDN_BUILD_ID_HEADER, "old-build"); + return response; + }); + + await expect( + warmCdnCache({ + expectedBuildId: "build-a", + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: ["/about"], + propagatingTarget: true, + retries: 0, + rscPaths: ["/about"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).rejects.toThrow(`response ${VINEXT_CDN_BUILD_ID_HEADER} does not match build build-a`); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("retries old-build BYPASS responses before accepting a staged skip", async () => { + const attempts = new Map(); + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const isRsc = new Headers(init?.headers).get("rsc") === "1"; + const kind = isRsc ? "rsc" : "html"; + const attempt = (attempts.get(kind) ?? 0) + 1; + attempts.set(kind, attempt); + if (attempt === 1) { + return new Response(isRsc ? "old-flight" : "old-html", { + headers: { + "cache-control": "no-store", + "cf-cache-status": "BYPASS", + "content-type": isRsc ? "text/x-component" : "text/html", + [VINEXT_CDN_BUILD_ID_HEADER]: "old-build", + ...(isRsc ? { [VINEXT_RSC_BUILD_ID_HEADER]: "old-rsc-build" } : {}), + }, + }); + } + return isRsc ? cacheableRsc() : cacheableHtml(); + }); + + await expect( + warmCdnCache({ + expectedBuildId: "build-a", + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: ["/became-cacheable"], + propagatingTarget: true, + retries: 1, + retryDelayMs: 0, + rscPaths: ["/became-cacheable"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).resolves.toMatchObject({ warmed: 2, skipped: 0, failed: 0 }); + expect(attempts).toEqual( + new Map([ + ["rsc", 2], + ["html", 2], + ]), + ); + }); + + it("does not retry permanent validation failures from the uploaded build", async () => { + const fetchImpl = vi.fn(async () => { + const response = cacheableRsc(); + response.headers.set("vary", `${VINEXT_RSC_VARY_HEADER}, User-Agent`); + return response; + }); + + await expect( + warmCdnCache({ + expectedBuildId: "build-a", + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: [], + propagatingTarget: true, + retries: 60, + retryDelayMs: 0, + rscPaths: ["/invalid-current-build"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).rejects.toThrow("response Vary has unsupported field user-agent"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("retries first and later staged-target failures only after the initial queue", async () => { const attempts = new Map(); const calls: string[] = []; const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { @@ -310,7 +631,7 @@ describe("Cloudflare CDN warmup", () => { calls.push(key); const attempt = (attempts.get(key) ?? 0) + 1; attempts.set(key, attempt); - if (url.pathname === "/later" && attempt === 1) { + if ((url.pathname === "/first" || url.pathname === "/later") && attempt === 1) { if (!isRsc) return new Response("not propagated", { status: 500 }); const stale = cacheableRsc(); stale.headers.set(VINEXT_RSC_BUILD_ID_HEADER, "stale-rsc-build"); @@ -333,13 +654,15 @@ describe("Cloudflare CDN warmup", () => { targetUrl: "https://app.example.com", }), ).resolves.toMatchObject({ total: 6, warmed: 6, failed: 0 }); - expect(attempts.get("/first:rsc")).toBe(1); - expect(attempts.get("/first:html")).toBe(1); + expect(attempts.get("/first:rsc")).toBe(2); + expect(attempts.get("/first:html")).toBe(2); expect(attempts.get("/later:rsc")).toBe(2); expect(attempts.get("/later:html")).toBe(2); expect(attempts.get("/tail:rsc")).toBe(1); expect(attempts.get("/tail:html")).toBe(1); const lastInitialRequest = Math.max(calls.indexOf("/tail:rsc"), calls.indexOf("/tail:html")); + expect(calls.lastIndexOf("/first:rsc")).toBeGreaterThan(lastInitialRequest); + expect(calls.lastIndexOf("/first:html")).toBeGreaterThan(lastInitialRequest); expect(calls.lastIndexOf("/later:rsc")).toBeGreaterThan(lastInitialRequest); expect(calls.lastIndexOf("/later:html")).toBeGreaterThan(lastInitialRequest); }); @@ -382,6 +705,34 @@ describe("Cloudflare CDN warmup", () => { expect(attempts.get("/later:html")).toBe(2); }); + it("keeps the staged propagation budget independent for each failed key", async () => { + const attempts = new Map(); + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const pathname = new URL(requestHref(input)!).pathname; + const attempt = (attempts.get(pathname) ?? 0) + 1; + attempts.set(pathname, attempt); + if (pathname === "/slow" && attempt <= 30) { + return new Response("not propagated", { status: 404 }); + } + return cacheableRsc(); + }); + + await expect( + warmCdnCache({ + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: [], + propagatingTarget: true, + retryDelayMs: 0, + rscPaths: ["/ready", "/slow"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).resolves.toMatchObject({ total: 2, warmed: 2, failed: 0 }); + expect(attempts.get("/ready")).toBe(1); + expect(attempts.get("/slow")).toBe(31); + }); + it("warms directly from the discovery manifest", async () => { writeFile("dist/server/BUILD_ID", "build-a\n"); writeFile( @@ -398,4 +749,55 @@ describe("Cloudflare CDN warmup", () => { }), ).resolves.toMatchObject({ total: 1, warmed: 1, skipped: 0, failed: 0 }); }); + + it("requires an explicit opt-out for adapters without a build identity header", async () => { + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile( + "dist/server/vinext-prerender-paths.json", + JSON.stringify({ buildId: "build-a", paths: ["/custom-adapter"] }), + ); + const fetchImpl = vi.fn( + async () => + new Response("html", { + headers: { + "cache-control": "public, max-age=0, must-revalidate", + "cdn-cache-control": "public, max-age=60", + "cf-cache-status": "MISS", + "content-type": "text/html", + }, + }), + ); + + await expect( + warmCdnCacheFromPrerender({ + fetchImpl: fetchImpl as typeof fetch, + root: tmpDir, + strict: true, + targetUrl: "https://app.example.com", + validateBuildIdentity: false, + }), + ).resolves.toMatchObject({ total: 1, warmed: 1, failed: 0 }); + }); + + it("uses the discovery manifest build identity by default", async () => { + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile( + "dist/server/vinext-prerender-paths.json", + JSON.stringify({ buildId: "build-a", paths: ["/wrong-build"] }), + ); + const fetchImpl = vi.fn(async () => { + const response = cacheableHtml(); + response.headers.set(VINEXT_CDN_BUILD_ID_HEADER, "old-build"); + return response; + }); + + await expect( + warmCdnCacheFromPrerender({ + fetchImpl: fetchImpl as typeof fetch, + root: tmpDir, + strict: true, + targetUrl: "https://app.example.com", + }), + ).rejects.toThrow(`response ${VINEXT_CDN_BUILD_ID_HEADER} does not match build build-a`); + }); }); diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index ff2d66016..1e5233d3b 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -38,6 +38,29 @@ describe("prerender path manifest", () => { ) { return Response.json([{ slug: "intro" }, { slug: "featured" }]); } + if ( + url.pathname === "/__vinext/prerender/static-params" && + url.searchParams.get("pattern") === "/:path+" + ) { + return Response.json([ + { path: ["pages-dir", "foobar"] }, + { path: ["pages-dir", "static"] }, + { path: ["api", "status"] }, + { path: ["specific", "value"] }, + ]); + } + if ( + url.pathname === "/__vinext/prerender/static-params" && + url.searchParams.get("pattern") === "/:locale/about" + ) { + return Response.json([{ locale: "fr" }]); + } + if ( + url.pathname === "/__vinext/prerender/static-params" && + url.searchParams.get("pattern") === "/:locale/api/status" + ) { + return Response.json([{ locale: "fr" }]); + } return new Response("null", { headers: { "content-type": "application/json" } }); }), ); @@ -135,6 +158,146 @@ describe("prerender path manifest", () => { expect(manifest?.rscBuildId).toBe("rsc-build-a"); }); + it("excludes Pages-owned hybrid paths from App RSC warm discovery", async () => { + // Next.js resolves matching Pages and App routes by cross-router specificity: + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-params/use-params.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/pages-to-app-routing/pages-to-app-routing.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/[...path]/page.tsx", + [ + "export function generateStaticParams() { return []; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile("app/[...path]/loading.tsx", "export default function Loading() { return null; }\n"); + writeFile("app/pages-dir/static/page.tsx", "export default function Page() { return null; }\n"); + writeFile("app/specific/[id]/page.tsx", "export default function Page() { return null; }\n"); + writeFile( + "app/specific/[id]/loading.tsx", + "export default function Loading() { return null; }\n", + ); + writeFile("pages/pages-dir/[dynamic].tsx", "export default function Page() { return null; }\n"); + writeFile( + "pages/api/[slug].ts", + "export default function handler(_request, response) { response.end('ok'); }\n", + ); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual([ + "/pages-dir/static", + "/pages-dir/foobar", + "/api/status", + "/specific/value", + ]); + expect(manifest?.rscPaths).toEqual(["/pages-dir/static", "/specific/value"]); + expect(manifest?.loadingShellPaths).toEqual(["/specific/value"]); + expect(manifest?.pagesPaths).toEqual([]); + }); + + it("uses the runtime-best App route for App-only loading-shell discovery", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/[...path]/page.tsx", + [ + "export function generateStaticParams() { return []; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile("app/pages-dir/static/page.tsx", "export default function Page() { return null; }\n"); + writeFile("app/specific/[id]/page.tsx", "export default function Page() { return null; }\n"); + writeFile( + "app/specific/[id]/loading.tsx", + "export default function Loading() { return null; }\n", + ); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + responseVary: "verbatim", + }); + + expect(manifest?.rscPaths).toEqual([ + "/pages-dir/static", + "/pages-dir/foobar", + "/api/status", + "/specific/value", + ]); + expect(manifest?.loadingShellPaths).toEqual(["/specific/value"]); + }); + + it("normalizes Pages i18n prefixes before resolving hybrid RSC ownership", async () => { + // Next.js normalizes locale prefixes before route matching: + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/base-server.ts + // Locale-prefixed paths do not become Pages API requests after stripping: + // https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-api-support/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( + "app/[locale]/about/page.tsx", + [ + "export function generateStaticParams() { return [{ locale: 'fr' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile( + "app/[locale]/about/loading.tsx", + "export default function Loading() { return null; }\n", + ); + writeFile( + "app/[locale]/api/status/page.tsx", + [ + "export function generateStaticParams() { return [{ locale: 'fr' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile( + "app/[locale]/api/status/loading.tsx", + "export default function Loading() { return null; }\n", + ); + writeFile("pages/about.tsx", "export default function Page() { return null; }\n"); + writeFile( + "pages/api/status.ts", + "export default function handler(_request, response) { response.end('ok'); }\n", + ); + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const nextConfig = await resolveNextConfig( + { i18n: { defaultLocale: "en", locales: ["en", "fr"] } }, + tmpDir, + ); + + const manifest = await emitPrerenderPathManifest({ + nextConfig, + responseVary: "verbatim", + root: tmpDir, + }); + + expect(manifest?.paths).toEqual(["/fr/api/status", "/fr/about", "/about"]); + expect(manifest?.rscPaths).toEqual(["/fr/api/status"]); + expect(manifest?.loadingShellPaths).toEqual(["/fr/api/status"]); + expect(manifest?.pagesPaths).toEqual(["/about"]); + }); + it("skips dynamic warmup paths when static params discovery aborts", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.stubGlobal(