Skip to content
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -710,9 +710,19 @@ The KV data adapter reads `env[binding]` at runtime, so add the matching KV name
```jsonc
{
"cache": { "enabled": true },
"version_metadata": { "binding": "CF_VERSION_METADATA" },
}
```

The version metadata binding is required for staged discovery and warming to
verify the uploaded Worker version. Wrangler named environments do not inherit
`version_metadata`, so repeat it inside each `env.<name>` used for warming.

`vinext-cloudflare deploy --experimental-warm-cdn-cache` performs the two-stage
upload and makes one final cache-fill request per admitted identity by default.
Add `--warm-cdn-certify` only to opt into a second, header-only request that
must prove every planned entry reusable before promotion.

While the data adapter can store entries and serve HIT/STALE itself, the CDN adapter delegates serving to Cloudflare's edge: the origin renders fresh responses and tags them with `Cache-Tag`, and `revalidateTag()` / `revalidatePath()` purge the edge through `ctx.cache.purge({ tags })`. See [examples/workers-cache](examples/workers-cache) for both adapters wired up together.

Each builder returns a plain, serializable `{ adapter, options }` descriptor — **it never touches the Workers runtime**, so nothing throws at build or dev time when bindings aren't available. The actual adapter (and its `env` binding lookup) is instantiated lazily on the first request.
Expand Down
13 changes: 13 additions & 0 deletions packages/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export default defineConfig({
"cache": {
"enabled": true,
},
"version_metadata": {
"binding": "CF_VERSION_METADATA",
},
}
```

Expand All @@ -54,6 +57,16 @@ import { cdnAdapter } from "@vinext/cloudflare/cache/cdn-adapter";
vinext({ cache: { cdn: cdnAdapter() } });
```

The version metadata binding lets staged warmup prove that every discovery,
probe, and fill request reached the uploaded Worker version. Wrangler named
environments do not inherit `version_metadata`; repeat the binding inside every
`env.<name>` used with CDN warming.

Use `--experimental-warm-cdn-cache` for the two-stage deploy. The default flow
makes one final fill request per admitted identity. Add `--warm-cdn-certify`
only when you want an opt-in second, header-only request that must prove every
planned entry reusable before promotion.

## Deploy

Deploy Cloudflare Workers projects with the package CLI:
Expand Down
94 changes: 80 additions & 14 deletions packages/cloudflare/src/cdn-warm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export type CdnWarmOptions = {
phaseTimeoutMs?: number;
/** Retry a newly staged version or preview alias until its routing has propagated. */
propagatingTarget?: boolean;
/** Require the response to come from a reusable cache entry, not merely an eligible MISS. */
requireCacheHit?: boolean;
strict?: boolean;
fetchImpl?: typeof fetch;
};
Expand All @@ -71,6 +73,7 @@ export type CdnWarmResult = {
skipped: number;
failed: number;
failures: Array<{ path: string; error: string }>;
warmedPlan: CdnWarmRequestPlan;
retryPlan: CdnWarmRequestPlan;
};

Expand Down Expand Up @@ -441,6 +444,7 @@ const REQUIRED_RSC_VARY_HEADERS = VINEXT_RSC_VARY_HEADER.split(",").map((name) =
name.trim().toLowerCase(),
);
const ADMITTED_CF_CACHE_STATUSES = new Set(["HIT", "MISS", "EXPIRED", "REVALIDATED", "UPDATING"]);
const REUSABLE_CF_CACHE_STATUSES = new Set(["HIT", "REVALIDATED", "UPDATING"]);
const NON_CACHEABLE_CF_CACHE_STATUSES = new Set(["BYPASS"]);
const CDN_CACHE_POLICY_HEADERS = [
"Cloudflare-CDN-Cache-Control",
Expand Down Expand Up @@ -488,7 +492,11 @@ type WarmValidation =
| { outcome: "skipped"; reason: string }
| { outcome: "failed"; error: string };

function validateCachePolicy(response: Response, requireCacheStatus: boolean): WarmValidation {
function validateCachePolicy(
response: Response,
requireCacheStatus: boolean,
requireCacheHit = false,
): WarmValidation {
const effectivePolicy = CDN_CACHE_POLICY_HEADERS.map((name) => ({
name,
value: response.headers.get(name),
Expand All @@ -503,6 +511,13 @@ function validateCachePolicy(response: Response, requireCacheStatus: boolean): W
const hasSetCookie = response.headers.has("Set-Cookie");
const cacheStatus = response.headers.get("CF-Cache-Status")?.trim().toUpperCase();

if (requireCacheHit && !REUSABLE_CF_CACHE_STATUSES.has(cacheStatus ?? "")) {
return {
outcome: "failed",
error: `CF-Cache-Status is ${cacheStatus ?? "missing"}; the cache fill is not reusable`,
};
}

// Cloudflare-CDN-Cache-Control is consumed at the edge and is deliberately
// not forwarded to clients. A cacheable Cloudflare-specific policy can
// therefore coexist with a downstream `no-store` policy or a Set-Cookie
Expand Down Expand Up @@ -584,6 +599,7 @@ function validateRscWarmResponse(
response: Response,
expectedBuildId?: string,
expectedRscBuildId?: string,
requireCacheHit = false,
): WarmValidation {
const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
if (buildIdentityValidation) return buildIdentityValidation;
Expand Down Expand Up @@ -612,7 +628,7 @@ function validateRscWarmResponse(
) {
return { outcome: "failed", error: `expected ${VINEXT_RSC_CONTENT_TYPE} response` };
}
const cachePolicyValidation = validateCachePolicy(response, true);
const cachePolicyValidation = validateCachePolicy(response, true, requireCacheHit);
if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation;
if (
terminalResponse &&
Expand All @@ -637,7 +653,11 @@ function validateRscWarmResponse(
return { outcome: "warmed" };
}

function validateHtmlWarmResponse(response: Response, expectedBuildId?: string): WarmValidation {
function validateHtmlWarmResponse(
response: Response,
expectedBuildId?: string,
requireCacheHit = false,
): WarmValidation {
const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
if (buildIdentityValidation) return buildIdentityValidation;
if (response.redirected) {
Expand All @@ -649,7 +669,7 @@ function validateHtmlWarmResponse(response: Response, expectedBuildId?: string):
return { outcome: "failed", error: `HTTP ${response.status}` };
}
}
const cachePolicyValidation = validateCachePolicy(response, true);
const cachePolicyValidation = validateCachePolicy(response, true, requireCacheHit);
if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation;
const extraVary = (response.headers.get("Vary") ?? "")
.split(",")
Expand Down Expand Up @@ -876,6 +896,7 @@ async function warmOnePath(
retryDelayMs: number;
retryNotFound: boolean;
phaseTimeoutMs?: number;
requireCacheHit: boolean;
},
): Promise<
| { path: string; ok: true; skipped: false }
Expand Down Expand Up @@ -908,16 +929,29 @@ async function warmOnePath(
}
const attemptTimeoutMs = Math.min(options.timeoutMs, remainingMs);
try {
const { response } = await fetchWithTimeout(
options.fetchImpl,
url,
attemptTimeoutMs,
target.headers ?? options.headers,
"manual",
);
const response = options.requireCacheHit
? await fetchHeadersWithTimeout(
options.fetchImpl,
url,
attemptTimeoutMs,
target.headers ?? options.headers,
)
: (
await fetchWithTimeout(
options.fetchImpl,
url,
attemptTimeoutMs,
target.headers ?? options.headers,
"manual",
)
).response;
if (options.deadlineAt !== undefined && Date.now() >= options.deadlineAt) {
return { path: target.label, ok: false, error: phaseDeadlineError(), retryable: false };
}
// Certification only needs immutable response metadata. A reusable HIT
// is already stored at the edge, so downloading it again would double
// warmup bandwidth without proving anything more.
if (options.requireCacheHit) void response.body?.cancel().catch(() => {});

if (process.env.VINEXT_CDN_WARM_DEBUG === "1") {
console.log(
Expand All @@ -935,6 +969,7 @@ async function warmOnePath(
response,
options.expectedBuildId,
options.expectedRscBuildId,
options.requireCacheHit,
);
if (validation.outcome === "warmed") {
return { path: target.label, ok: true, skipped: false };
Expand All @@ -943,7 +978,12 @@ async function warmOnePath(
return { path: target.label, ok: true, skipped: true, reason: validation.reason };
}
lastError = validation.error;
lastRetryable = shouldRetryValidationFailure(response, target, options);
const cacheStatus = response.headers.get("CF-Cache-Status")?.trim().toUpperCase();
lastRetryable =
(options.requireCacheHit &&
ADMITTED_CF_CACHE_STATUSES.has(cacheStatus ?? "") &&
!REUSABLE_CF_CACHE_STATUSES.has(cacheStatus ?? "")) ||
shouldRetryValidationFailure(response, target, options);
if (!lastRetryable) break;
if (!canRetry(attempt)) break;
if (!(await waitBeforeRetry())) {
Expand All @@ -952,15 +992,24 @@ async function warmOnePath(
continue;
}

const validation = validateHtmlWarmResponse(response, options.expectedBuildId);
const validation = validateHtmlWarmResponse(
response,
options.expectedBuildId,
options.requireCacheHit,
);
if (validation.outcome === "warmed") {
return { path: target.label, ok: true, skipped: false };
}
if (validation.outcome === "skipped") {
return { path: target.label, ok: true, skipped: true, reason: validation.reason };
}
lastError = validation.error;
lastRetryable = shouldRetryValidationFailure(response, target, options);
const cacheStatus = response.headers.get("CF-Cache-Status")?.trim().toUpperCase();
lastRetryable =
(options.requireCacheHit &&
ADMITTED_CF_CACHE_STATUSES.has(cacheStatus ?? "") &&
!REUSABLE_CF_CACHE_STATUSES.has(cacheStatus ?? "")) ||
shouldRetryValidationFailure(response, target, options);
if (!lastRetryable) break;
} catch (error) {
lastRetryable = true;
Expand Down Expand Up @@ -1031,6 +1080,7 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise<CdnWarmResu
skipped: 0,
failed: 0,
failures: [],
warmedPlan: { loadingShellPaths: [], paths: [], rscPaths: [] },
retryPlan: { loadingShellPaths: [], paths: [], rscPaths: [] },
};
}
Expand Down Expand Up @@ -1063,6 +1113,7 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise<CdnWarmResu
retryDelayMs: isPropagationRequest ? propagationRetryDelayMs : normalRetryDelayMs,
retryNotFound: isPropagationRequest,
phaseTimeoutMs,
requireCacheHit: options.requireCacheHit === true,
});
};

Expand Down Expand Up @@ -1145,6 +1196,10 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise<CdnWarmResu
);
const failures = failedRequests.map(({ result: { path, error } }) => ({ path, error }));
const skippedResults = results.filter((result) => result.ok && result.skipped);
const warmedRequests = requests.filter((_target, index) => {
const result = results[index];
return result.ok && !result.skipped;
});
const warmed = results.length - failures.length - skippedResults.length;

console.log(
Expand All @@ -1168,6 +1223,17 @@ export async function warmCdnCache(options: CdnWarmOptions): Promise<CdnWarmResu
skipped: skippedResults.length,
failed: failures.length,
failures,
warmedPlan: {
loadingShellPaths: warmedRequests
.filter((target) => target.kind === "rsc-loading-shell")
.map((target) => target.sourcePathname),
paths: warmedRequests
.filter((target) => target.kind === "html")
.map((target) => target.sourcePathname),
rscPaths: warmedRequests
.filter((target) => target.kind === "rsc-full")
.map((target) => target.sourcePathname),
},
retryPlan: {
loadingShellPaths: failedRequests
.filter(({ target }) => target.kind === "rsc-loading-shell")
Expand Down
1 change: 1 addition & 0 deletions packages/cloudflare/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ async function deployCommand(): Promise<void> {
warmCdnDiscoveryRetries: parsed.warmCdnDiscoveryRetries,
warmCdnProbeTimeout: parsed.warmCdnProbeTimeout,
warmCdnProbeRetries: parsed.warmCdnProbeRetries,
warmCdnCertify: parsed.warmCdnCertify,
warmCdnReadinessTimeout: parsed.warmCdnReadinessTimeout,
warmCdnReadinessRetries: parsed.warmCdnReadinessRetries,
warmCdnReadinessProbes: parsed.warmCdnReadinessProbes,
Expand Down
6 changes: 5 additions & 1 deletion packages/cloudflare/src/deploy-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export function formatDeployHelp(): string {
Total cacheability-probe deadline (default: 120000)
--warm-cdn-probe-retries <n>
Cacheability-probe retries (default: 2)
--warm-cdn-certify With --experimental-warm-cdn-cache, re-request warmed
entries using headers only and require every planned
entry to be reusable before promotion
--warm-cdn-readiness-timeout <ms>
Total staged-readiness deadline (default: 120000)
--warm-cdn-readiness-retries <n>
Expand All @@ -48,7 +51,8 @@ export function formatDeployHelp(): string {
--warm-cdn-readiness-probe-delay <ms>
Delay between staged-readiness probes (default: 1000)
--dangerously-promote-on-cdn-warm-error
Promote even when staged warmup cannot be verified
Promote even when ordinary staged warmup cannot be
verified (never bypasses --warm-cdn-certify)
--warm-cdn-no-promote Leave the warmed Worker version staged at 0% traffic;
production triggers are still applied before warming
--warm-cdn-promotion-delay <ms>
Expand Down
Loading
Loading