From 5e4f9aa8010b68e10eea0cf130acd766aa24652b Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 03:25:44 +0100 Subject: [PATCH] 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");