From e8079d29fe6820ab97e5d39e824ad11ca9e5222b Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 03:16:29 +0100 Subject: [PATCH 1/6] test(cloudflare): prove deployed Pages CDN warmup --- .../workers-cache/pages/pages-prewarm.tsx | 7 ++++++ .../cloudflare-workers/rsc-prewarm.spec.ts | 24 ++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 examples/workers-cache/pages/pages-prewarm.tsx diff --git a/examples/workers-cache/pages/pages-prewarm.tsx b/examples/workers-cache/pages/pages-prewarm.tsx new file mode 100644 index 000000000..644ed3b7e --- /dev/null +++ b/examples/workers-cache/pages/pages-prewarm.tsx @@ -0,0 +1,7 @@ +export async function getStaticProps() { + return { props: {}, revalidate: 300 }; +} + +export default function PagesPrewarmTarget() { + return

Pages prewarm target

; +} diff --git a/tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts b/tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts index 72ea26dd7..04c7fea42 100644 --- a/tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts +++ b/tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts @@ -10,6 +10,7 @@ import { import fs from "node:fs"; const TARGET_PATH = "/prewarm-target"; +const PAGES_TARGET_PATH = "/pages-prewarm"; const LOADING_SHELL_RSC_SEARCH = "?_rsc=9qLBDIU2NgN178cB"; const PROMOTION_STABILITY_WINDOW_MS = 60_000; const PROMOTION_READINESS_TIMEOUT_MS = 120_000; @@ -32,10 +33,10 @@ function rejectStaleSeedWorker(response: Response | null): void { } } -async function getCanonicalRscAfterPromotion( +async function getResponseAfterPromotion( request: APIRequestContext, url: string, - headers: Record, + headers: Record = {}, ): Promise { const deadline = Date.now() + STALE_SEED_RETRY_TIMEOUT_MS; let lastSeedStatus: number | undefined; @@ -51,7 +52,7 @@ async function getCanonicalRscAfterPromotion( } while (Date.now() < deadline); throw new Error( - `stale seed Worker remained reachable for canonical RSC request: ${lastSeedStatus ?? "unknown status"}`, + `stale seed Worker remained reachable for prewarmed request: ${lastSeedStatus ?? "unknown status"}`, ); } @@ -160,7 +161,7 @@ async function waitForStablePromotion({ ); } -test("deploy-prewarmed full and loading RSC variants are reused by browser navigation", async ({ +test("deploy-prewarmed Pages HTML and RSC variants are reused", async ({ baseURL, browser, playwright, @@ -190,7 +191,18 @@ test("deploy-prewarmed full and loading RSC variants are reused by browser navig // without touching either canonical cache entry before its HIT assertion. await waitForStablePromotion({ baseURL, buildId, playwright, rscBuildId }); - const fullResponse = await getCanonicalRscAfterPromotion( + const pagesResponse = await getResponseAfterPromotion(request, `${baseURL}${PAGES_TARGET_PATH}`); + const pagesResponseHeaders = pagesResponse.headers(); + expect(pagesResponse.ok(), JSON.stringify(pagesResponseHeaders)).toBe(true); + expect(pagesResponseHeaders["content-type"]).toContain("text/html"); + expect(pagesResponseHeaders["x-vinext-build-id"]).toBe(rscBuildId); + expect( + pagesResponseHeaders["cf-cache-status"], + `Pages response headers: ${JSON.stringify(pagesResponseHeaders)}`, + ).toBe("HIT"); + expect(await pagesResponse.text()).toContain("Pages prewarm target"); + + const fullResponse = await getResponseAfterPromotion( request, `${baseURL}${TARGET_PATH}?_rsc`, fullHeaders, @@ -207,7 +219,7 @@ test("deploy-prewarmed full and loading RSC variants are reused by browser navig expect(fullBody).toContain(buildId); expect(fullBody).toContain("Prewarm target"); - const shellResponse = await getCanonicalRscAfterPromotion( + const shellResponse = await getResponseAfterPromotion( request, `${baseURL}${TARGET_PATH}${LOADING_SHELL_RSC_SEARCH}`, shellHeaders, From ff55d3f42d6ddfc2df03865fbf5e0a51d0bfa087 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 03:24:09 +0100 Subject: [PATCH 2/6] fix(cloudflare): reject incomplete no-promote warmup --- packages/cloudflare/src/deploy.ts | 16 ++++++--- tests/cloudflare-cdn-warm-deploy.test.ts | 43 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 386938d8c..5b7728fcf 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -727,6 +727,11 @@ export async function deployWithCdnWarmup( ); } + const remainingWarmRequests = + remainingWarmPlan.paths.length + + remainingWarmPlan.rscPaths.length + + remainingWarmPlan.loadingShellPaths.length; + if (options.warmCdnPromote === false) { if (!staged) { throw new Error( @@ -734,6 +739,13 @@ export async function deployWithCdnWarmup( "The current deployment must have exactly one version serving 100% traffic.", ); } + if (remainingWarmRequests > 0) { + throw withStagedVersionCleanupNote( + new Error( + `CDN warmup cannot skip promotion because ${remainingWarmRequests} request(s) remain unwarmed.`, + ), + ); + } console.log( " CDN warmup: promotion disabled; uploaded Worker version remains staged at 0% traffic.", ); @@ -770,10 +782,6 @@ export async function deployWithCdnWarmup( } catch (error) { throw withPromotedVersionTriggerNote(error); } - const remainingWarmRequests = - remainingWarmPlan.paths.length + - remainingWarmPlan.rscPaths.length + - remainingWarmPlan.loadingShellPaths.length; if (remainingWarmRequests > 0) { const targetUrl = resolveCdnWarmupTargetUrl(root, triggersDeployedUrl, options) ?? deployed.deployedUrl; diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index ba09f9532..d49a83cdd 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -881,6 +881,49 @@ describe("Cloudflare CDN warmup deploy flow", () => { ).toBe(false); }); + it("fails no-promote deployment when a staged warm request remains unsuccessful", async () => { + writeFile( + "wrangler.jsonc", + JSON.stringify({ name: "my-worker", custom_domains: ["app.example.com"] }), + ); + vi.mocked(fetch).mockImplementation(async (url) => { + if (isReadinessFetch(url)) return cacheableHtml(); + return new Response("unavailable", { status: 503 }); + }); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) { + return "Uploaded my-worker\nWorker Version ID: 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("22222222-2222-4222-8222-222222222222@0%")) { + return "Staged version\nhttps://app.example.com\n"; + } + if (args.includes("triggers")) { + return "Triggers deployed\n app.example.com (custom domain)\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await expect( + deployWithCdnWarmup(tmpDir, ["/about"], { + expectedBuildId: "app-build-a", + warmCdnPromote: false, + warmCdnRetries: 0, + }), + ).rejects.toThrow("1 request(s) remain unwarmed"); + expect(fetch).toHaveBeenCalledTimes(7); + expect( + (execFileSyncMock.mock.calls as Array<[string, string[]]>).some(([, args]) => + args.includes("22222222-2222-4222-8222-222222222222@100%"), + ), + ).toBe(false); + }); + it("rejects strict HTML warmup without verifiable build identity before upload", async () => { writeFile( "wrangler.jsonc", From 5e4f9aa8010b68e10eea0cf130acd766aa24652b Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 03:25:44 +0100 Subject: [PATCH 3/6] fix(cloudflare): keep warm discovery fail-closed --- packages/cloudflare/src/cdn-warm.ts | 38 ++++++++------ packages/vinext/src/build/prerender-paths.ts | 42 +++++++++++++--- tests/cloudflare-cdn-warm.test.ts | 52 ++++++++++++++++++++ tests/prerender-paths.test.ts | 27 ++++++++++ 4 files changed, 138 insertions(+), 21 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 4b97b3c24..623085fc0 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -516,15 +516,6 @@ function validateRscWarmResponse( expectedBuildId?: string, expectedRscBuildId?: string, ): WarmValidation { - if (response.redirected || response.status < 200 || response.status >= 300) { - return { - outcome: "failed", - error: response.redirected ? "redirected response" : `HTTP ${response.status}`, - }; - } - 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 ( @@ -536,6 +527,19 @@ function validateRscWarmResponse( error: `response ${VINEXT_RSC_BUILD_ID_HEADER} does not match build ${expectedRscBuildId}`, }; } + if (response.redirected) { + return { outcome: "failed", error: "redirected response" }; + } + if (response.status < 200 || response.status >= 300) { + if (expectedBuildId !== undefined || expectedRscBuildId !== undefined) { + const cachePolicyValidation = validateCachePolicy(response, true); + if (cachePolicyValidation.outcome === "skipped") return cachePolicyValidation; + } + return { outcome: "failed", error: `HTTP ${response.status}` }; + } + if (!response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE)) { + return { outcome: "failed", error: `expected ${VINEXT_RSC_CONTENT_TYPE} response` }; + } const cachePolicyValidation = validateCachePolicy(response, true); if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation; const vary = new Set( @@ -556,14 +560,18 @@ function validateRscWarmResponse( } 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}`, - }; - } const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId); if (buildIdentityValidation) return buildIdentityValidation; + if (response.redirected) { + return { outcome: "failed", error: "redirected response" }; + } + if (response.status < 200 || response.status >= 300) { + if (expectedBuildId !== undefined) { + const cachePolicyValidation = validateCachePolicy(response, true); + if (cachePolicyValidation.outcome === "skipped") return cachePolicyValidation; + } + return { outcome: "failed", error: `HTTP ${response.status}` }; + } const cachePolicyValidation = validateCachePolicy(response, true); if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation; const extraVary = (response.headers.get("Vary") ?? "") diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index e2f6ca1dd..50331f341 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -106,6 +106,37 @@ function throwDiscoveryFailure(route: string, error: unknown): never { throw new Error(`Failed to discover warmup path(s) for ${route}: ${message}`, { cause: error }); } +function validatePagesStaticPathsResult( + value: unknown, + route: string, +): { fallback: boolean | "blocking"; paths: StaticPathsEntry[] } { + const expected = "Expected { paths: [], fallback: boolean | 'blocking' }."; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Invalid value returned from getStaticPaths for ${route}. ${expected}`); + } + + const result = value as Record; + const extraKeys = Object.keys(result).filter((key) => key !== "paths" && key !== "fallback"); + if (extraKeys.length > 0) { + throw new Error( + `Extra key(s) returned from getStaticPaths for ${route}: ${extraKeys.join(", ")}. ${expected}`, + ); + } + if (typeof result.fallback !== "boolean" && result.fallback !== "blocking") { + throw new Error(`Invalid fallback returned from getStaticPaths for ${route}. ${expected}`); + } + if (!Array.isArray(result.paths)) { + throw new Error( + `Invalid paths returned from getStaticPaths for ${route}; paths must be an array.`, + ); + } + + return { + fallback: result.fallback, + paths: result.paths as StaticPathsEntry[], + }; +} + async function fetchDiscoveryEndpoint( url: string, headers: Record, @@ -269,13 +300,12 @@ async function collectPagesPaths(options: { `${options.baseUrl}/__vinext/prerender/pages-static-paths?${search}`, options.secretHeaders, ); - if (text === null) continue; + if (text === null) { + throw new Error(`Invalid value returned from getStaticPaths for ${route.pattern}.`); + } - const pathsResult = JSON.parse(text) as { - paths?: Array; - fallback?: unknown; - }; - for (const item of pathsResult.paths ?? []) { + const pathsResult = validatePagesStaticPathsResult(JSON.parse(text), route.pattern); + for (const item of pathsResult.paths) { let itemToNormalize = item; let locale = options.i18n?.defaultLocale; if (options.i18n && typeof item === "string") { diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index d2ae11efa..a71434bcc 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -520,6 +520,58 @@ describe("Cloudflare CDN warmup", () => { ).resolves.toMatchObject({ warmed: 0, skipped: 2, failed: 0 }); }); + it("skips same-build non-success responses that explicitly opt out of caching", 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 not found" : "redirect", { + status: isRsc ? 404 : 307, + headers: { + "cache-control": "no-store", + "cf-cache-status": "BYPASS", + "content-type": isRsc ? "text/x-component" : "text/html", + [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", + ...(isRsc ? { [VINEXT_RSC_BUILD_ID_HEADER]: "rsc-build-a" } : {}), + }, + }); + }); + + await expect( + warmCdnCache({ + expectedBuildId: "build-a", + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: ["/redirect"], + rscPaths: ["/not-found"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).resolves.toMatchObject({ warmed: 0, skipped: 2, failed: 0 }); + }); + + it("does not skip a non-success response from a different build", async () => { + const fetchImpl = vi.fn( + async () => + new Response("redirect", { + status: 307, + headers: { + "cache-control": "no-store", + "cf-cache-status": "BYPASS", + [VINEXT_CDN_BUILD_ID_HEADER]: "old-build", + }, + }), + ); + + await expect( + warmCdnCache({ + expectedBuildId: "build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: ["/redirect"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).rejects.toThrow(`response ${VINEXT_CDN_BUILD_ID_HEADER} does not match build build-a`); + }); + it("requires CDN admission evidence for HTML responses", async () => { const fetchImpl = vi.fn(async () => new Response("html")); diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 385a93865..1d37f7ca3 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -736,6 +736,33 @@ describe("prerender path manifest", () => { ); }); + it.each([ + ["null", null, "Invalid value returned"], + ["missing paths", { fallback: false }, "Invalid paths returned"], + ["null paths", { fallback: false, paths: null }, "Invalid paths returned"], + ["invalid fallback", { fallback: "yes", paths: [] }, "Invalid fallback"], + ["extra key", { extra: true, fallback: false, paths: [] }, "Extra key(s)"], + ])("fails path discovery for %s getStaticPaths results", async (_name, result, message) => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/entry.js", "export default {};\n"); + writeFile( + "pages/posts/[slug].tsx", + [ + "export function getStaticPaths() { return null; }", + "export function getStaticProps() { return { props: {}, revalidate: 60 }; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + vi.mocked(fetch).mockResolvedValue(Response.json(result)); + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + + await expect(emitPrerenderPathManifest({ root: tmpDir })).rejects.toThrow( + `Failed to discover warmup path(s) for /posts/:slug: ${message}`, + ); + }); + it("excludes only the locale-specific Pages key affected by a rewrite", async () => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); From 697f59a077fb010a018999a33312a5500ef68967 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 03:26:09 +0100 Subject: [PATCH 4/6] test(cloudflare): prove prewarmed App HTML reuse --- tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts b/tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts index 04c7fea42..cfeb4eb86 100644 --- a/tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts +++ b/tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts @@ -202,6 +202,17 @@ test("deploy-prewarmed Pages HTML and RSC variants are reused", async ({ ).toBe("HIT"); expect(await pagesResponse.text()).toContain("Pages prewarm target"); + const appHtmlResponse = await getResponseAfterPromotion(request, `${baseURL}${TARGET_PATH}`); + const appHtmlResponseHeaders = appHtmlResponse.headers(); + expect(appHtmlResponse.ok(), JSON.stringify(appHtmlResponseHeaders)).toBe(true); + expect(appHtmlResponseHeaders["content-type"]).toContain("text/html"); + expect(appHtmlResponseHeaders["x-vinext-build-id"]).toBe(rscBuildId); + expect( + appHtmlResponseHeaders["cf-cache-status"], + `App HTML response headers: ${JSON.stringify(appHtmlResponseHeaders)}`, + ).toBe("HIT"); + expect(await appHtmlResponse.text()).toContain("Prewarm target"); + const fullResponse = await getResponseAfterPromotion( request, `${baseURL}${TARGET_PATH}?_rsc`, From d7794d6472a180f7be5c1b7743fb1db70e4d164e Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 03:33:08 +0100 Subject: [PATCH 5/6] fix(cloudflare): enforce no-promote warmup contract --- packages/cloudflare/src/deploy.ts | 15 +++++++++++++++ tests/cloudflare-cdn-warm-deploy.test.ts | 12 ++++++++++++ tests/deploy-prerender-config.test.ts | 15 +++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 5b7728fcf..a161f4240 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -592,6 +592,16 @@ export async function deployWithCdnWarmup( "Configure that adapter capability or rerun without --warm-cdn-strict.", ); } + if ( + options.warmCdnPromote === false && + paths.length > 0 && + options.expectedBuildId === undefined + ) { + throw new Error( + "CDN warmup cannot skip promotion because the discovered HTML requests cannot be verified. " + + "Configure a CDN adapter that declares build-identity response headers.", + ); + } const upload = runWranglerVersionUpload(root, options); const warmUploadedVersion = ( targetUrl: string, @@ -1116,6 +1126,11 @@ export async function deploy(options: DeployOptions): Promise { warmCdnPromotionDelay: options.warmCdnPromotionDelay, }); } else { + if (options.warmCdnPromote === false) { + throw new Error( + "CDN warmup cannot skip promotion because no build-discovered requests were found to warm.", + ); + } console.log("\n CDN warmup skipped: no build-discovered paths found."); url = await runWranglerDeploy(root, wranglerOptions); } diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index d49a83cdd..14089861c 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -724,6 +724,18 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(fetch).not.toHaveBeenCalled(); }); + it("rejects no-promote HTML warmup without verifiable build identity before upload", async () => { + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await expect( + deployWithCdnWarmup(tmpDir, ["/about"], { + warmCdnPromote: false, + }), + ).rejects.toThrow("discovered HTML requests cannot be verified"); + expect(execFileSyncMock).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + it("applies triggers before post-promotion fallback warmup", async () => { const events: string[] = []; writeFile( diff --git a/tests/deploy-prerender-config.test.ts b/tests/deploy-prerender-config.test.ts index db9f1d46b..50642fabf 100644 --- a/tests/deploy-prerender-config.test.ts +++ b/tests/deploy-prerender-config.test.ts @@ -414,4 +414,19 @@ describe("deploy prerender config wiring", () => { "deploy", ]); }); + + it("rejects no-promote warmup when discovery finds no requests", async () => { + writeApiOnlyProject(); + const { deploy } = await import("../packages/cloudflare/src/deploy.js"); + + await expect( + deploy({ + root: tmpDir, + skipBuild: true, + warmCdnCache: true, + warmCdnPromote: false, + }), + ).rejects.toThrow("no build-discovered requests were found to warm"); + expect(spawn).not.toHaveBeenCalled(); + }); }); From fa424bc65d8dfe6e995ca2a17db9b9447183ed8c Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 03:35:52 +0100 Subject: [PATCH 6/6] fix(cloudflare): warm terminal ISR responses --- packages/cloudflare/src/cdn-warm.ts | 57 ++++++++++------ tests/cloudflare-cdn-warm.test.ts | 102 ++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 21 deletions(-) diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 623085fc0..c16af5fe3 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -511,6 +511,10 @@ function validateBuildIdentity( return null; } +function isExpectedTerminalStatus(status: number): boolean { + return (status >= 300 && status < 400) || status === 404; +} + function validateRscWarmResponse( response: Response, expectedBuildId?: string, @@ -530,18 +534,27 @@ function validateRscWarmResponse( if (response.redirected) { return { outcome: "failed", error: "redirected response" }; } - if (response.status < 200 || response.status >= 300) { - if (expectedBuildId !== undefined || expectedRscBuildId !== undefined) { - const cachePolicyValidation = validateCachePolicy(response, true); - if (cachePolicyValidation.outcome === "skipped") return cachePolicyValidation; + const terminalResponse = response.status < 200 || response.status >= 300; + if (terminalResponse) { + if ( + !isExpectedTerminalStatus(response.status) || + (expectedBuildId === undefined && expectedRscBuildId === undefined) + ) { + return { outcome: "failed", error: `HTTP ${response.status}` }; } - return { outcome: "failed", error: `HTTP ${response.status}` }; - } - if (!response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE)) { + } else if ( + !response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE) + ) { return { outcome: "failed", error: `expected ${VINEXT_RSC_CONTENT_TYPE} response` }; } const cachePolicyValidation = validateCachePolicy(response, true); if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation; + if ( + terminalResponse && + !response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE) + ) { + return { outcome: "failed", error: `expected ${VINEXT_RSC_CONTENT_TYPE} response` }; + } const vary = new Set( (response.headers.get("Vary") ?? "") .split(",") @@ -565,12 +578,11 @@ function validateHtmlWarmResponse(response: Response, expectedBuildId?: string): if (response.redirected) { return { outcome: "failed", error: "redirected response" }; } - if (response.status < 200 || response.status >= 300) { - if (expectedBuildId !== undefined) { - const cachePolicyValidation = validateCachePolicy(response, true); - if (cachePolicyValidation.outcome === "skipped") return cachePolicyValidation; + const terminalResponse = response.status < 200 || response.status >= 300; + if (terminalResponse) { + if (!isExpectedTerminalStatus(response.status) || expectedBuildId === undefined) { + return { outcome: "failed", error: `HTTP ${response.status}` }; } - return { outcome: "failed", error: `HTTP ${response.status}` }; } const cachePolicyValidation = validateCachePolicy(response, true); if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation; @@ -590,15 +602,6 @@ function validateReadinessResponse( expectedBuildId?: string, expectedRscBuildId?: string, ): string | null { - if (response.redirected || response.status < 200 || response.status >= 300) { - return response.redirected ? "redirected response" : `HTTP ${response.status}`; - } - if ( - kind === "rsc" && - !response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE) - ) { - return `expected ${VINEXT_RSC_CONTENT_TYPE} response`; - } const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId); if (buildIdentityValidation?.outcome === "failed") return buildIdentityValidation.error; if ( @@ -608,6 +611,18 @@ function validateReadinessResponse( ) { return `response ${VINEXT_RSC_BUILD_ID_HEADER} does not match build ${expectedRscBuildId}`; } + if (response.redirected) return "redirected response"; + if ( + response.status >= 200 && + response.status < 300 && + kind === "rsc" && + !response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE) + ) { + return `expected ${VINEXT_RSC_CONTENT_TYPE} response`; + } + // Readiness proves only that version overrides consistently reach the + // uploaded build. The real warm pass validates status, representation, and + // cache admission for every untouched cache key. return null; } diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index a71434bcc..e8311dda3 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -7,6 +7,7 @@ import { DEFAULT_CDN_WARM_CONCURRENCY, DEFAULT_CDN_WARM_TIMEOUT_MS, readPrerenderWarmPlan, + waitForCdnWarmTargetReadiness, warmCdnCache, warmCdnCacheFromPrerender, } from "../packages/cloudflare/src/cdn-warm.js"; @@ -531,6 +532,7 @@ describe("Cloudflare CDN warmup", () => { "content-type": isRsc ? "text/x-component" : "text/html", [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", ...(isRsc ? { [VINEXT_RSC_BUILD_ID_HEADER]: "rsc-build-a" } : {}), + ...(isRsc ? { vary: VINEXT_RSC_VARY_HEADER } : {}), }, }); }); @@ -548,6 +550,106 @@ describe("Cloudflare CDN warmup", () => { ).resolves.toMatchObject({ warmed: 0, skipped: 2, failed: 0 }); }); + it("warms same-build cacheable redirect and not-found responses", 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 not found" : "redirect", { + status: isRsc ? 404 : 307, + headers: { + "cache-control": "public, max-age=0, must-revalidate", + "cdn-cache-control": "public, max-age=60", + "cf-cache-status": "MISS", + "content-type": isRsc ? "text/x-component" : "text/html", + [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", + ...(isRsc ? { [VINEXT_RSC_BUILD_ID_HEADER]: "rsc-build-a" } : {}), + ...(isRsc ? { vary: VINEXT_RSC_VARY_HEADER } : {}), + }, + }); + }); + + await expect( + warmCdnCache({ + expectedBuildId: "build-a", + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: ["/redirect"], + rscPaths: ["/not-found"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).resolves.toMatchObject({ warmed: 2, skipped: 0, failed: 0 }); + }); + + it("requires browser-reusable variance for cacheable terminal responses", async () => { + const fetchImpl = vi.fn(async () => { + const response = cacheableRsc("flight not found"); + response.headers.set("vary", `${VINEXT_RSC_VARY_HEADER}, User-Agent`); + return new Response(response.body, { headers: response.headers, status: 404 }); + }); + + await expect( + warmCdnCache({ + expectedBuildId: "build-a", + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: [], + rscPaths: ["/not-found"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).rejects.toThrow("response Vary has unsupported field user-agent"); + }); + + it("does not treat same-build server errors as terminal route responses", async () => { + const fetchImpl = vi.fn( + async () => + new Response("error", { + status: 500, + headers: { + "cache-control": "no-store", + "cf-cache-status": "BYPASS", + [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", + }, + }), + ); + + await expect( + warmCdnCache({ + expectedBuildId: "build-a", + fetchImpl: fetchImpl as typeof fetch, + paths: ["/error"], + strict: true, + targetUrl: "https://app.example.com", + }), + ).rejects.toThrow("HTTP 500"); + }); + + it("accepts an exact-build terminal response as staged-version readiness", async () => { + const fetchImpl = vi.fn( + async () => + new Response("not found", { + status: 404, + headers: { + [VINEXT_CDN_BUILD_ID_HEADER]: "build-a", + [VINEXT_RSC_BUILD_ID_HEADER]: "rsc-build-a", + }, + }), + ); + + await expect( + waitForCdnWarmTargetReadiness({ + expectedBuildId: "build-a", + expectedRscBuildId: "rsc-build-a", + fetchImpl: fetchImpl as typeof fetch, + maxAttempts: 1, + plan: { loadingShellPaths: [], paths: [], rscPaths: ["/not-found"] }, + probeIntervalMs: 0, + requiredConsecutiveSuccesses: 1, + targetUrl: "https://app.example.com", + }), + ).resolves.toEqual({ ready: true }); + }); + it("does not skip a non-success response from a different build", async () => { const fetchImpl = vi.fn( async () =>