Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
094c4a9
fix(cloudflare): verify CDN warmup hit the uploaded Worker version be…
NathanDrake2406 Jul 11, 2026
35ce45f
fix(cloudflare): isolate CDN-warmup Worker-name/host resolution from …
NathanDrake2406 Jul 11, 2026
993aa05
fix(cloudflare): resolve the deployment target once instead of repars…
NathanDrake2406 Jul 11, 2026
f75b156
fix(cloudflare): guard against staging a duplicate version split
NathanDrake2406 Jul 11, 2026
4926519
chore: merge upstream main into CDN warmup branch
NathanDrake2406 Jul 15, 2026
e22bc00
fix(cloudflare): make verified warmup retries recoverable
NathanDrake2406 Jul 15, 2026
7dc1746
fix(cloudflare): validate CDN warmup cache targets
james-elicx Jul 15, 2026
06e9343
fix(cloudflare): address CDN warmup review feedback
james-elicx Jul 15, 2026
d035a99
Merge remote-tracking branch 'origin/main' into codex/pr-2593-review
james-elicx Jul 15, 2026
865c071
fix(cloudflare): clarify CDN warmup diagnostics
james-elicx Jul 15, 2026
5f0be5c
fix(cloudflare): clarify CDN warmup fallback failures
james-elicx Jul 15, 2026
6f014fb
fix(cloudflare): harden warmup config edge cases
james-elicx Jul 15, 2026
28641d8
fix(cloudflare): improve warmup target diagnostics
james-elicx Jul 15, 2026
a938001
fix(cloudflare): avoid redundant warmup promotion
james-elicx Jul 15, 2026
7b00b14
test(cloudflare): cover strict warmup idempotence
james-elicx Jul 15, 2026
a1ad362
fix(cloudflare): preserve strict warmup verification counts
NathanDrake2406 Jul 16, 2026
da11980
Merge branch 'main' into nathan/fix-workers-dev-cdn-warmup
NathanDrake2406 Jul 18, 2026
c13d76d
fix(init): reject conflicting version_metadata bindings instead of re…
NathanDrake2406 Jul 18, 2026
43b43ca
fix(cloudflare): require cf-cache-status proof before reporting a pat…
NathanDrake2406 Jul 18, 2026
e623639
fix(cloudflare): re-verify the staged split before promoting the uplo…
NathanDrake2406 Jul 18, 2026
c7572ab
refactor(cloudflare): move Wrangler config parsing out of tpr.ts
NathanDrake2406 Jul 18, 2026
6f3fa1e
fix(cloudflare): warm every host-wide origin before claiming a confir…
NathanDrake2406 Jul 18, 2026
490f7e6
fix(cloudflare): refuse a confirmed-warm claim when a route cannot be…
NathanDrake2406 Jul 18, 2026
878f30c
refactor(cloudflare): break the deploy/warmup module cycle
NathanDrake2406 Jul 18, 2026
766f8ce
refactor(cloudflare): read wrangler config via Wrangler's own parser
NathanDrake2406 Jul 18, 2026
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
11 changes: 9 additions & 2 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,17 @@
"@types/node": "catalog:",
"vinext": "workspace:*",
"vite": "catalog:",
"vite-plus": "catalog:"
"vite-plus": "catalog:",
"wrangler": "catalog:"
},
"peerDependencies": {
"vinext": "workspace:^"
"vinext": "workspace:^",
"wrangler": "^4.80.0"
},
"peerDependenciesMeta": {
"wrangler": {
"optional": true
}
},
"engines": {
"node": ">=22"
Expand Down
451 changes: 451 additions & 0 deletions packages/cloudflare/src/cdn-warm-deployment.ts

Large diffs are not rendered by default.

133 changes: 128 additions & 5 deletions packages/cloudflare/src/cdn-warm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type PrerenderManifest,
type PrerenderedPathSelectionOptions,
} from "vinext/internal/server/prerender-manifest";
import { VINEXT_WORKER_VERSION_HEADER } from "vinext/internal/server/worker-version";

