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/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 8019d54a3..955a8ff74 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -52,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 = { @@ -332,10 +334,8 @@ const CDN_CACHE_POLICY_HEADERS = [ "CDN-Cache-Control", "Cache-Control", ] as const; -const SHARED_CACHE_FRESHNESS_RES = [ - /(?:^|,)\s*s-maxage\s*=\s*"?(-?\d+)"?/i, - /(?:^|,)\s*max-age\s*=\s*"?(-?\d+)"?/i, -] as const; +const FIELD_QUALIFIED_SET_COOKIE_RE = + /(?:^|,)\s*(?:private|no-cache)\s*=\s*(?:"[^"]*\bset-cookie\b[^"]*"|[^,]*\bset-cookie\b)/i; type WarmValidation = | { outcome: "warmed" } @@ -357,28 +357,41 @@ function validateCachePolicy(response: Response, requireCacheStatus: boolean): W 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 || !FIELD_QUALIFIED_SET_COOKIE_RE.test(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)) { - return { outcome: "skipped", reason }; - } - return { - outcome: "failed", - error: `${reason}, but CF-Cache-Status is ${cacheStatus ?? "missing"}`, - }; + : hasSetCookie + ? "response sets a cookie" + : `CF-Cache-Status is ${cacheStatus}`; + return { outcome: "skipped", reason }; } - const sharedFreshness = effectivePolicy - ? getSharedCacheFreshnessSeconds(effectivePolicy.value) - : null; - if (sharedFreshness !== null && sharedFreshness <= 0) { - const reason = `${effectivePolicy!.name} has no positive shared-cache freshness`; - if (cacheStatus && NON_CACHEABLE_CF_CACHE_STATUSES.has(cacheStatus)) { - return { outcome: "skipped", reason }; - } + 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"}`, @@ -390,18 +403,7 @@ 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: "warmed" }; -} - -function getSharedCacheFreshnessSeconds(cacheControl: string): number | null { - for (const pattern of SHARED_CACHE_FRESHNESS_RES) { - const match = pattern.exec(cacheControl); - if (match) return Number(match[1]); - } - return null; + return { outcome: "failed", error: `CF-Cache-Status is ${cacheStatus}` }; } function validateBuildIdentity( @@ -445,6 +447,8 @@ function validateRscWarmResponse( 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(",") @@ -459,7 +463,7 @@ function validateRscWarmResponse( if (extraVary) { return { outcome: "failed", error: `response Vary has unsupported field ${extraVary}` }; } - return validateCachePolicy(response, true); + return { outcome: "warmed" }; } function validateHtmlWarmResponse(response: Response, expectedBuildId?: string): WarmValidation { @@ -471,7 +475,9 @@ function validateHtmlWarmResponse(response: Response, expectedBuildId?: string): } const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId); if (buildIdentityValidation) return buildIdentityValidation; - return validateCachePolicy(response, true); + const cachePolicyValidation = validateCachePolicy(response, true); + if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation; + return { outcome: "warmed" }; } async function warmOnePath( @@ -814,6 +820,7 @@ export async function warmCdnCacheFromPrerender( return warmCdnCache({ ...options, ...warmPlan, - expectedBuildId: options.expectedBuildId ?? buildId, + 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 8911e713e..85c079fe7 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, @@ -580,6 +581,12 @@ export async function deployWithCdnWarmup( "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, @@ -610,6 +617,7 @@ 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; let staged: ReturnType | null = null; let triggersDeployedUrl: string | null = null; let stagedCacheFilled = false; @@ -642,9 +650,29 @@ export async function deployWithCdnWarmup( const headers = buildVersionOverrideHeaders(workerName, upload.versionId); if (targetUrl && headers) { try { - const warmResult = await warmUploadedVersion(targetUrl, headers, true); - stagedCacheFilled = warmResult.warmed > 0; - remainingWarmPlan = warmResult.retryPlan; + if (!canVerifyStagedHtml && remainingWarmPlan.paths.length > 0) { + console.log( + ` CDN warmup: deferring ${remainingWarmPlan.paths.length} HTML request(s) until after promotion because the CDN adapter does not declare build-identity response headers.`, + ); + } + const stagedWarmPlan: CdnWarmRequestPlan = { + loadingShellPaths: remainingWarmPlan.loadingShellPaths, + paths: canVerifyStagedHtml ? 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: canVerifyStagedHtml ? warmResult.retryPlan.paths : remainingWarmPlan.paths, + rscPaths: warmResult.retryPlan.rscPaths, + }; + } } catch (error) { throw withStagedVersionCleanupNote(error); } @@ -668,6 +696,12 @@ export async function deployWithCdnWarmup( "The current deployment must have exactly one version serving 100% traffic.", ); } + if (!canVerifyStagedHtml && remainingWarmPlan.paths.length > 0) { + const message = + "CDN warmup cannot verify HTML responses before promotion because the configured CDN adapter " + + "does not declare build-identity response headers."; + console.warn(` ${message} HTML warmup was deferred and promotion is disabled.`); + } console.log( " CDN warmup: promotion disabled; uploaded Worker version remains staged at 0% traffic.", ); @@ -704,6 +738,11 @@ export async function deployWithCdnWarmup( remainingWarmPlan.rscPaths.length + remainingWarmPlan.loadingShellPaths.length; if (remainingWarmRequests > 0) { + if (!canVerifyStagedHtml && remainingWarmPlan.paths.length > 0) { + console.warn( + " CDN warmup: post-promotion HTML warming is best-effort because the CDN adapter does not declare build-identity response headers.", + ); + } try { applyTriggers(); } catch (error) { @@ -964,6 +1003,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); @@ -1049,7 +1089,7 @@ export async function deploy(options: DeployOptions): Promise { url = await deployWithCdnWarmup(root, warmPlan.paths, { ...wranglerOptions, deploymentId: warmPlan.deploymentId, - expectedBuildId: warmPlan.buildId, + 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 39500cbaf..0324ba5ec 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -25,6 +25,7 @@ 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; @@ -383,6 +384,7 @@ async function collectAppPaths(options: { async function resolveAppRscWarmPaths(options: { appDir: string; + i18n: ResolvedNextConfig["i18n"]; pagesDir: string | null; pageExtensions: readonly string[]; paths: readonly string[]; @@ -408,10 +410,18 @@ async function resolveAppRscWarmPaths(options: { const appRenderEntryPath = getAppRouteRenderEntryPath(matchedAppRoute); if (!appRenderEntryPath) continue; - const pagesMatch = matchRoute( - pathname, - pathname === "/api" || pathname.startsWith("/api/") ? apiRoutes : pageRoutes, - ); + // 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; } @@ -546,6 +556,7 @@ export async function emitPrerenderPathManifest( options.responseVary && appDir ? await resolveAppRscWarmPaths({ appDir, + i18n: config.i18n, pagesDir, pageExtensions: config.pageExtensions, paths: discoveredAppPaths, 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/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-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 2600f89d9..7bd4ac101 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -43,7 +43,11 @@ 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", + }, }); } @@ -416,6 +420,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { await deployWithCdnWarmup(tmpDir, ["/"], { env: "staging", + expectedBuildId: "app-build-a", warmCdnConcurrency: 1, warmCdnPromotionDelay: 2_500, }); @@ -449,6 +454,57 @@ describe("Cloudflare CDN warmup deploy flow", () => { ).toBe("https://my-worker-staging.example.workers.dev"); }); + it("defers unverifiable HTML warmup until after promotion", 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", + "fetch:https://app.example.com/about", + ]); + const requestHeaders = new Headers(vi.mocked(fetch).mock.calls[0]![1]?.headers); + expect(requestHeaders.has("Cloudflare-Workers-Version-Overrides")).toBe(false); + }); + it("applies triggers before post-promotion fallback warmup", async () => { const events: string[] = []; writeFile( @@ -539,6 +595,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { await expect( deployWithCdnWarmup(tmpDir, ["/about"], { + expectedBuildId: "app-build-a", warmCdnConcurrency: 1, warmCdnPromote: false, }), @@ -554,6 +611,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( @@ -601,6 +691,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, }); @@ -650,6 +741,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, }); @@ -693,6 +785,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { await expect( deployWithCdnWarmup(tmpDir, ["/"], { + expectedBuildId: "app-build-a", warmCdnConcurrency: 1, warmCdnRetries: 0, warmCdnStrict: true, @@ -831,6 +924,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { await expect( deployWithCdnWarmup(tmpDir, ["/"], { + expectedBuildId: "app-build-a", warmCdnRetries: 0, warmCdnStrict: true, }), @@ -859,9 +953,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 9fd703e10..654b514ef 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -307,7 +307,7 @@ describe("Cloudflare CDN warmup", () => { ).resolves.toMatchObject({ warmed: 1, failed: 0 }); }); - it("fails contradictory effective cache policy instead of claiming a warm", async () => { + 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"); @@ -323,10 +323,71 @@ describe("Cloudflare CDN warmup", () => { strict: true, targetUrl: "https://app.example.com", }), - ).rejects.toThrow("CDN-Cache-Control opts out of caching, but CF-Cache-Status is MISS"); + ).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 stale cache objects and zero-freshness responses", async () => { + 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("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"); @@ -342,24 +403,49 @@ describe("Cloudflare CDN warmup", () => { targetUrl: "https://app.example.com", }), ).rejects.toThrow("CF-Cache-Status is STALE"); + }); - const zeroFreshnessFetch = vi.fn(async () => { + 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"); + response.headers.set("cdn-cache-control", "public, max-age=0, stale-while-revalidate=60"); return response; }); await expect( warmCdnCache({ expectedRscBuildId: "rsc-build-a", - fetchImpl: zeroFreshnessFetch as typeof fetch, + fetchImpl: fetchImpl as typeof fetch, paths: [], rscPaths: ["/immediately-stale"], strict: true, targetUrl: "https://app.example.com", }), - ).rejects.toThrow( - "CDN-Cache-Control has no positive shared-cache freshness, but CF-Cache-Status is MISS", - ); + ).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 () => { @@ -399,6 +485,49 @@ describe("Cloudflare CDN warmup", () => { 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("retries first and later staged-target failures only after the initial queue", async () => { const attempts = new Map(); const calls: string[] = []; @@ -527,4 +656,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 2c95c579f..1e5233d3b 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -49,6 +49,18 @@ describe("prerender path manifest", () => { { 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" } }); }), ); @@ -228,6 +240,64 @@ describe("prerender path manifest", () => { 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(