Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions examples/workers-cache/pages/pages-prewarm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export async function getStaticProps() {
return { props: {}, revalidate: 300 };
}

export default function PagesPrewarmTarget() {
return <h1>Pages prewarm target</h1>;
}
71 changes: 47 additions & 24 deletions packages/cloudflare/src/cdn-warm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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(",")
Expand All @@ -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") ?? "")
Expand All @@ -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 (
Expand All @@ -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;
}

Expand Down
31 changes: 27 additions & 4 deletions packages/cloudflare/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -727,13 +737,25 @@ export async function deployWithCdnWarmup(
);
}

const remainingWarmRequests =
remainingWarmPlan.paths.length +
remainingWarmPlan.rscPaths.length +
remainingWarmPlan.loadingShellPaths.length;

if (options.warmCdnPromote === false) {
if (!staged) {
throw new Error(
"CDN warmup cannot skip promotion because the uploaded Worker version could not be staged at 0% traffic. " +
"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.",
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1108,6 +1126,11 @@ export async function deploy(options: DeployOptions): Promise<void> {
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);
}
Expand Down
42 changes: 36 additions & 6 deletions packages/vinext/src/build/prerender-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, string>,
Expand Down Expand Up @@ -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<StaticPathsEntry>;
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") {
Expand Down
55 changes: 55 additions & 0 deletions tests/cloudflare-cdn-warm-deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading