Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c32ef0a
feat(cloudflare): warm prerendered paths before deploy
james-elicx Jul 1, 2026
24126e6
fix(cloudflare): warm production CDN for uploaded versions
james-elicx Jul 3, 2026
7ed95fd
fix(cloudflare): resolve Worker names for CDN warmup
james-elicx Jul 3, 2026
e158661
fix(cloudflare): harden CDN warmup diagnostics
james-elicx Jul 3, 2026
d8a1060
fix(cloudflare): harden CDN warmup deploy flow
james-elicx Jul 4, 2026
3d3e3cd
fix(cloudflare): clarify CDN warmup trigger failures
james-elicx Jul 4, 2026
dc89227
feat(cloudflare): warm cdn from build path manifest
james-elicx Jul 4, 2026
4c56a37
Merge remote-tracking branch 'origin/main' into codex/cloudflare-cdn-…
james-elicx Jul 4, 2026
a89176e
fix(cloudflare): emit warm paths for skip-build deploys
james-elicx Jul 4, 2026
6d1df98
fix(cloudflare): avoid stale skip-build path manifests
james-elicx Jul 4, 2026
7436104
test(cloudflare): lock skip-build prerender manifest gate
james-elicx Jul 4, 2026
796fb4f
fix(cloudflare): warm fallback deploy URL from triggers
james-elicx Jul 4, 2026
e0f2e3b
fix(cloudflare): clarify CDN warmup deploy logs
james-elicx Jul 4, 2026
be4635e
feat(init): offer CDN warmup deploy script
james-elicx Jul 4, 2026
800b80d
update wrangler configs
james-elicx Jul 4, 2026
6c66db4
fix(init): default CDN warmup to yes
james-elicx Jul 4, 2026
101bcc6
fix(cloudflare): explain first warm deploy failure
james-elicx Jul 4, 2026
09cc1bb
fix(cloudflare): keep deploy errors concise
james-elicx Jul 4, 2026
7ba145a
fix(init): make generated home page cacheable
james-elicx Jul 4, 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
8 changes: 8 additions & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@
"types": "./dist/tpr.d.ts",
"import": "./dist/tpr.js"
},
"./internal/cdn-warm": {
"types": "./dist/cdn-warm.d.ts",
"import": "./dist/cdn-warm.js"
},
"./internal/version-deploy": {
"types": "./dist/version-deploy.d.ts",
"import": "./dist/version-deploy.js"
},
"./cache/*": {
"types": "./dist/cache/*.d.ts",
"import": "./dist/cache/*.js"
Expand Down
236 changes: 236 additions & 0 deletions packages/cloudflare/src/cdn-warm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import path from "node:path";
import fs from "node:fs";
import {
getPrerenderedConcretePaths,
readPrerenderManifest,
type PrerenderManifest,
type PrerenderedPathSelectionOptions,
} from "vinext/internal/server/prerender-manifest";

export type CdnWarmOptions = {
targetUrl: string;
paths: readonly string[];
headers?: HeadersInit;
concurrency?: number;
timeoutMs?: number;
retries?: number;
strict?: boolean;
fetchImpl?: typeof fetch;
};

export type PrerenderCdnWarmOptions = Omit<CdnWarmOptions, "paths"> & {
root: string;
includeFallbackShells?: boolean;
};

export type CdnWarmResult = {
total: number;
warmed: number;
failed: number;
failures: Array<{ path: string; error: string }>;
};

function readBuiltBuildId(root: string): string | null {
try {
const buildId = fs.readFileSync(path.join(root, "dist", "server", "BUILD_ID"), "utf-8").trim();
return buildId.length > 0 ? buildId : null;
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return null;
}
throw error;
}
}

export function readPrerenderWarmPaths(
root: string,
options?: { includeFallbackShells?: boolean; strict?: boolean },
): string[] {
const manifest = readPrerenderManifest(
path.join(root, "dist", "server", "vinext-prerender.json"),
);
if (!manifest) {
const message = "[vinext] CDN warmup skipped: prerender manifest not found.";
if (options?.strict) throw new Error(message);
return [];
}

const builtBuildId = readBuiltBuildId(root);
if (!manifest.buildId || !builtBuildId || manifest.buildId !== builtBuildId) {
const message =
"[vinext] CDN warmup skipped: prerender manifest buildId does not match dist/server/BUILD_ID.";
if (options?.strict) throw new Error(message);
console.warn(message);
return [];
}

return getPrerenderedConcretePaths(manifest, options);
}

export function getWarmPathsFromPrerenderManifest(
manifest: PrerenderManifest,
options?: PrerenderedPathSelectionOptions,
): string[] {
return getPrerenderedConcretePaths(manifest, options);
}

function normalizeWarmPath(pathname: string): string {
return pathname.startsWith("/") ? pathname : `/${pathname}`;
}

export function buildWarmupUrl(targetUrl: string, pathname: string): URL {
return new URL(
normalizeWarmPath(pathname),
targetUrl.endsWith("/") ? targetUrl : `${targetUrl}/`,
);
}

function isRetryableStatus(status: number): boolean {
return status === 408 || status === 429 || status >= 500;
}

