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/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts
index 4b97b3c24..c16af5fe3 100644
--- a/packages/cloudflare/src/cdn-warm.ts
+++ b/packages/cloudflare/src/cdn-warm.ts
@@ -511,20 +511,15 @@ function validateBuildIdentity(
return null;
}
+function isExpectedTerminalStatus(status: number): boolean {
+ return (status >= 300 && status < 400) || status === 404;
+}
+
function validateRscWarmResponse(
response: Response,
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,8 +531,30 @@ function validateRscWarmResponse(
error: `response ${VINEXT_RSC_BUILD_ID_HEADER} does not match build ${expectedRscBuildId}`,
};
}
+ if (response.redirected) {
+ return { outcome: "failed", error: "redirected response" };
+ }
+ 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}` };
+ }
+ } 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(",")
@@ -556,14 +573,17 @@ 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" };
+ }
+ const terminalResponse = response.status < 200 || response.status >= 300;
+ if (terminalResponse) {
+ if (!isExpectedTerminalStatus(response.status) || expectedBuildId === undefined) {
+ return { outcome: "failed", error: `HTTP ${response.status}` };
+ }
+ }
const cachePolicyValidation = validateCachePolicy(response, true);
if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation;
const extraVary = (response.headers.get("Vary") ?? "")
@@ -582,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 (
@@ -600,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/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts
index 386938d8c..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,
@@ -727,6 +737,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 +749,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 +792,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;
@@ -1108,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/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-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts
index ba09f9532..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(
@@ -881,6 +893,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",
diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts
index d2ae11efa..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";
@@ -520,6 +521,159 @@ 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" } : {}),
+ ...(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: 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 () =>
+ 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/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();
+ });
});
diff --git a/tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts b/tests/e2e/cloudflare-workers/rsc-prewarm.spec.ts
index 72ea26dd7..cfeb4eb86 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,29 @@ 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 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`,
fullHeaders,
@@ -207,7 +230,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,
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");