Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
12 changes: 11 additions & 1 deletion .github/workflows/deploy-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ jobs:
project: workers-cache
working_directory: examples/workers-cache
wrangler_config: dist/server/wrangler.json
warm_cdn_cache: true
- name: web
project: vinext-web
working_directory: apps/web
wrangler_config: dist/server/wrangler.json
warm_cdn_cache: true
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
Expand Down Expand Up @@ -92,14 +94,22 @@ jobs:
command: d1 migrations apply vinext-metrics --remote

- name: Deploy to Cloudflare Workers (Production)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && matrix.example.warm_cdn_cache != true
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: ${{ matrix.example.working_directory }}
command: deploy --config ${{ matrix.example.wrangler_config }}

- name: Deploy to Cloudflare Workers with CDN warmup (Production)
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && matrix.example.warm_cdn_cache == true
working-directory: ${{ matrix.example.working_directory }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: vp exec vinext-cloudflare deploy --skip-build --config ${{ matrix.example.wrangler_config }} --warm-cdn-cache

- name: Deploy Preview Version
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
Expand Down
4 changes: 4 additions & 0 deletions examples/workers-cache/app/cached/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { RevalidateControls } from "../../components/revalidate-controls";
// Cache layer honours it automatically.
export const revalidate = 60;

export function generateStaticParams() {
return [{ slug: "intro" }, { slug: "featured" }];
}

export async function generateMetadata({
params,
}: {
Expand Down
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
289 changes: 289 additions & 0 deletions packages/cloudflare/src/cdn-warm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
import path from "node:path";
import fs from "node:fs";
import {
PRERENDER_PATHS_MANIFEST,
type PrerenderPathManifest,
} from "vinext/internal/build/prerender-paths";
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 const DEFAULT_CDN_WARM_TIMEOUT_MS = 5_000;

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;
}
}

function readPrerenderPathManifest(manifestPath: string): PrerenderPathManifest | null {
if (!fs.existsSync(manifestPath)) return null;
try {
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const manifest = parsed as PrerenderPathManifest;
if (!Array.isArray(manifest.paths)) return null;
return manifest;
} catch (error) {
console.warn(`[vinext] Failed to read prerender path manifest at ${manifestPath}:`, error);
return null;
}
}

function readPrerenderPathWarmPaths(root: string, options?: { strict?: boolean }): string[] | null {
const manifest = readPrerenderPathManifest(
path.join(root, "dist", "server", PRERENDER_PATHS_MANIFEST),
);
if (!manifest) return null;

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

return manifest.paths.filter((pathname) => pathname.startsWith("/"));
}

export function readPrerenderWarmPaths(
root: string,
options?: { includeFallbackShells?: boolean; strict?: boolean },
): string[] {
const shouldPreferPrerenderManifest = options?.includeFallbackShells === true;
if (!shouldPreferPrerenderManifest) {
const pathManifestPaths = readPrerenderPathWarmPaths(root, options);
if (pathManifestPaths !== null) return pathManifestPaths;
}

const manifest = readPrerenderManifest(
path.join(root, "dist", "server", "vinext-prerender.json"),
);
if (!manifest) {
if (shouldPreferPrerenderManifest) {
const pathManifestPaths = readPrerenderPathWarmPaths(root, options);
if (pathManifestPaths !== null) {
console.warn(
"[vinext] CDN warmup fallback shells requested, but prerender manifest not found; warming build-discovered paths only.",
);
return pathManifestPaths;
}
}
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 ?? DEFAULT_CDN_WARM_TIMEOUT_MS);
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} build-discovered 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
Loading
Loading