async function fetchWithTimeout(
fetchImpl: typeof fetch,
url: URL,
timeoutMs: number,
headers: HeadersInit | undefined,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const requestHeaders = new Headers(headers);
requestHeaders.set("User-Agent", "vinext-cloudflare-cdn-warm");
try {
return await fetchImpl(url, {
method: "GET",
redirect: "follow",
headers: requestHeaders,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}

async function warmOnePath(
pathname: string,
options: Required<Pick<CdnWarmOptions, "targetUrl" | "timeoutMs" | "retries">> & {
fetchImpl: typeof fetch;
headers?: HeadersInit;
},
): Promise<{ path: string; ok: true } | { path: string; ok: false; error: string }> {
const url = buildWarmupUrl(options.targetUrl, pathname);
let lastError = "unknown error";

for (let attempt = 0; attempt <= options.retries; attempt++) {
try {
const response = await fetchWithTimeout(
options.fetchImpl,
url,
options.timeoutMs,
options.headers,
);
await response.arrayBuffer();

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

lastError = `HTTP ${response.status}`;
if (!isRetryableStatus(response.status)) break;
} 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);
}
}
}

return { path: pathname, ok: false, error: lastError };
}

async function runWithConcurrency<T, R>(
items: readonly T[],
concurrency: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const results = Array.from<R>({ length: items.length });
let nextIndex = 0;

async function worker(): Promise<void> {
while (nextIndex < items.length) {
const index = nextIndex++;
results[index] = await fn(items[index]);
}
}

if (items.length === 0) return results;
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
await Promise.all(workers);
return results;
}

export async function warmCdnCache(options: CdnWarmOptions): Promise<CdnWarmResult> {
const paths = options.paths;
const concurrency = Math.max(1, options.concurrency ?? 10);
const timeoutMs = Math.max(1, options.timeoutMs ?? 30_000);
const retries = Math.max(0, options.retries ?? 1);
const fetchImpl = options.fetchImpl ?? fetch;

if (paths.length === 0) {
return { total: 0, warmed: 0, failed: 0, failures: [] };
}

console.log(`\n Warming CDN cache for ${paths.length} prerendered path(s)...`);

const results = await runWithConcurrency(paths, concurrency, (pathname) =>
warmOnePath(pathname, {
targetUrl: options.targetUrl,
timeoutMs,
retries,
fetchImpl,
headers: options.headers,
}),
);

const failures = results
.filter((result): result is { path: string; ok: false; error: string } => !result.ok)
.map(({ path, error }) => ({ path, error }));
const warmed = results.length - failures.length;

console.log(` CDN warmup: ${warmed}/${paths.length} path(s) warmed.`);
if (failures.length > 0) {
for (const failure of failures.slice(0, 5)) {
console.warn(` CDN warmup failed for ${failure.path}: ${failure.error}`);
}
if (failures.length > 5) {
console.warn(` CDN warmup: ${failures.length - 5} additional failure(s) omitted.`);
}
}

const result = {
total: paths.length,
warmed,
failed: failures.length,
failures,
};

if (options.strict && failures.length > 0) {
throw new Error(
`CDN warmup failed for ${failures.length}/${paths.length} path(s). ` +
`First failure: ${failures[0].path}: ${failures[0].error}`,
);
}

return result;
}

export async function warmCdnCacheFromPrerender(
options: PrerenderCdnWarmOptions,
): Promise<CdnWarmResult> {
const paths = readPrerenderWarmPaths(options.root, {
includeFallbackShells: options.includeFallbackShells,
strict: options.strict,
});
return warmCdnCache({ ...options, paths });
}
6 changes: 6 additions & 0 deletions packages/cloudflare/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ async function deployCommand(): Promise<void> {
name: parsed.name,
prerenderAll: parsed.prerenderAll,
prerenderConcurrency: parsed.prerenderConcurrency,
warmCdnCache: parsed.warmCdnCache,
warmCdnConcurrency: parsed.warmCdnConcurrency,
warmCdnTimeout: parsed.warmCdnTimeout,
warmCdnRetries: parsed.warmCdnRetries,
warmCdnStrict: parsed.warmCdnStrict,
warmCdnIncludeFallbacks: parsed.warmCdnIncludeFallbacks,
experimentalTPR: parsed.experimentalTPR,
tprCoverage: parsed.tprCoverage,
tprLimit: parsed.tprLimit,
Expand Down
13 changes: 13 additions & 0 deletions packages/cloudflare/src/deploy-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ export function formatDeployHelp(): string {
releases will auto-populate the remote cache)
--prerender-concurrency <count>
Maximum number of routes to pre-render in parallel
--warm-cdn-cache Upload a Worker version, warm prerendered paths
through the production URL, then promote it
--warm-cdn-concurrency <count>
Maximum number of CDN warmup requests in parallel
--warm-cdn-timeout <ms> Per-request CDN warmup timeout (default: 30000)
--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-include-fallbacks
Also warm PPR fallback-shell placeholder paths
-h, --help Show this help

Experimental:
Expand All @@ -34,6 +43,9 @@ export function formatDeployHelp(): string {
a custom domain (zone analytics are unavailable on *.workers.dev) and the
CLOUDFLARE_API_TOKEN environment variable with Zone.Analytics read permission.

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.

Examples:
npx @vinext/cloudflare deploy Build and deploy to production
vpx @vinext/cloudflare deploy Build and deploy with Vite+
Expand All @@ -42,6 +54,7 @@ export function formatDeployHelp(): string {
vinext-cloudflare deploy --env staging Deploy using wrangler env.staging
vinext-cloudflare deploy --dry-run Validate setup without building or deploying
vinext-cloudflare deploy --name my-app Deploy with a custom Worker name
vinext-cloudflare deploy --prerender-all --warm-cdn-cache Warm prerendered paths during version deploy
vinext-cloudflare deploy --experimental-tpr Enable TPR during deploy
vinext-cloudflare deploy --experimental-tpr --tpr-coverage 95 Cover 95% of traffic
vinext-cloudflare deploy --experimental-tpr --tpr-limit 500 Cap at 500 pages
Expand Down
Loading
Loading