export type CdnWarmOptions = {
targetUrl: string;
Expand All @@ -18,7 +19,15 @@ export type CdnWarmOptions = {
concurrency?: number;
timeoutMs?: number;
retries?: number;
retryDelayMs?: number;
strict?: boolean;
expectedVersionId?: string;
/**
* Require cf-cache-status proof that Workers Cache stored the response, not
* just that the expected Worker produced it. A MISS fill is confirmed with a
* second identical request that must come back cache-served.
*/
confirmCache?: boolean;
fetchImpl?: typeof fetch;
};

Expand Down Expand Up @@ -142,11 +151,61 @@ function isRetryableStatus(status: number): boolean {
return status === 408 || status === 429 || status >= 500;
}

async function waitBeforeRetry(
attempt: number,
retries: number,
retryDelayMs: number,
): Promise<void> {
if (attempt >= retries || retryDelayMs === 0) return;
await new Promise((resolve) => setTimeout(resolve, retryDelayMs * 2 ** attempt));
Comment thread
james-elicx marked this conversation as resolved.
}

const CF_CACHE_STATUS_HEADER = "cf-cache-status";
/** Statuses proving the edge served the response from the cache partition. */
const CACHE_SERVED_STATUSES = new Set(["HIT", "STALE", "UPDATING", "REVALIDATED"]);
/** Statuses where the Worker ran and the response may have been written to cache. */
const CACHE_FILL_STATUSES = new Set(["MISS", "EXPIRED"]);

function getCacheStatus(response: Response): string | null {
return response.headers.get(CF_CACHE_STATUS_HEADER)?.toUpperCase() ?? null;
}

/**
* Null when the response proves the cache served it from the expected Worker
* version's partition; otherwise the reason it does not.
*/
function describeUnconfirmedCacheServe(
response: Response,
expectedVersionId: string | undefined,
): string | null {
if (expectedVersionId) {
const actualVersionId = response.headers.get(VINEXT_WORKER_VERSION_HEADER);
if (actualVersionId !== expectedVersionId) {
return describeVersionMismatch(expectedVersionId, actualVersionId);
}
}
if (response.status >= 400) return `HTTP ${response.status}`;
const cacheStatus = getCacheStatus(response);
if (cacheStatus && CACHE_SERVED_STATUSES.has(cacheStatus)) return null;
return `cache entry not confirmed (${CF_CACHE_STATUS_HEADER}: ${cacheStatus ?? "missing"})`;
}

/** Error text for a response that didn't prove it came from the expected Worker version. */
function describeVersionMismatch(
expectedVersionId: string,
actualVersionId: string | null,
): string {
return actualVersionId
? `expected Worker version ${expectedVersionId}, received ${actualVersionId}`
: `expected Worker version ${expectedVersionId}, but the response did not include ${VINEXT_WORKER_VERSION_HEADER}`;
}

async function fetchWithTimeout(
fetchImpl: typeof fetch,
url: URL,
timeoutMs: number,
headers: HeadersInit | undefined,
redirect: RequestRedirect = "follow",
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
Expand All @@ -155,7 +214,7 @@ async function fetchWithTimeout(
try {
return await fetchImpl(url, {
method: "GET",
redirect: "follow",
redirect,
headers: requestHeaders,
signal: controller.signal,
});
Expand All @@ -166,9 +225,13 @@ async function fetchWithTimeout(

async function warmOnePath(
pathname: string,
options: Required<Pick<CdnWarmOptions, "targetUrl" | "timeoutMs" | "retries">> & {
options: Required<
Pick<CdnWarmOptions, "targetUrl" | "timeoutMs" | "retries" | "retryDelayMs">
> & {
fetchImpl: typeof fetch;
headers?: HeadersInit;
expectedVersionId?: string;
confirmCache?: boolean;
},
): Promise<{ path: string; ok: true } | { path: string; ok: false; error: string }> {
const url = buildWarmupUrl(options.targetUrl, pathname);
Expand All @@ -181,21 +244,76 @@ async function warmOnePath(
url,
options.timeoutMs,
options.headers,
"manual",
);
await response.arrayBuffer();

// A staged version can take a few seconds to become globally routable, so
// before the override propagates, the old Worker answers with its own
// status codes (including 404s for newly added routes). Check which
// Worker actually produced the response before trusting its status —
// otherwise a pre-propagation old-Worker 404 looks like a terminal
// failure instead of "not warmed yet".
if (options.expectedVersionId) {
const actualVersionId = response.headers.get(VINEXT_WORKER_VERSION_HEADER);
if (actualVersionId !== options.expectedVersionId) {
lastError = describeVersionMismatch(options.expectedVersionId, actualVersionId);
await waitBeforeRetry(attempt, options.retries, options.retryDelayMs);
continue;
Comment thread
james-elicx marked this conversation as resolved.
}
}

if (response.status < 400) {
return { path: pathname, ok: true };
if (!options.confirmCache) return { path: pathname, ok: true };

// "The expected version produced a 200" and "the edge stored that
// response in the version's cache partition" are different facts.
// Per-entrypoint cache overrides and response-level bypasses
// (Set-Cookie, Cache-Control: no-store/private) return a healthy 200
// that Workers Cache never stores — only cf-cache-status can tell
// those apart from a real warm.
const cacheStatus = getCacheStatus(response);
if (cacheStatus && CACHE_SERVED_STATUSES.has(cacheStatus)) {
return { path: pathname, ok: true };
}
if (!cacheStatus || !CACHE_FILL_STATUSES.has(cacheStatus)) {
// BYPASS/DYNAMIC or a missing header is deterministic for this
// response shape: the cache will never store it, so retrying only
// burns the retry budget on the same answer.
lastError = `response was not stored by Workers Cache (${CF_CACHE_STATUS_HEADER}: ${cacheStatus ?? "missing"})`;
break;
}

// MISS/EXPIRED started a fill. Only a second identical request coming
// back cache-served proves the fill became a reusable entry.
const confirm = await fetchWithTimeout(
options.fetchImpl,
url,
options.timeoutMs,
options.headers,
"manual",
);
await confirm.arrayBuffer();
const confirmError = describeUnconfirmedCacheServe(confirm, options.expectedVersionId);
if (!confirmError) return { path: pathname, ok: true };
lastError = confirmError;
await waitBeforeRetry(attempt, options.retries, options.retryDelayMs);
continue;
}

// These paths came from this build's prerender manifest, so even a 4xx
// from the expected Worker version means the intended cache entry was
// not populated and must remain a warmup failure.
lastError = `HTTP ${response.status}`;
if (!isRetryableStatus(response.status)) break;
Comment thread
james-elicx marked this conversation as resolved.
await waitBeforeRetry(attempt, options.retries, options.retryDelayMs);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
lastError = `timed out after ${options.timeoutMs}ms`;
} else {
lastError = error instanceof Error ? error.message : String(error);
}
await waitBeforeRetry(attempt, options.retries, options.retryDelayMs);
}
}

Expand Down Expand Up @@ -227,7 +345,8 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise<CdnWarmResu
const paths = options.paths;
const concurrency = Math.max(1, options.concurrency ?? 10);
const timeoutMs = Math.max(1, options.timeoutMs ?? DEFAULT_CDN_WARM_TIMEOUT_MS);
const retries = Math.max(0, options.retries ?? 1);
const retries = Math.max(0, options.retries ?? (options.expectedVersionId ? 3 : 1));
const retryDelayMs = Math.max(0, options.retryDelayMs ?? 500);
const fetchImpl = options.fetchImpl ?? fetch;

if (paths.length === 0) {
Expand All @@ -241,8 +360,11 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise<CdnWarmResu
targetUrl: options.targetUrl,
timeoutMs,
retries,
retryDelayMs,
fetchImpl,
headers: options.headers,
expectedVersionId: options.expectedVersionId,
confirmCache: options.confirmCache,
}),
);

Expand Down Expand Up @@ -270,7 +392,8 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise<CdnWarmResu

if (options.strict && failures.length > 0) {
throw new Error(
`CDN warmup failed for ${failures.length}/${paths.length} path(s). ` +
`CDN warmup failed for ${failures.length}/${paths.length} path(s); ` +
`verified ${warmed}/${paths.length}. ` +
`First failure: ${failures[0].path}: ${failures[0].error}`,
);
}
Expand Down
14 changes: 10 additions & 4 deletions packages/cloudflare/src/deploy-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,17 @@ export function formatDeployHelp(): string {
--prerender-concurrency <count>
Maximum number of routes to pre-render in parallel
--experimental-warm-cdn-cache
Upload a Worker version, warm build-discovered paths
through the production URL, then promote it (experimental)
Stage a Worker version at 0%, warm build-discovered
paths through a verified version override, then promote
it to 100% (experimental)
--warm-cdn-concurrency <count>
Maximum number of CDN warmup requests in parallel
--warm-cdn-timeout <ms> Per-request CDN warmup timeout (default: 5000)
--warm-cdn-retries <n> Retries for transient CDN warmup failures (default: 1)
--warm-cdn-strict Fail deploy when any CDN warmup request fails
--warm-cdn-retries <n> Retries for warmup failures (default: 3)
Increase when Worker version propagation is slow
--warm-cdn-strict Fail when staging or any CDN warmup request fails.
The previous version remains at 100% until warming succeeds
(static exports skip Worker-version warmup because Assets serve them)
--warm-cdn-include-fallbacks
Also warm PPR fallback-shell placeholder paths
-h, --help Show this help
Expand All @@ -47,6 +51,8 @@ export function formatDeployHelp(): string {

CDN warmup requests populate the edge cache only in the Cloudflare data centers
reached by the warmup run; they do not globally prefill every edge location.
Verified warmup exposes x-vinext-worker-version on Worker-served responses so
cached representations can identify the Worker version that produced them.

Examples:
npx @vinext/cloudflare deploy Build and deploy to production
Expand Down
Loading
Loading