diff --git a/.github/workflows/deploy-examples.yml b/.github/workflows/deploy-examples.yml index b6121810f7..79ec85fc2f 100644 --- a/.github/workflows/deploy-examples.yml +++ b/.github/workflows/deploy-examples.yml @@ -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: @@ -92,7 +94,7 @@ 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 }} @@ -100,6 +102,14 @@ jobs: 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 diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index c98210daa9..75ceeb63dd 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -5,9 +5,11 @@ "compatibility_flags": ["nodejs_compat"], "main": "./worker/index.ts", "preview_urls": true, + "account_id": "d48e2eb599d9aa075d5e682deaecc518", "assets": { "not_found_handling": "none", "binding": "ASSETS", + "directory": "dist/client", }, "images": { "binding": "IMAGES", diff --git a/examples/workers-cache/app/cached/[slug]/page.tsx b/examples/workers-cache/app/cached/[slug]/page.tsx index b568437040..12a596ac8f 100644 --- a/examples/workers-cache/app/cached/[slug]/page.tsx +++ b/examples/workers-cache/app/cached/[slug]/page.tsx @@ -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, }: { diff --git a/examples/workers-cache/wrangler.jsonc b/examples/workers-cache/wrangler.jsonc index 8024146878..9b48ed005c 100644 --- a/examples/workers-cache/wrangler.jsonc +++ b/examples/workers-cache/wrangler.jsonc @@ -8,7 +8,9 @@ "compatibility_flags": ["nodejs_compat"], "main": "vinext/server/fetch-handler", "preview_urls": true, + "account_id": "d48e2eb599d9aa075d5e682deaecc518", "assets": { + "directory": "dist/client", "not_found_handling": "none", "binding": "ASSETS", }, diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index b5edeb1597..5cee404e7e 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -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" diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts new file mode 100644 index 0000000000..b8f52cc186 --- /dev/null +++ b/packages/cloudflare/src/cdn-warm.ts @@ -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 & { + 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 { + 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> & { + 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( + items: readonly T[], + concurrency: number, + fn: (item: T) => Promise, +): Promise { + const results = Array.from({ length: items.length }); + let nextIndex = 0; + + async function worker(): Promise { + 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 { + 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 { + const paths = readPrerenderWarmPaths(options.root, { + includeFallbackShells: options.includeFallbackShells, + strict: options.strict, + }); + return warmCdnCache({ ...options, paths }); +} diff --git a/packages/cloudflare/src/cli.ts b/packages/cloudflare/src/cli.ts index b318d6259c..e4450ac342 100644 --- a/packages/cloudflare/src/cli.ts +++ b/packages/cloudflare/src/cli.ts @@ -49,6 +49,12 @@ async function deployCommand(): Promise { 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, @@ -69,7 +75,7 @@ if (command === "--help" || command === "-h" || !command) { switch (command) { case "deploy": deployCommand().catch((error) => { - console.error(error); + console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }); break; diff --git a/packages/cloudflare/src/deploy-help.ts b/packages/cloudflare/src/deploy-help.ts index ad875361d2..7123123b96 100644 --- a/packages/cloudflare/src/deploy-help.ts +++ b/packages/cloudflare/src/deploy-help.ts @@ -14,12 +14,22 @@ export function formatDeployHelp(): string { --preview Deploy to preview environment (same as --env preview) --env Deploy using wrangler env. --name Custom Worker name (default: from package.json) + --config Wrangler config path (default: wrangler.jsonc/json/toml) --skip-build Skip the build step (use existing dist/) --dry-run Validate setup without building or deploying --prerender-all Pre-render discovered routes after building (future releases will auto-populate the remote cache) --prerender-concurrency Maximum number of routes to pre-render in parallel + --warm-cdn-cache Upload a Worker version, warm build-discovered paths + through the production URL, then promote it + --warm-cdn-concurrency + Maximum number of CDN warmup requests in parallel + --warm-cdn-timeout Per-request CDN warmup timeout (default: 5000) + --warm-cdn-retries 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: @@ -34,14 +44,19 @@ 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+ vp exec vinext-cloudflare deploy Run the locally installed Vite+ bin vinext-cloudflare deploy --preview Deploy to a preview URL vinext-cloudflare deploy --env staging Deploy using wrangler env.staging + vinext-cloudflare deploy --config dist/server/wrangler.json Deploy using a generated Wrangler config 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 --warm-cdn-cache Warm build-discovered 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 diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 9d7672768c..9e771c540e 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -15,6 +15,7 @@ import { createRequire } from "node:module"; import { spawn, type SpawnOptions } from "node:child_process"; import { parseArgs as nodeParseArgs } from "node:util"; import { pathToFileURL } from "node:url"; +import { emitPrerenderPathManifest } from "vinext/internal/build/prerender-paths"; import { runPrerender } from "vinext/internal/build/run-prerender"; import { loadDotenv } from "vinext/internal/config/dotenv"; import { loadNextConfig, resolveNextConfig } from "vinext/internal/config/next-config"; @@ -22,6 +23,7 @@ import { formatVinextPrerenderLabel, loadVinextCacheConfigFromViteConfig, loadVinextPrerenderConfigFromViteConfig, + loadVinextRouteRootConfigFromViteConfig, resolveVinextPrerenderDecision, } from "vinext/internal/config/prerender"; import { @@ -31,7 +33,8 @@ import { getMissingDeps, type ProjectInfo, } from "vinext/internal/utils/project"; -import { runTPR } from "./tpr.js"; +import { parseWranglerConfig, runTPR } from "./tpr.js"; +import { readPrerenderWarmPaths, warmCdnCache } from "./cdn-warm.js"; import { formatMissingCacheAdapterError, formatImageOptimizationHint, @@ -41,6 +44,16 @@ import { viteConfigHasImageAdapter, workerEntryHasCacheHandler, } from "./deploy-config.js"; +import { + runWranglerDeploymentStatus, + runWranglerTriggersDeploy, + runWranglerVersionDeploy, + runWranglerVersionUpload, + type WranglerDeploymentStatus, + type WranglerVersionTraffic, +} from "./version-deploy.js"; +import { parseWorkersDevUrl } from "./workers-dev-url.js"; +import { PHASE_PRODUCTION_BUILD } from "vinext/shims/constants"; import { buildPrerenderKVPairs, type KVBulkPair } from "./prerender-kv-populate.js"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -54,6 +67,8 @@ export type DeployOptions = { env?: string; /** Custom project name for the Worker */ name?: string; + /** Wrangler config path, relative to root unless absolute */ + config?: string; /** Skip the build step (assume already built) */ skipBuild?: boolean; /** Dry run — validate setup but don't build or deploy */ @@ -62,6 +77,18 @@ export type DeployOptions = { prerenderAll?: boolean; /** Maximum number of routes to prerender in parallel */ prerenderConcurrency?: number; + /** Warm Cloudflare's CDN cache by requesting build-discovered paths for the uploaded version */ + warmCdnCache?: boolean; + /** Maximum number of CDN warmup requests to issue in parallel */ + warmCdnConcurrency?: number; + /** Per-request CDN warmup timeout in milliseconds */ + warmCdnTimeout?: number; + /** Number of CDN warmup retries for transient failures */ + warmCdnRetries?: number; + /** Fail deployment if any CDN warmup request fails */ + warmCdnStrict?: boolean; + /** Include PPR fallback-shell placeholder paths during CDN warmup */ + warmCdnIncludeFallbacks?: boolean; /** Enable experimental TPR (Traffic-aware Pre-Rendering) */ experimentalTPR?: boolean; /** TPR: traffic coverage percentage target (0–100, default: 90) */ @@ -85,6 +112,17 @@ function parsePositiveIntegerArg(raw: string, flag: string): number { return parsed; } +function parseNonNegativeIntegerArg(raw: string, flag: string): number { + if (raw === "") { + throw new Error(`${flag} requires a value, but none was provided.`); + } + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`${flag} expects a non-negative integer, but got "${raw}".`); + } + return parsed; +} + function formatUnknownError(error: unknown): string { if (error instanceof Error && error.message) return error.message; return String(error); @@ -98,10 +136,17 @@ const deployArgOptions = { preview: { type: "boolean", default: false }, env: { type: "string" }, name: { type: "string" }, + config: { type: "string" }, "skip-build": { type: "boolean", default: false }, "dry-run": { type: "boolean", default: false }, "prerender-all": { type: "boolean", default: false }, "prerender-concurrency": { type: "string" }, + "warm-cdn-cache": { type: "boolean", default: false }, + "warm-cdn-concurrency": { type: "string" }, + "warm-cdn-timeout": { type: "string" }, + "warm-cdn-retries": { type: "string" }, + "warm-cdn-strict": { type: "boolean", default: false }, + "warm-cdn-include-fallbacks": { type: "boolean", default: false }, "experimental-tpr": { type: "boolean", default: false }, "tpr-coverage": { type: "string" }, "tpr-limit": { type: "string" }, @@ -126,6 +171,7 @@ export function parseDeployArgs(args: string[]) { preview: values.preview, env: values.env?.trim() || undefined, name: values.name?.trim() || undefined, + config: values.config?.trim() || undefined, skipBuild: values["skip-build"], dryRun: values["dry-run"], prerenderAll: values["prerender-all"], @@ -133,6 +179,21 @@ export function parseDeployArgs(args: string[]) { values["prerender-concurrency"] === undefined ? undefined : parsePositiveIntegerArg(values["prerender-concurrency"], "--prerender-concurrency"), + warmCdnCache: values["warm-cdn-cache"], + warmCdnConcurrency: + values["warm-cdn-concurrency"] === undefined + ? undefined + : parsePositiveIntegerArg(values["warm-cdn-concurrency"], "--warm-cdn-concurrency"), + warmCdnTimeout: + values["warm-cdn-timeout"] === undefined + ? undefined + : parsePositiveIntegerArg(values["warm-cdn-timeout"], "--warm-cdn-timeout"), + warmCdnRetries: + values["warm-cdn-retries"] === undefined + ? undefined + : parseNonNegativeIntegerArg(values["warm-cdn-retries"], "--warm-cdn-retries"), + warmCdnStrict: values["warm-cdn-strict"], + warmCdnIncludeFallbacks: values["warm-cdn-include-fallbacks"], experimentalTPR: values["experimental-tpr"], tprCoverage: parseIntArg("tpr-coverage", values["tpr-coverage"]), tprLimit: parseIntArg("tpr-limit", values["tpr-limit"]), @@ -268,10 +329,16 @@ export function validateWranglerEnvName(env: string): string { } export function buildWranglerDeployArgs( - options: Pick, + options: Pick, ): WranglerDeployArgs { const args = ["deploy"]; const env = options.env || (options.preview ? "preview" : undefined); + if (options.config) { + args.push("--config", options.config); + } + if (options.name) { + args.push("--name", options.name); + } if (env) { args.push("--env", validateWranglerEnvName(env)); } @@ -329,7 +396,7 @@ export function buildNodeCliInvocation( export function buildWranglerInvocation( root: string, - options: Pick, + options: Pick, nodeExecutable: string = process.execPath, ): { file: string; args: string[]; env: string | undefined } { const wranglerBin = resolveWranglerBin(root); @@ -391,7 +458,7 @@ export async function runWranglerKVBulkPut( export async function runWranglerDeploy( root: string, - options: Pick, + options: Pick, execute: typeof spawn = spawn, ): Promise { const spawnOptions: SpawnOptions = { @@ -437,12 +504,239 @@ export async function runWranglerDeploy( // Parse the deployed URL from wrangler output // Wrangler prints: "Published (version_id)\n https://..workers.dev" - const urlMatch = output.match(/https:\/\/[^\s]+\.workers\.dev[^\s]*/); - const deployedUrl = urlMatch ? urlMatch[0] : null; + const deployedUrl = parseWorkersDevUrl(output); return deployedUrl ?? "(URL not detected in wrangler output)"; } +export async function deployWithCdnWarmup( + root: string, + paths: readonly string[], + options: Pick< + DeployOptions, + | "preview" + | "env" + | "name" + | "config" + | "warmCdnConcurrency" + | "warmCdnTimeout" + | "warmCdnRetries" + | "warmCdnStrict" + >, +): Promise { + const upload = runWranglerVersionUpload(root, options); + const wranglerConfig = parseWranglerConfig(root, options.config); + const deploymentStatus = readWranglerDeploymentStatus(root, options); + const stagingTraffic = getZeroPercentStagingTraffic(deploymentStatus, upload.versionId); + let staged: ReturnType | null = null; + let triggersDeployedUrl: string | null = null; + let warmedBeforePromotion = false; + let triggersApplied = false; + + function applyTriggers(): void { + if (triggersApplied) return; + triggersDeployedUrl = runWranglerTriggersDeploy(root, options).deployedUrl; + triggersApplied = true; + } + + if (stagingTraffic) { + staged = runWranglerVersionDeploy(root, stagingTraffic, options, "stage"); + try { + applyTriggers(); + } catch (error) { + throw withStagedVersionCleanupNote(error); + } + const targetUrl = resolveCdnWarmupTargetUrl( + root, + staged.deployedUrl ?? triggersDeployedUrl, + options, + ); + const workerName = resolveWorkerNameForVersionOverride(wranglerConfig, options); + const headers = buildVersionOverrideHeaders(workerName, upload.versionId); + if (targetUrl && headers) { + try { + await warmCdnCache({ + targetUrl, + paths, + headers, + concurrency: options.warmCdnConcurrency, + timeoutMs: options.warmCdnTimeout, + retries: options.warmCdnRetries, + strict: options.warmCdnStrict, + }); + } catch (error) { + throw withStagedVersionCleanupNote(error); + } + warmedBeforePromotion = true; + } else if (options.warmCdnStrict) { + throw new Error( + "CDN warmup failed: pre-traffic warmup needs a production URL and Worker name for version overrides. " + + "Configure a route/custom domain and Worker name, or rerun without --warm-cdn-strict. " + + getStagedVersionCleanupNote(), + ); + } + } else { + console.warn( + " CDN warmup: pre-traffic version override skipped because the current deployment is not a single 100% version.", + ); + } + + const deployed = runWranglerVersionDeploy( + root, + [{ versionId: upload.versionId, percentage: 100 }], + options, + warmedBeforePromotion ? "promote-warmed" : "promote-uploaded", + ); + if (!warmedBeforePromotion) { + try { + applyTriggers(); + } catch (error) { + throw withPromotedVersionTriggerNote(error); + } + const targetUrl = resolveCdnWarmupTargetUrl( + root, + deployed.deployedUrl ?? triggersDeployedUrl, + options, + ); + if (targetUrl) { + await warmCdnCache({ + targetUrl, + paths, + concurrency: options.warmCdnConcurrency, + timeoutMs: options.warmCdnTimeout, + retries: options.warmCdnRetries, + strict: options.warmCdnStrict, + }); + } else if (options.warmCdnStrict) { + throw new Error( + "CDN warmup failed: no production URL could be inferred from wrangler config or output. " + + "Configure a route/custom domain, ensure Wrangler prints a workers.dev URL, or rerun without --warm-cdn-strict.", + ); + } else { + console.warn( + " CDN warmup skipped: no production URL could be inferred from wrangler config or output.", + ); + } + } + return ( + deployed.deployedUrl ?? + triggersDeployedUrl ?? + staged?.deployedUrl ?? + upload.previewUrl ?? + "(URL not detected in wrangler output)" + ); +} + +export function resolveCdnWarmupTargetUrl(root: string, deployedUrl: string | null): string | null; +export function resolveCdnWarmupTargetUrl( + root: string, + deployedUrl: string | null, + options: Pick, +): string | null; +export function resolveCdnWarmupTargetUrl( + root: string, + deployedUrl: string | null, + options?: Pick, +): string | null { + const config = parseWranglerConfig(root, options?.config); + const env = getWranglerTargetEnv(options ?? {}); + const customDomain = (env ? config?.env?.[env]?.customDomain : undefined) ?? config?.customDomain; + if (customDomain) { + return `https://${customDomain}`; + } + return deployedUrl; +} + +function readWranglerDeploymentStatus( + root: string, + options: Pick, +): WranglerDeploymentStatus | null { + try { + return runWranglerDeploymentStatus(root, options); + } catch { + return null; + } +} + +export function getZeroPercentStagingTraffic( + deployment: WranglerDeploymentStatus | null, + versionId: string, +): WranglerVersionTraffic[] | null { + const current = deployment?.versions ?? []; + if (current.length !== 1 || current[0].percentage !== 100) { + return null; + } + if (current[0].versionId === versionId) { + return null; + } + return [current[0], { versionId, percentage: 0 }]; +} + +function getWranglerTargetEnv(options: Pick): string | undefined { + return options.env || (options.preview ? "preview" : undefined); +} + +type ParsedWranglerConfig = NonNullable>; + +export function resolveWorkerNameForVersionOverride( + config: ParsedWranglerConfig | null, + options: Pick, +): string | undefined { + if (options.name) { + return options.name; + } + + const env = getWranglerTargetEnv(options); + if (!env) { + return config?.name; + } + + if (config?.legacyEnv === false) { + return config.name; + } + + return config?.env?.[env]?.name ?? (config?.name ? `${config.name}-${env}` : undefined); +} + +function quoteStructuredHeaderString(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +export function buildVersionOverrideHeaders( + workerName: string | undefined, + versionId: string, +): HeadersInit | undefined { + if (!workerName) return undefined; + return { + "Cloudflare-Workers-Version-Overrides": `${workerName}=${quoteStructuredHeaderString(versionId)}`, + }; +} + +function withStagedVersionCleanupNote(error: unknown): Error { + const message = error instanceof Error ? error.message : String(error); + return new Error(`${message} ${getStagedVersionCleanupNote()}`, { + cause: error, + }); +} + +function getStagedVersionCleanupNote(): string { + return ( + "The uploaded version may remain staged at 0% with the previous version still serving 100% traffic; " + + "rerun deploy to promote it or use `wrangler versions deploy` to choose the desired version split." + ); +} + +function withPromotedVersionTriggerNote(error: unknown): Error { + const message = error instanceof Error ? error.message : String(error); + return new Error( + `${message} The uploaded version may already be promoted to 100%, but Worker triggers/routes may not be updated; ` + + "rerun deploy or `wrangler triggers deploy` after fixing the trigger error.", + { + cause: error, + }, + ); +} + // ─── Main Entry ────────────────────────────────────────────────────────────── export async function deploy(options: DeployOptions): Promise { @@ -520,45 +814,57 @@ export async function deploy(options: DeployOptions): Promise { return; } - // Step 5: Build + const rawNextConfig = await loadNextConfig(info.root, PHASE_PRODUCTION_BUILD); + const nextConfig = await resolveNextConfig(rawNextConfig, info.root); const buildEnv = deployEnv === "production" && !options.env ? undefined : deployEnv; + + const shouldLoadVinextPrerenderConfig = !options.prerenderAll && nextConfig.output !== "export"; + const vinextPrerenderConfig = shouldLoadVinextPrerenderConfig + ? await withCloudflareEnv(buildEnv, async () => { + const vite = await loadProjectViteApi(info.root); + return loadVinextPrerenderConfigFromViteConfig(vite, info.root); + }) + : null; + const prerenderDecision = resolveVinextPrerenderDecision({ + prerenderAllFlag: options.prerenderAll, + vinextPrerenderConfig, + nextOutput: nextConfig.output, + }); + const shouldEmitPrerenderPathManifest = + options.warmCdnCache || (!options.skipBuild && prerenderDecision); + + // Step 5: Build if (!options.skipBuild) { await runBuild(info, buildEnv); } else { console.log("\n Skipping build (--skip-build)"); } + if (shouldEmitPrerenderPathManifest) { + const routeRootConfig = await withCloudflareEnv(buildEnv, async () => { + const vite = await loadProjectViteApi(info.root); + return loadVinextRouteRootConfigFromViteConfig(vite, info.root); + }); + await emitPrerenderPathManifest({ + root: info.root, + nextConfigOverride: nextConfig, + routeRootConfig, + }); + } + // Step 6a: prerender — render every discovered route into dist. // Triggered by --prerender-all, vinext({ prerender: true }), or automatically // when next.config.js sets `output: 'export'` (every route must be statically // exportable). The CLI flag wins when more than one trigger is present. let ranPrerender = false; - { - const rawNextConfig = await loadNextConfig(info.root); - const nextConfig = await resolveNextConfig(rawNextConfig, info.root); - - const shouldLoadVinextPrerenderConfig = !options.prerenderAll && nextConfig.output !== "export"; - const vinextPrerenderConfig = shouldLoadVinextPrerenderConfig - ? await withCloudflareEnv(buildEnv, async () => { - const vite = await loadProjectViteApi(info.root); - return loadVinextPrerenderConfigFromViteConfig(vite, info.root); - }) - : null; - const prerenderDecision = resolveVinextPrerenderDecision({ - prerenderAllFlag: options.prerenderAll, - vinextPrerenderConfig, - nextOutput: nextConfig.output, - }); - - if (prerenderDecision) { - console.log(`\n ${formatVinextPrerenderLabel(prerenderDecision)}`); - if (nextConfig.enablePrerenderSourceMaps) { - process.setSourceMapsEnabled(true); - Error.stackTraceLimit = Math.max(Error.stackTraceLimit, 50); - } - await runPrerender({ root: info.root, concurrency: options.prerenderConcurrency }); - ranPrerender = true; + if (prerenderDecision) { + console.log(`\n ${formatVinextPrerenderLabel(prerenderDecision)}`); + if (nextConfig.enablePrerenderSourceMaps) { + process.setSourceMapsEnabled(true); + Error.stackTraceLimit = Math.max(Error.stackTraceLimit, 50); } + await runPrerender({ root: info.root, concurrency: options.prerenderConcurrency }); + ranPrerender = true; } if (ranPrerender) { @@ -580,6 +886,7 @@ export async function deploy(options: DeployOptions): Promise { console.log(); const tprResult = await runTPR({ root, + config: options.config, coverage: Math.max(1, Math.min(100, options.tprCoverage ?? 90)), limit: Math.max(1, options.tprLimit ?? 1000), window: Math.max(1, options.tprWindow ?? 24), @@ -591,9 +898,33 @@ export async function deploy(options: DeployOptions): Promise { } // Step 7: Deploy via wrangler - const url = await runWranglerDeploy(root, { + const wranglerOptions = { env: deployEnv === "production" && !options.env ? undefined : deployEnv, - }); + name: options.name, + config: options.config, + }; + let url: string; + + if (options.warmCdnCache) { + const warmPaths = readPrerenderWarmPaths(root, { + includeFallbackShells: options.warmCdnIncludeFallbacks, + strict: options.warmCdnStrict, + }); + if (warmPaths.length > 0) { + url = await deployWithCdnWarmup(root, warmPaths, { + ...wranglerOptions, + warmCdnConcurrency: options.warmCdnConcurrency, + warmCdnTimeout: options.warmCdnTimeout, + warmCdnRetries: options.warmCdnRetries, + warmCdnStrict: options.warmCdnStrict, + }); + } else { + console.log("\n CDN warmup skipped: no build-discovered paths found."); + url = await runWranglerDeploy(root, wranglerOptions); + } + } else { + url = await runWranglerDeploy(root, wranglerOptions); + } console.log("\n ─────────────────────────────────────────"); console.log(` Deployed to: ${url}`); diff --git a/packages/cloudflare/src/tpr.ts b/packages/cloudflare/src/tpr.ts index 95d953e674..ce1d4e9733 100644 --- a/packages/cloudflare/src/tpr.ts +++ b/packages/cloudflare/src/tpr.ts @@ -35,6 +35,8 @@ import { ENTRY_PREFIX } from "@vinext/cloudflare/cache/kv-data-adapter.runtime"; export type TPROptions = { /** Project root directory. */ root: string; + /** Wrangler config path, relative to root unless absolute. */ + config?: string; /** Traffic coverage percentage (0–100). Default: 90. */ coverage: number; /** Hard cap on number of pages to pre-render. Default: 1000. */ @@ -78,6 +80,14 @@ type WranglerConfig = { accountId?: string; kvNamespaceId?: string; customDomain?: string; + name?: string; + legacyEnv?: boolean; + env?: Record; +}; + +export type WranglerEnvironmentConfig = { + customDomain?: string; + name?: string; }; // ─── Wrangler Config Parsing ───────────────────────────────────────────────── @@ -86,14 +96,29 @@ type WranglerConfig = { * Parse wrangler config (JSONC or TOML) to extract the fields TPR needs: * account_id, VINEXT_KV_CACHE KV namespace ID, and custom domain. */ -export function parseWranglerConfig(root: string): WranglerConfig | null { +export function parseWranglerConfig(root: string, configPath?: string): WranglerConfig | null { + if (configPath) { + const filepath = path.resolve(root, configPath); + if (!fs.existsSync(filepath)) return null; + const content = fs.readFileSync(filepath, "utf-8"); + if (filepath.endsWith(".toml")) { + return extractFromTOML(content); + } + try { + const json = JSON.parse(stripJsonCommentsAndTrailingCommas(content)); + return extractFromJSON(json); + } catch { + return null; + } + } + // Try JSONC / JSON first for (const filename of ["wrangler.jsonc", "wrangler.json"]) { const filepath = path.join(root, filename); if (fs.existsSync(filepath)) { const content = fs.readFileSync(filepath, "utf-8"); try { - const json = JSON.parse(stripJsonComments(content)); + const json = JSON.parse(stripJsonCommentsAndTrailingCommas(content)); return extractFromJSON(json); } catch { continue; @@ -112,10 +137,10 @@ export function parseWranglerConfig(root: string): WranglerConfig | null { } /** - * Strip single-line (//) and multi-line comments from JSONC while - * preserving strings that contain slashes. + * Strip single-line (//), multi-line comments, and trailing commas from JSONC + * while preserving strings that contain comment-like text or commas. */ -function stripJsonComments(str: string): string { +function stripJsonCommentsAndTrailingCommas(str: string): string { let result = ""; let inString = false; let inSingleLine = false; @@ -178,15 +203,59 @@ function stripJsonComments(str: string): string { continue; } + if (!inString && ch === "," && isJsonTrailingComma(str, i + 1)) { + continue; + } + result += ch; } return result; } +function isJsonTrailingComma(str: string, start: number): boolean { + for (let i = start; i < str.length; i++) { + const ch = str[i]; + const next = str[i + 1]; + if (ch === undefined) return false; + if (/\s/.test(ch)) { + continue; + } + if (ch === "/" && next === "/") { + i += 2; + while (i < str.length && str[i] !== "\n") { + i++; + } + continue; + } + if (ch === "/" && next === "*") { + i += 2; + while (i < str.length) { + if (str[i] === "*" && str[i + 1] === "/") { + i++; + break; + } + i++; + } + continue; + } + return ch === "}" || ch === "]"; + } + + return false; +} + function extractFromJSON(config: Record): WranglerConfig { const result: WranglerConfig = {}; + if (typeof config.name === "string" && config.name.length > 0) { + result.name = config.name; + } + + if (typeof config.legacy_env === "boolean") { + result.legacyEnv = config.legacy_env; + } + // account_id if (typeof config.account_id === "string") { result.accountId = config.account_id; @@ -209,6 +278,33 @@ function extractFromJSON(config: Record): WranglerConfig { const domain = extractDomainFromRoutes(config.routes) ?? extractDomainFromCustomDomains(config); if (domain) result.customDomain = domain; + const env = extractEnvConfigs(config.env); + if (env) result.env = env; + + return result; +} + +function extractEnvConfigs(envs: unknown): Record | undefined { + if (!envs || typeof envs !== "object" || Array.isArray(envs)) return undefined; + + const result: Record = {}; + for (const [envName, rawConfig] of Object.entries(envs)) { + if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) continue; + const envConfig = extractEnvironmentConfig(rawConfig as Record); + if (envConfig.name || envConfig.customDomain) { + result[envName] = envConfig; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + +function extractEnvironmentConfig(config: Record): WranglerEnvironmentConfig { + const result: WranglerEnvironmentConfig = {}; + if (typeof config.name === "string" && config.name.length > 0) { + result.name = config.name; + } + const domain = extractDomainFromRoutes(config.routes) ?? extractDomainFromCustomDomains(config); + if (domain) result.customDomain = domain; return result; } @@ -265,6 +361,12 @@ function cleanDomain(raw: string): string | null { function extractFromTOML(content: string): WranglerConfig { const result: WranglerConfig = {}; + const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m); + if (nameMatch) result.name = nameMatch[1]; + + const legacyEnvMatch = content.match(/^legacy_env\s*=\s*(true|false)\s*$/m); + if (legacyEnvMatch) result.legacyEnv = legacyEnvMatch[1] === "true"; + // account_id = "..." const accountMatch = content.match(/^account_id\s*=\s*"([^"]+)"/m); if (accountMatch) result.accountId = accountMatch[1]; @@ -311,9 +413,104 @@ function extractFromTOML(content: string): WranglerConfig { } } + const env = extractEnvConfigsFromTOML(content); + if (env) result.env = env; + return result; } +function extractEnvConfigsFromTOML( + content: string, +): Record | undefined { + const result: Record = {}; + + for (const section of getTomlSections(content)) { + const envName = section.header.match(/^env\.([^.]+)$/)?.[1]; + if (envName) { + const envConfig = result[envName] ?? {}; + const nameMatch = section.body.match(/^name\s*=\s*"([^"]+)"/m); + if (nameMatch) envConfig.name = nameMatch[1]; + const domain = + extractTomlScalarRouteDomain(section.body) ?? extractTomlRoutesArrayDomain(section.body); + if (domain) envConfig.customDomain = domain; + if (envConfig.name || envConfig.customDomain) { + result[envName] = envConfig; + } + continue; + } + + const routesEnvName = section.header.match(/^env\.([^.]+)\.routes$/)?.[1]; + if (routesEnvName) { + const envConfig = result[routesEnvName] ?? {}; + const domain = extractTomlRouteBlockDomain(section.body); + if (domain) envConfig.customDomain = domain; + if (envConfig.name || envConfig.customDomain) { + result[routesEnvName] = envConfig; + } + } + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +function getTomlSections(content: string): Array<{ header: string; body: string }> { + const sections: Array<{ header: string; body: string }> = []; + let currentHeader: string | null = null; + let currentBody: string[] = []; + + for (const line of content.split("\n")) { + const header = parseTomlSectionHeader(line); + if (header) { + if (currentHeader) { + sections.push({ header: currentHeader, body: currentBody.join("\n") }); + } + currentHeader = header; + currentBody = []; + } else if (currentHeader) { + currentBody.push(line); + } + } + + if (currentHeader) { + sections.push({ header: currentHeader, body: currentBody.join("\n") }); + } + + return sections; +} + +function parseTomlSectionHeader(line: string): string | null { + const trimmed = line.trim(); + if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null; + const isArrayHeader = trimmed.startsWith("[[") && trimmed.endsWith("]]"); + const start = isArrayHeader ? 2 : 1; + const end = isArrayHeader ? trimmed.length - 2 : trimmed.length - 1; + const header = trimmed.slice(start, end).trim(); + return header.length > 0 ? header : null; +} + +function extractTomlScalarRouteDomain(section: string): string | null { + const routeMatch = section.match(/^route\s*=\s*"([^"]+)"/m); + if (!routeMatch) return null; + const domain = cleanDomain(routeMatch[1]); + return domain && !domain.includes("workers.dev") ? domain : null; +} + +function extractTomlRoutesArrayDomain(section: string): string | null { + const routesMatch = section.match(/^routes\s*=\s*\[([\s\S]*?)\]/m); + if (!routesMatch) return null; + const patternMatch = (routesMatch[1] ?? "").match(/(?:pattern\s*=\s*)?"([^"]+)"/); + if (!patternMatch) return null; + const domain = cleanDomain(patternMatch[1]); + return domain && !domain.includes("workers.dev") ? domain : null; +} + +function extractTomlRouteBlockDomain(section: string): string | null { + const patternMatch = section.match(/^(?:pattern|zone_name)\s*=\s*"([^"]+)"/m); + if (!patternMatch) return null; + const domain = cleanDomain(patternMatch[1]); + return domain && !domain.includes("workers.dev") ? domain : null; +} + // ─── Cloudflare API ────────────────────────────────────────────────────────── /** @@ -811,7 +1008,7 @@ const DEFAULT_REVALIDATE_SECONDS = 3600; */ export async function runTPR(options: TPROptions): Promise { const startTime = Date.now(); - const { root, coverage, limit, window: windowHours } = options; + const { root, config, coverage, limit, window: windowHours } = options; const skip = (reason: string): TPRResult => ({ totalPaths: 0, @@ -828,7 +1025,7 @@ export async function runTPR(options: TPROptions): Promise { } // ── 2. Parse wrangler config ────────────────────────────────── - const wranglerConfig = parseWranglerConfig(root); + const wranglerConfig = parseWranglerConfig(root, config); if (!wranglerConfig) { return skip("could not parse wrangler config"); } diff --git a/packages/cloudflare/src/version-deploy.ts b/packages/cloudflare/src/version-deploy.ts new file mode 100644 index 0000000000..5a3993d7d5 --- /dev/null +++ b/packages/cloudflare/src/version-deploy.ts @@ -0,0 +1,326 @@ +import { execFileSync, type ExecFileSyncOptions } from "node:child_process"; +import { + buildNodeCliInvocation, + resolveWranglerBin, + validateWranglerEnvName, + type DeployOptions, +} from "./deploy.js"; +import { parseWorkersDevUrl } from "./workers-dev-url.js"; + +export { parseWorkersDevUrl } from "./workers-dev-url.js"; + +export type WranglerVersionUploadResult = { + versionId: string; + previewUrl: string | null; + output: string; +}; + +export type WranglerVersionDeployResult = { + deployedUrl: string | null; + output: string; +}; + +export type WranglerVersionTraffic = { + versionId: string; + percentage: number; +}; + +export type WranglerDeploymentStatus = { + versions: WranglerVersionTraffic[]; + output: string; +}; + +type WranglerVersionArgs = { + args: string[]; + env: string | undefined; +}; + +type JsonRecord = Record; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseJsonObject(output: string): JsonRecord | unknown[] | null { + const trimmed = output.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null; + try { + const parsed: unknown = JSON.parse(trimmed); + return isRecord(parsed) || Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +function findStringInRecord(value: unknown, keys: readonly string[]): string | null { + if (!isRecord(value)) return null; + for (const key of keys) { + const field = value[key]; + if (typeof field === "string" && field.length > 0) return field; + } + return null; +} + +function findVersionIdInUploadJson(parsed: JsonRecord | unknown[] | null): string | null { + if (!isRecord(parsed)) return null; + return ( + findStringInRecord(parsed, ["version_id", "versionId", "id"]) ?? + findStringInRecord(parsed.version, ["version_id", "versionId", "id"]) ?? + findStringInRecord(parsed.result, ["version_id", "versionId", "id"]) + ); +} + +function findPreviewUrlInUploadJson(parsed: JsonRecord | unknown[] | null): string | null { + if (!isRecord(parsed)) return null; + return ( + findStringInRecord(parsed, ["preview_url", "previewUrl", "url"]) ?? + findStringInRecord(parsed.version, ["preview_url", "previewUrl", "url"]) ?? + findStringInRecord(parsed.result, ["preview_url", "previewUrl", "url"]) + ); +} + +export function parseVersionId(output: string): string | null { + return ( + output.match( + /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/, + )?.[0] ?? null + ); +} + +export function parseWranglerVersionUploadOutput(output: string): WranglerVersionUploadResult { + const parsed = parseJsonObject(output); + const versionId = findVersionIdInUploadJson(parsed) ?? parseVersionId(output); + const previewUrl = findPreviewUrlInUploadJson(parsed) ?? parseWorkersDevUrl(output); + + if (!versionId) { + throw new Error("Could not detect Worker version ID from `wrangler versions upload` output."); + } + + return { versionId, previewUrl, output }; +} + +export function buildWranglerVersionUploadArgs( + options: Pick & { previewAlias?: string }, +): WranglerVersionArgs { + const args = ["versions", "upload"]; + const env = options.env || (options.preview ? "preview" : undefined); + if (options.config) { + args.push("--config", options.config); + } + if (options.name) { + args.push("--name", options.name); + } + if (env) { + args.push("--env", validateWranglerEnvName(env)); + } + if (options.previewAlias) { + args.push("--preview-alias", options.previewAlias); + } + return { args, env }; +} + +export function buildWranglerVersionDeployArgs( + versionTraffic: readonly WranglerVersionTraffic[], + options: Pick, +): WranglerVersionArgs { + const args = [ + "versions", + "deploy", + ...versionTraffic.map(({ versionId, percentage }) => `${versionId}@${percentage}%`), + "--yes", + ]; + const env = options.env || (options.preview ? "preview" : undefined); + if (options.config) { + args.push("--config", options.config); + } + if (options.name) { + args.push("--name", options.name); + } + if (env) { + args.push("--env", validateWranglerEnvName(env)); + } + return { args, env }; +} + +export function buildWranglerDeploymentsStatusArgs( + options: Pick, +): WranglerVersionArgs { + const args = ["deployments", "status", "--json"]; + const env = options.env || (options.preview ? "preview" : undefined); + if (options.config) { + args.push("--config", options.config); + } + if (options.name) { + args.push("--name", options.name); + } + if (env) { + args.push("--env", validateWranglerEnvName(env)); + } + return { args, env }; +} + +export function buildWranglerTriggersDeployArgs( + options: Pick, +): WranglerVersionArgs { + const args = ["triggers", "deploy"]; + const env = options.env || (options.preview ? "preview" : undefined); + if (options.config) { + args.push("--config", options.config); + } + if (options.name) { + args.push("--name", options.name); + } + if (env) { + args.push("--env", validateWranglerEnvName(env)); + } + return { args, env }; +} + +function runWranglerCommand( + root: string, + args: string[], + execute: typeof execFileSync = execFileSync, +): string { + const wranglerBin = resolveWranglerBin(root); + const invocation = buildNodeCliInvocation(wranglerBin, args); + const execOpts: ExecFileSyncOptions = { + cwd: root, + stdio: "pipe", + encoding: "utf-8", + shell: false, + }; + const output = execute(invocation.file, invocation.args, execOpts) as string; + if (output.trim()) { + for (const line of output.trim().split("\n")) { + console.log(` ${line}`); + } + } + return output; +} + +function errorText(error: unknown): string { + if (!error || typeof error !== "object") return String(error); + const parts = error instanceof Error ? [error.message] : []; + const record = error as Record; + for (const field of ["stdout", "stderr", "output"] as const) { + const value = record[field]; + if (Array.isArray(value)) { + parts.push( + ...value + .filter( + (entry): entry is string | Buffer => + typeof entry === "string" || Buffer.isBuffer(entry), + ) + .map((entry) => entry.toString()), + ); + } else if (typeof value === "string" || Buffer.isBuffer(value)) { + parts.push(value.toString()); + } + } + return parts.join("\n"); +} + +function isMissingWorkerVersionUploadError(error: unknown): boolean { + return /cannot upload a new version of a Worker that does not yet exist/i.test(errorText(error)); +} + +function withInitialDeployRequiredMessage(): Error { + const message = + "CDN pre-warm needs an existing Cloudflare Worker before it can upload a new Worker version. " + + "Run `vinext-cloudflare deploy` once without `--warm-cdn-cache` to create the Worker, then rerun your pre-warm deploy."; + return new Error(message); +} + +function parseDeploymentVersions(value: unknown): WranglerVersionTraffic[] { + const deployment = Array.isArray(value) ? value.at(-1) : value; + if (!isRecord(deployment) || !Array.isArray(deployment.versions)) return []; + + const versions: WranglerVersionTraffic[] = []; + for (const version of deployment.versions) { + if (!isRecord(version)) continue; + const versionId = version.version_id; + const percentage = version.percentage; + if (typeof versionId !== "string" || typeof percentage !== "number") continue; + versions.push({ versionId, percentage }); + } + return versions; +} + +export function parseWranglerDeploymentStatusOutput(output: string): WranglerDeploymentStatus { + const parsed = parseJsonObject(output); + if (!parsed) { + throw new Error("Could not parse `wrangler deployments status --json` output."); + } + + return { versions: parseDeploymentVersions(parsed), output }; +} + +export function runWranglerVersionUpload( + root: string, + options: Pick & { previewAlias?: string }, + execute: typeof execFileSync = execFileSync, +): WranglerVersionUploadResult { + const { args, env } = buildWranglerVersionUploadArgs(options); + if (env) { + console.log(`\n Uploading Worker version for env: ${env}...`); + } else { + console.log("\n Uploading Worker version for production..."); + } + try { + return parseWranglerVersionUploadOutput(runWranglerCommand(root, args, execute)); + } catch (error) { + if (isMissingWorkerVersionUploadError(error)) { + throw withInitialDeployRequiredMessage(); + } + throw error; + } +} + +export function runWranglerVersionDeploy( + root: string, + versionTraffic: readonly WranglerVersionTraffic[], + options: Pick, + phase: "stage" | "promote-warmed" | "promote-uploaded" = "promote-uploaded", + execute: typeof execFileSync = execFileSync, +): WranglerVersionDeployResult { + const { args, env } = buildWranglerVersionDeployArgs(versionTraffic, options); + const target = env ? `env: ${env}` : "production"; + if (phase === "stage") { + console.log(`\n Staging uploaded Worker version at 0% for CDN warmup in ${target}...`); + } else if (phase === "promote-warmed") { + console.log(`\n Promoting warmed Worker version to ${target}...`); + } else { + console.log(`\n Promoting uploaded Worker version to ${target}...`); + } + const output = runWranglerCommand(root, args, execute); + return { deployedUrl: parseWorkersDevUrl(output), output }; +} + +export function runWranglerDeploymentStatus( + root: string, + options: Pick, + execute: typeof execFileSync = execFileSync, +): WranglerDeploymentStatus { + const { args, env } = buildWranglerDeploymentsStatusArgs(options); + if (env) { + console.log(`\n Reading current Worker deployment for env: ${env}...`); + } else { + console.log("\n Reading current Worker deployment..."); + } + return parseWranglerDeploymentStatusOutput(runWranglerCommand(root, args, execute)); +} + +export function runWranglerTriggersDeploy( + root: string, + options: Pick, + execute: typeof execFileSync = execFileSync, +): WranglerVersionDeployResult { + const { args, env } = buildWranglerTriggersDeployArgs(options); + if (env) { + console.log(`\n Applying Worker triggers for env: ${env}...`); + } else { + console.log("\n Applying Worker triggers..."); + } + const output = runWranglerCommand(root, args, execute); + return { deployedUrl: parseWorkersDevUrl(output), output }; +} diff --git a/packages/cloudflare/src/workers-dev-url.ts b/packages/cloudflare/src/workers-dev-url.ts new file mode 100644 index 0000000000..68f2fe2a08 --- /dev/null +++ b/packages/cloudflare/src/workers-dev-url.ts @@ -0,0 +1,49 @@ +export function parseWorkersDevUrl(output: string): string | null { + for (const rawToken of splitWhitespace(output)) { + const candidate = trimUrlPunctuation(rawToken); + if (!candidate.startsWith("https://")) continue; + try { + const url = new URL(candidate); + if (url.protocol !== "https:") continue; + if (url.hostname !== "workers.dev" && !url.hostname.endsWith(".workers.dev")) continue; + if (url.pathname === "/" && url.search === "" && url.hash === "") { + return url.origin; + } + return url.toString(); + } catch { + continue; + } + } + return null; +} + +function splitWhitespace(value: string): string[] { + const tokens: string[] = []; + let tokenStart: number | null = null; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + if (char === undefined) continue; + if (char.trim() === "") { + if (tokenStart !== null) { + tokens.push(value.slice(tokenStart, i)); + tokenStart = null; + } + } else if (tokenStart === null) { + tokenStart = i; + } + } + + if (tokenStart !== null) { + tokens.push(value.slice(tokenStart)); + } + return tokens; +} + +function trimUrlPunctuation(value: string): string { + let start = 0; + let end = value.length; + while (start < end && "\"'(<".includes(value[start]!)) start++; + while (end > start && "\"')>.,;".includes(value[end - 1]!)) end--; + return value.slice(start, end); +} diff --git a/packages/create-vinext-app/src/index.ts b/packages/create-vinext-app/src/index.ts index 8eee2c2fe8..1340580ae7 100644 --- a/packages/create-vinext-app/src/index.ts +++ b/packages/create-vinext-app/src/index.ts @@ -19,6 +19,7 @@ type CloudflareInitOptions = { dataCache: InitDataCache; cdnCache: InitCdnCache; imageOptimization: InitImageOptimization; + warmCdnCache?: boolean; }; type PlatformPromptOptions = { @@ -68,7 +69,7 @@ const packageManagerFlags: Record = { "--use-bun": "bun", }; -function getTemplateFiles(platform: InitPlatform): Record { +function getTemplateFiles(platform: InitPlatform, warmCdnCache = false): Record { const isCloudflare = platform === "cloudflare"; const apiMessage = isCloudflare ? "Hello from vinext on Cloudflare Workers" : "Hello from vinext"; const title = isCloudflare ? "vinext on Cloudflare Workers" : "vinext app"; @@ -89,11 +90,14 @@ function getTemplateFiles(platform: InitPlatform): Record { ? "This App Router project is wired for vinext, Tailwind CSS, and Cloudflare Workers." : "This App Router project is wired for vinext and Tailwind CSS."; const buildOutput = isCloudflare ? "Worker-ready production output" : "production output"; + const deployCommand = warmCdnCache + ? "pnpm exec vinext-cloudflare deploy --warm-cdn-cache" + : "pnpm exec vinext-cloudflare deploy"; const actionCard = isCloudflare ? `

Deploy

Ship the generated Worker with Wrangler.

- pnpm exec vinext-cloudflare deploy + ${deployCommand}
` : `

Start

@@ -150,6 +154,8 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac ${secondaryLink} ]; +export const revalidate = 300; + export default function Home() { return (
@@ -282,6 +288,8 @@ function printHelp(): void { --image-optimization Cloudflare image optimization: cloudflare-images or none --prerender Configure vinext to pre-render static routes --no-prerender Do not configure pre-rendering + --warm-cdn-cache Add CDN pre-warming to the Cloudflare deploy script + --no-warm-cdn-cache Do not add CDN pre-warming to the deploy script --use-npm Use npm --use-pnpm Use pnpm --use-yarn Use Yarn @@ -432,11 +440,16 @@ function writeTemplate( root: string, appName: string, packageManager: PackageManagerName, - platform: InitPlatform, + initOptions: ResolvedInitOptions, ): void { fs.mkdirSync(root, { recursive: true }); writePackageJson(root, appName, packageManager); - for (const [relativePath, content] of Object.entries(getTemplateFiles(platform))) { + const warmCdnCache = + initOptions.platform === "cloudflare" && + (initOptions.cloudflare?.warmCdnCache ?? initOptions.cloudflare?.cdnCache === "workers-cache"); + for (const [relativePath, content] of Object.entries( + getTemplateFiles(initOptions.platform, warmCdnCache), + )) { writeFile(root, relativePath, content); } } @@ -462,7 +475,7 @@ export async function createVinextApp(options: CreateVinextAppOptions): Promise< } console.log(`Creating a new vinext app in ${root}.\n`); - writeTemplate(root, appName, options.packageManager, options.initOptions.platform); + writeTemplate(root, appName, options.packageManager, options.initOptions); await init({ root, diff --git a/packages/vinext/package.json b/packages/vinext/package.json index 41692bdbc4..66c179b0d8 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -89,6 +89,10 @@ "types": "./dist/build/run-prerender.d.ts", "import": "./dist/build/run-prerender.js" }, + "./internal/build/prerender-paths": { + "types": "./dist/build/prerender-paths.d.ts", + "import": "./dist/build/prerender-paths.js" + }, "./internal/cache-adapters": { "types": "./dist/cache/cache-adapters-virtual.d.ts", "import": "./dist/cache/cache-adapters-virtual.js" diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts new file mode 100644 index 0000000000..9d0d34d646 --- /dev/null +++ b/packages/vinext/src/build/prerender-paths.ts @@ -0,0 +1,461 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Server as HttpServer } from "node:http"; +import { loadNextConfig, resolveNextConfig } from "../config/next-config.js"; +import { appRouter } from "../routing/app-router.js"; +import { apiRouter, pagesRouter } from "../routing/pages-router.js"; +import { normalizeStaticPathsEntry, type StaticPathsEntry } from "../routing/route-pattern.js"; +import { + getAppRouteRenderEntryPath, + classifyAppRoute, + classifyPagesRoute, + hasNamedExport, +} from "./report.js"; +import { buildUrlFromParams, resolveParentParams, type StaticParamsMap } from "./prerender.js"; +import { readPrerenderSecret } from "./server-manifest.js"; +import { startProdServer } from "../server/prod-server.js"; +import { findDir } from "../utils/project.js"; +import { normalizePathSeparators } from "../utils/path.js"; +import { BLOCKED_PAGES, PHASE_PRODUCTION_BUILD } from "vinext/shims/constants"; +import { VINEXT_PRERENDER_SECRET_HEADER } from "../server/headers.js"; +import type { VinextRouteRootConfig } from "../config/prerender.js"; + +export type PrerenderPathManifest = { + buildId?: string; + trailingSlash?: boolean; + paths: string[]; +}; + +export const PRERENDER_PATH_DISCOVERY_ENV = "__VINEXT_PRERENDER_PATH_DISCOVERY"; +export const PRERENDER_PATHS_MANIFEST = "vinext-prerender-paths.json"; + +const PATH_DISCOVERY_FETCH_TIMEOUT_MS = 30_000; + +type EmitPrerenderPathManifestOptions = { + root: string; + nextConfigOverride?: Partial; + appDir?: string | null; + pagesDir?: string | null; + routeRootConfig?: VinextRouteRootConfig | null; + pagesBundlePath?: string; + rscBundlePath?: string; +}; + +function readBuiltBuildId(serverDir: string): string | null { + try { + const buildId = fs.readFileSync(path.join(serverDir, "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 addPath(paths: string[], seen: Set, pathname: string): void { + if (seen.has(pathname)) return; + seen.add(pathname); + paths.push(pathname); +} + +function warnDiscoveryFailure(route: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[vinext] Warning: failed to discover warmup path(s) for ${route}: ${message}`); +} + +async function fetchDiscoveryEndpoint( + url: string, + headers: Record, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PATH_DISCOVERY_FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { + headers, + signal: controller.signal, + }); + const text = await res.text(); + if (!res.ok || text === "null") return null; + return text; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`path discovery timed out after ${PATH_DISCOVERY_FETCH_TIMEOUT_MS}ms`); + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +function fileHasNamedExport(filePath: string | null | undefined, name: string): boolean { + if (!filePath) return false; + try { + return hasNamedExport(fs.readFileSync(filePath, "utf-8"), name); + } catch { + return false; + } +} + +function resolveConfiguredRouteDirs( + root: string, + routeRootConfig: VinextRouteRootConfig | null | undefined, +): { appDir: string | null; pagesDir: string | null } { + if (!routeRootConfig) { + return { + appDir: findDir(root, "app", "src/app"), + pagesDir: findDir(root, "pages", "src/pages"), + }; + } + + let baseDir: string; + if (routeRootConfig.appDir) { + baseDir = path.isAbsolute(routeRootConfig.appDir) + ? routeRootConfig.appDir + : path.resolve(root, routeRootConfig.appDir); + baseDir = normalizePathSeparators(baseDir); + } else { + const hasRootApp = fs.existsSync(path.posix.join(root, "app")); + const hasRootPages = fs.existsSync(path.posix.join(root, "pages")); + const hasSrcApp = fs.existsSync(path.posix.join(root, "src", "app")); + const hasSrcPages = fs.existsSync(path.posix.join(root, "src", "pages")); + baseDir = + hasRootApp || hasRootPages + ? root + : hasSrcApp || hasSrcPages + ? path.posix.join(root, "src") + : root; + } + + const appDir = path.posix.join(baseDir, "app"); + const pagesDir = path.posix.join(baseDir, "pages"); + return { + appDir: !routeRootConfig.disableAppRouter && fs.existsSync(appDir) ? appDir : null, + pagesDir: fs.existsSync(pagesDir) ? pagesDir : null, + }; +} + +function appRouteMayHaveGenerateStaticParams(route: Awaited>[number]) { + if (fileHasNamedExport(route.pagePath, "generateStaticParams")) return true; + return route.layouts.some((layoutPath) => fileHasNamedExport(layoutPath, "generateStaticParams")); +} + +async function shouldStartPathDiscoveryServer(options: { + appDir: string | null; + pagesDir: string | null; + pageExtensions: readonly string[]; +}): Promise { + if (options.appDir) { + const routes = await appRouter(options.appDir, options.pageExtensions); + if (routes.some((route) => route.isDynamic && appRouteMayHaveGenerateStaticParams(route))) { + return true; + } + } + + if (options.pagesDir) { + const routes = await pagesRouter(options.pagesDir, options.pageExtensions); + if ( + routes.some( + (route) => route.isDynamic && fileHasNamedExport(route.filePath, "getStaticPaths"), + ) + ) { + return true; + } + } + + return false; +} + +async function withPrerenderEndpoints(fn: () => Promise): Promise { + const previousPrerenderFlag = process.env.VINEXT_PRERENDER; + const previousPathDiscoveryFlag = process.env[PRERENDER_PATH_DISCOVERY_ENV]; + process.env.VINEXT_PRERENDER = "1"; + process.env[PRERENDER_PATH_DISCOVERY_ENV] = "1"; + try { + return await fn(); + } finally { + if (previousPrerenderFlag === undefined) delete process.env.VINEXT_PRERENDER; + else process.env.VINEXT_PRERENDER = previousPrerenderFlag; + if (previousPathDiscoveryFlag === undefined) delete process.env[PRERENDER_PATH_DISCOVERY_ENV]; + else process.env[PRERENDER_PATH_DISCOVERY_ENV] = previousPathDiscoveryFlag; + } +} + +async function collectPagesPaths(options: { + baseUrl: string | null; + pagesDir: string; + pageExtensions: readonly string[]; + secretHeaders: Record; +}): Promise { + const [pageRoutes, apiRoutes] = await Promise.all([ + pagesRouter(options.pagesDir, options.pageExtensions), + apiRouter(options.pagesDir, options.pageExtensions), + ]); + const apiPatterns = new Set(apiRoutes.map((route) => route.pattern)); + const paths: string[] = []; + const seen = new Set(); + + for (const route of pageRoutes) { + if (apiPatterns.has(route.pattern)) continue; + if (BLOCKED_PAGES.includes(route.pattern)) continue; + if (route.pattern === "/404" || route.pattern === "/500" || route.pattern === "/_error") { + continue; + } + + const { type } = classifyPagesRoute(route.filePath); + if (type === "api" || type === "ssr") continue; + + if (!route.isDynamic) { + addPath(paths, seen, route.pattern); + continue; + } + + if (!fileHasNamedExport(route.filePath, "getStaticPaths")) continue; + if (!options.baseUrl) continue; + + try { + const search = new URLSearchParams({ pattern: route.pattern }); + const text = await fetchDiscoveryEndpoint( + `${options.baseUrl}/__vinext/prerender/pages-static-paths?${search}`, + options.secretHeaders, + ); + if (text === null) continue; + + const pathsResult = JSON.parse(text) as { + paths?: Array; + fallback?: unknown; + }; + for (const item of pathsResult.paths ?? []) { + const normalized = normalizeStaticPathsEntry(item, route.pattern); + if ("error" in normalized) { + throw new Error(normalized.error); + } + addPath(paths, seen, buildUrlFromParams(route.pattern, normalized.params)); + } + } catch (error) { + warnDiscoveryFailure(route.pattern, error); + } + } + + return paths; +} + +async function collectAppPaths(options: { + appDir: string; + baseUrl: string | null; + pageExtensions: readonly string[]; + secretHeaders: Record; +}): Promise { + const routes = await appRouter(options.appDir, options.pageExtensions); + const paths: string[] = []; + const seen = new Set(); + const staticParamsCache = new Map[] | null>>(); + const staticParamsMap = new Proxy({} as StaticParamsMap, { + get(_target, pattern: string) { + return async ({ params }: { params: Record }) => { + if (!options.baseUrl) return null; + const cacheKey = `${pattern}\0${JSON.stringify(params)}`; + const cached = staticParamsCache.get(cacheKey); + if (cached !== undefined) return cached; + const request = (async () => { + const search = new URLSearchParams({ pattern }); + if (Object.keys(params).length > 0) { + search.set("parentParams", JSON.stringify(params)); + } + const text = await fetchDiscoveryEndpoint( + `${options.baseUrl}/__vinext/prerender/static-params?${search}`, + options.secretHeaders, + ); + if (text === null) return null; + return JSON.parse(text) as Record[]; + })(); + void request.catch(() => staticParamsCache.delete(cacheKey)); + staticParamsCache.set(cacheKey, request); + return request; + }; + }, + has() { + return false; + }, + }); + + for (const route of routes) { + const renderEntryPath = getAppRouteRenderEntryPath(route); + if (!renderEntryPath) continue; + + const { type } = classifyAppRoute(renderEntryPath, route.routePath, route.isDynamic); + if (type === "api") continue; + + const isConfiguredDynamic = type === "ssr" && !route.isDynamic; + if (isConfiguredDynamic) continue; + + if (!route.isDynamic) { + addPath(paths, seen, route.pattern); + continue; + } + + if (!appRouteMayHaveGenerateStaticParams(route)) continue; + try { + const generateStaticParams = staticParamsMap[route.pattern]; + if (typeof generateStaticParams !== "function") continue; + + const parentParamSets = await resolveParentParams(route, staticParamsMap); + let paramSets: Record[] | null; + + if (parentParamSets.length > 0) { + paramSets = []; + for (const parentParams of parentParamSets) { + const childResults = await generateStaticParams({ params: parentParams }); + if (childResults === null) { + paramSets = null; + break; + } + if (Array.isArray(childResults)) { + for (const childParams of childResults) { + paramSets.push({ ...parentParams, ...childParams }); + } + } + } + } else { + const results = await generateStaticParams({ params: {} }); + paramSets = Array.isArray(results) || results === null ? results : []; + } + + if (!paramSets?.length) continue; + + for (const params of paramSets) { + if (params === null || params === undefined) continue; + addPath(paths, seen, buildUrlFromParams(route.pattern, params)); + } + } catch (error) { + warnDiscoveryFailure(route.pattern, error); + } + } + + return paths; +} + +async function startPathDiscoveryServer(options: { + serverDir: string; + pagesBundlePath?: string; + rscBundlePath?: string; +}): Promise<{ server: HttpServer; port: number }> { + return startProdServer({ + port: 0, + host: "127.0.0.1", + outDir: options.pagesBundlePath + ? path.dirname(path.dirname(options.pagesBundlePath)) + : path.dirname(options.serverDir), + rscEntryPath: options.rscBundlePath, + serverEntryPath: options.pagesBundlePath, + noCompression: true, + purpose: "prerender", + }); +} + +export async function emitPrerenderPathManifest( + options: EmitPrerenderPathManifestOptions, +): Promise { + const { root } = options; + const configuredRouteDirs = resolveConfiguredRouteDirs(root, options.routeRootConfig); + const appDir = options.appDir !== undefined ? options.appDir : configuredRouteDirs.appDir; + const pagesDir = options.pagesDir !== undefined ? options.pagesDir : configuredRouteDirs.pagesDir; + + if (!appDir && !pagesDir) return null; + + const defaultRscBundlePath = options.routeRootConfig?.rscOutDir + ? path.join(path.resolve(root, options.routeRootConfig.rscOutDir), "index.js") + : path.join(root, "dist", "server", "index.js"); + const rscBundlePath = options.rscBundlePath ?? defaultRscBundlePath; + const pagesBundlePath = options.pagesBundlePath ?? path.join(root, "dist", "server", "entry.js"); + const bundleServerDir = fs.existsSync(rscBundlePath) + ? path.dirname(rscBundlePath) + : path.dirname(pagesBundlePath); + const manifestDir = path.join(root, "dist", "server"); + const loadedConfig = await resolveNextConfig( + await loadNextConfig(root, PHASE_PRODUCTION_BUILD), + root, + ); + const config = options.nextConfigOverride + ? { ...loadedConfig, ...options.nextConfigOverride } + : { ...loadedConfig }; + const builtBuildId = readBuiltBuildId(manifestDir) ?? readBuiltBuildId(bundleServerDir); + if (builtBuildId) { + config.buildId = builtBuildId; + } + + const paths: string[] = []; + const seen = new Set(); + await withPrerenderEndpoints(async () => { + let prodServer: { server: HttpServer; port: number } | null = null; + const needsServer = await shouldStartPathDiscoveryServer({ + appDir, + pagesDir, + pageExtensions: config.pageExtensions, + }); + if (needsServer) { + try { + prodServer = await startPathDiscoveryServer({ + serverDir: bundleServerDir, + pagesBundlePath: !appDir && pagesDir ? pagesBundlePath : undefined, + rscBundlePath: appDir ? rscBundlePath : undefined, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `[vinext] Warning: failed to start prerender path discovery server: ${message}`, + ); + } + } + + const baseUrl = prodServer ? `http://127.0.0.1:${prodServer.port}` : null; + const prerenderSecret = + readPrerenderSecret(bundleServerDir) ?? readPrerenderSecret(manifestDir); + const secretHeaders: Record = prerenderSecret + ? { [VINEXT_PRERENDER_SECRET_HEADER]: prerenderSecret } + : {}; + + try { + if (appDir) { + for (const pathname of await collectAppPaths({ + appDir, + baseUrl, + pageExtensions: config.pageExtensions, + secretHeaders, + })) { + addPath(paths, seen, pathname); + } + } + + if (pagesDir) { + for (const pathname of await collectPagesPaths({ + baseUrl, + pagesDir, + pageExtensions: config.pageExtensions, + secretHeaders, + })) { + addPath(paths, seen, pathname); + } + } + } finally { + if (prodServer) { + await new Promise((resolve) => prodServer!.server.close(() => resolve())); + } + } + }); + + const manifest: PrerenderPathManifest = { + buildId: config.buildId, + trailingSlash: config.trailingSlash, + paths, + }; + fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync( + path.join(manifestDir, PRERENDER_PATHS_MANIFEST), + JSON.stringify(manifest, null, 2) + "\n", + "utf-8", + ); + console.log(` Discovered ${paths.length} CDN warmup path(s).`); + + return manifest; +} diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts index de1839c748..3340abd975 100644 --- a/packages/vinext/src/build/prerender.ts +++ b/packages/vinext/src/build/prerender.ts @@ -417,7 +417,7 @@ async function runWithConcurrency( * caller (prerenderPages / prerenderApp) catches this and surfaces it as a * per-route error result. */ -function buildUrlFromParams( +export function buildUrlFromParams( pattern: string, params: Record | undefined | null, ): string { diff --git a/packages/vinext/src/cache/cache-adapters-virtual.ts b/packages/vinext/src/cache/cache-adapters-virtual.ts index a5edbfed91..08efd448cc 100644 --- a/packages/vinext/src/cache/cache-adapters-virtual.ts +++ b/packages/vinext/src/cache/cache-adapters-virtual.ts @@ -157,6 +157,7 @@ export function generateCacheAdaptersModule(cache?: VinextCacheConfig): string { "let __vinextCacheAdaptersRegistered = false;", "", "export function registerConfiguredCacheAdapters(env) {", + " if (typeof process !== 'undefined' && process.env?.__VINEXT_PRERENDER_PATH_DISCOVERY === '1') return;", " if (__vinextCacheAdaptersRegistered) return;", " __vinextCacheAdaptersRegistered = true;", ); diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index c8eb6fcde0..a4d1f2caa8 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -15,6 +15,7 @@ import vinext from "./index.js"; import { runPrerender } from "./build/run-prerender.js"; +import { emitPrerenderPathManifest } from "./build/prerender-paths.js"; import path from "node:path"; import fs from "node:fs"; import { pathToFileURL } from "node:url"; @@ -51,10 +52,12 @@ import { generateRouteTypes } from "./typegen.js"; import { normalizePathSeparators } from "./utils/path.js"; import { createDevServerConfigPlugin, normalizeDevServerHostname } from "./cli-dev-config.js"; import { + findVinextRouteRootConfigInPlugins, findVinextPrerenderConfigInPlugins, formatVinextPrerenderLabel, resolveVinextPrerenderDecision, type ResolvedVinextPrerenderConfig, + type VinextRouteRootConfig, } from "./config/prerender.js"; // ─── Resolve Vite from the project root ──────────────────────────────────────── @@ -226,13 +229,14 @@ function hasPagesDir(): boolean { type BuildViteConfigMetadata = { emptyOutDir?: boolean; prerenderConfig: ResolvedVinextPrerenderConfig | null; + routeRootConfig: VinextRouteRootConfig | null; }; async function loadBuildViteConfigMetadata( vite: ViteModule, root: string, ): Promise { - if (!hasViteConfig(root)) return { prerenderConfig: null }; + if (!hasViteConfig(root)) return { prerenderConfig: null, routeRootConfig: null }; // Read the raw user config before the multi-environment build so // `build.emptyOutDir: false` remains an escape hatch for vinext's upfront clean. @@ -245,6 +249,7 @@ async function loadBuildViteConfigMetadata( return { emptyOutDir: typeof emptyOutDir === "boolean" ? emptyOutDir : undefined, prerenderConfig: findVinextPrerenderConfigInPlugins(loaded?.config.plugins), + routeRootConfig: findVinextRouteRootConfigInPlugins(loaded?.config.plugins), }; } @@ -689,6 +694,11 @@ async function buildApp() { root: normalizePathSeparators(process.cwd()), concurrency: parsed.prerenderConcurrency, }); + await emitPrerenderPathManifest({ + root: normalizePathSeparators(process.cwd()), + nextConfigOverride: resolvedNextConfig, + routeRootConfig: buildConfigMetadata.routeRootConfig, + }); } // Precompression runs as a Vite plugin writeBundle hook (vinext:precompress). @@ -946,6 +956,8 @@ function printHelp(cmd?: string) { --platform Deployment target: cloudflare or node --prerender Configure vinext build to pre-render all static routes (default: prompt, with No selected by default) + --warm-cdn-cache Add --warm-cdn-cache to the Cloudflare deploy script + (Workers Cache CDN only, default: prompt with Yes) --cdn-cache Cloudflare CDN cache: workers-cache or data-cache (default: workers-cache) --data-cache Cloudflare data cache: kv or none (default: kv) @@ -963,6 +975,8 @@ function printHelp(cmd?: string) { vinext init --platform=cloudflare --image-optimization=none Do not configure Cloudflare Images vinext init --prerender Add prerender: { routes: "*" } to vite.config.ts + vinext init --warm-cdn-cache + Add CDN pre-warming to deploy:vinext vinext init --platform=node Configure a Node deployment vinext init -p 4000 Use port 4000 for dev:vinext vinext init --force Overwrite existing vite.config.ts diff --git a/packages/vinext/src/config/prerender.ts b/packages/vinext/src/config/prerender.ts index d344893fb6..2a970388ec 100644 --- a/packages/vinext/src/config/prerender.ts +++ b/packages/vinext/src/config/prerender.ts @@ -32,9 +32,18 @@ export type VinextPrerenderDecision = ResolvedVinextPrerenderConfig & { // Custom metadata key attached to vinext's config plugin so fresh Vite config // loads can recover the normalized prerender option without re-parsing user code. export const VINEXT_PRERENDER_CONFIG_PLUGIN_PROPERTY = "__vinextPrerenderConfig"; +export const VINEXT_ROUTE_ROOT_CONFIG_PLUGIN_PROPERTY = "__vinextRouteRootConfig"; + +export type VinextRouteRootConfig = { + appDir?: string; + disableAppRouter?: boolean; + rscOutDir?: string; + ssrOutDir?: string; +}; type VinextPrerenderConfigPlugin = { [VINEXT_PRERENDER_CONFIG_PLUGIN_PROPERTY]?: ResolvedVinextPrerenderConfig | null; + [VINEXT_ROUTE_ROOT_CONFIG_PLUGIN_PROPERTY]?: VinextRouteRootConfig | null; }; type ViteConfigLoader = { @@ -83,6 +92,23 @@ export function findVinextPrerenderConfigInPlugins( return null; } +export function findVinextRouteRootConfigInPlugins( + plugins: PluginOption[] | undefined, +): VinextRouteRootConfig | null { + const flattened: unknown[] = []; + flattenPluginOptions(plugins, flattened); + + for (const plugin of flattened) { + if (!isUnknownRecord(plugin)) continue; + const routeRootConfig = (plugin as VinextPrerenderConfigPlugin)[ + VINEXT_ROUTE_ROOT_CONFIG_PLUGIN_PROPERTY + ]; + if (routeRootConfig) return routeRootConfig; + } + + return null; +} + export async function loadVinextPrerenderConfigFromViteConfig( vite: ViteConfigLoader, root: string, @@ -95,6 +121,18 @@ export async function loadVinextPrerenderConfigFromViteConfig( return findVinextPrerenderConfigInPlugins(loaded?.config.plugins); } +export async function loadVinextRouteRootConfigFromViteConfig( + vite: ViteConfigLoader, + root: string, +): Promise { + const loaded = await vite.loadConfigFromFile( + { command: "build", mode: "production" }, + undefined, + root, + ); + return findVinextRouteRootConfigInPlugins(loaded?.config.plugins); +} + export function resolveVinextPrerenderDecision(options: { prerenderAllFlag?: boolean; vinextPrerenderConfig?: ResolvedVinextPrerenderConfig | null; diff --git a/packages/vinext/src/image/image-adapters-virtual.ts b/packages/vinext/src/image/image-adapters-virtual.ts index 5d6657d419..351de60ff4 100644 --- a/packages/vinext/src/image/image-adapters-virtual.ts +++ b/packages/vinext/src/image/image-adapters-virtual.ts @@ -107,6 +107,7 @@ export function generateImageAdaptersModule(images?: VinextImageConfig): string "let __vinextImageOptimizerRegistered = false;", "", "export function registerConfiguredImageOptimizer(env) {", + " if (typeof process !== 'undefined' && process.env?.__VINEXT_PRERENDER_PATH_DISCOVERY === '1') return;", " if (__vinextImageOptimizerRegistered) return;", " __vinextImageOptimizerRegistered = true;", " try {", diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 25854f3b01..967074dfe4 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -242,6 +242,7 @@ import { import { normalizeVinextPrerenderConfig, VINEXT_PRERENDER_CONFIG_PLUGIN_PROPERTY, + VINEXT_ROUTE_ROOT_CONFIG_PLUGIN_PROPERTY, type VinextPrerenderConfig, } from "./config/prerender.js"; @@ -1301,7 +1302,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let pagesClientAssetsModule: string | null = null; let rscCompatibilityId: string | undefined; const draftModeSecret = randomUUID(); - // Per-plugin-instance binding of the Sass-aware CSS Modules Loader. The // `config` hook injects `Loader` as `css.modules.Loader` and // `configResolved` binds the resolved config, so multiple vinext builds in @@ -1734,6 +1734,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { string, unknown >), + ...({ + [VINEXT_ROUTE_ROOT_CONFIG_PLUGIN_PROPERTY]: { + appDir: options.appDir, + disableAppRouter: options.disableAppRouter, + rscOutDir: options.rscOutDir, + ssrOutDir: options.ssrOutDir, + }, + } as Record), ...({ [VINEXT_CACHE_CONFIG_PLUGIN_PROPERTY]: options.cache ?? null } as Record< string, unknown diff --git a/packages/vinext/src/init-platform.ts b/packages/vinext/src/init-platform.ts index de23d08cb5..4f70bc7ba5 100644 --- a/packages/vinext/src/init-platform.ts +++ b/packages/vinext/src/init-platform.ts @@ -11,6 +11,7 @@ export type CloudflareInitOptions = { dataCache: InitDataCache; cdnCache: InitCdnCache; imageOptimization: InitImageOptimization; + warmCdnCache?: boolean; }; export const INIT_PLATFORMS = { @@ -118,15 +119,38 @@ export function parseImageOptimizationArg(args: string[]): InitImageOptimization } export function parsePrerenderArg(args: string[]): boolean | undefined { + return parseBooleanArg( + args, + "--prerender", + "--no-prerender", + '--prerender expects true or false when using the "--prerender=value" form.', + ); +} + +export function parseWarmCdnCacheArg(args: string[]): boolean | undefined { + return parseBooleanArg( + args, + "--warm-cdn-cache", + "--no-warm-cdn-cache", + '--warm-cdn-cache expects true or false when using the "--warm-cdn-cache=value" form.', + ); +} + +function parseBooleanArg( + args: string[], + enabledFlag: string, + disabledFlag: string, + errorMessage: string, +): boolean | undefined { for (const arg of args) { - if (arg === "--prerender") return true; - if (arg === "--no-prerender") return false; - if (!arg.startsWith("--prerender=")) continue; + if (arg === enabledFlag) return true; + if (arg === disabledFlag) return false; + if (!arg.startsWith(`${enabledFlag}=`)) continue; - const value = arg.slice("--prerender=".length).toLowerCase(); + const value = arg.slice(enabledFlag.length + 1).toLowerCase(); if (value === "true" || value === "yes" || value === "1") return true; if (value === "false" || value === "no" || value === "0") return false; - throw new Error('--prerender expects true or false when using the "--prerender=value" form.'); + throw new Error(errorMessage); } return undefined; @@ -189,12 +213,26 @@ export async function resolveInitOptions( ): Promise { const platform = await resolveInitPlatform(args, options); const platformOptions = await INIT_PLATFORMS[platform].options(args, options); + const explicitWarmCdnCache = parseWarmCdnCacheArg(args); + if (platform === "cloudflare" && platformOptions?.cdnCache !== "workers-cache") { + if (explicitWarmCdnCache === true) { + throw new Error("--warm-cdn-cache requires --cdn-cache=workers-cache."); + } + } + const prerender = await resolveInitPrerender(args, options); + const warmCdnCache = + platform === "cloudflare" && platformOptions?.cdnCache === "workers-cache" + ? await resolveInitWarmCdnCache(args, options) + : false; return { platform, prerender, - cloudflare: platform === "cloudflare" ? platformOptions : undefined, + cloudflare: + platform === "cloudflare" && platformOptions + ? { ...platformOptions, warmCdnCache } + : undefined, }; } @@ -239,6 +277,47 @@ export async function resolveInitPrerender( } } +export async function resolveInitWarmCdnCache( + args: string[], + options: PlatformPromptOptions = {}, +): Promise { + const explicitWarmCdnCache = parseWarmCdnCacheArg(args); + if (explicitWarmCdnCache !== undefined) return explicitWarmCdnCache; + + const env = options.env ?? process.env; + const input = options.input ?? process.stdin; + const output = options.output ?? process.stdout; + const isInteractive = + options.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY); + if (isAgentEnvironment(env) || !isInteractive) return true; + + const readline = options.question ? undefined : createInterface({ input, output }); + const question = options.question ?? ((prompt: string) => readline!.question(prompt)); + + try { + while (true) { + const answer = (await question(" Pre-warm Workers Cache during deploy? [Y/n]: ")) + .trim() + .toLowerCase(); + if (answer === "") { + output.write("\n"); + return true; + } + if (answer === "y" || answer === "yes") { + output.write("\n"); + return true; + } + if (answer === "n" || answer === "no") { + output.write("\n"); + return false; + } + output.write(" Please answer yes or no.\n"); + } + } finally { + readline?.close(); + } +} + export async function resolveCloudflareInitOptions( args: string[], options: PlatformPromptOptions = {}, diff --git a/packages/vinext/src/init.ts b/packages/vinext/src/init.ts index d9f39ec241..397a499939 100644 --- a/packages/vinext/src/init.ts +++ b/packages/vinext/src/init.ts @@ -154,7 +154,12 @@ export default defineConfig({ * Add vinext scripts to package.json without overwriting existing scripts. * Returns the list of script names that were added. */ -export function addScripts(root: string, port: number, platform: InitPlatform = "node"): string[] { +export function addScripts( + root: string, + port: number, + platform: InitPlatform = "node", + options: { warmCdnCache?: boolean } = {}, +): string[] { const pkgPath = path.join(root, "package.json"); if (!fs.existsSync(pkgPath)) return []; @@ -187,7 +192,9 @@ export function addScripts(root: string, port: number, platform: InitPlatform = } if (platform === "cloudflare" && !pkg.scripts["deploy:vinext"]) { - pkg.scripts["deploy:vinext"] = "vinext-cloudflare deploy"; + pkg.scripts["deploy:vinext"] = options.warmCdnCache + ? "vinext-cloudflare deploy --warm-cdn-cache" + : "vinext-cloudflare deploy"; added.push("deploy:vinext"); } @@ -557,7 +564,10 @@ export async function init(options: InitOptions): Promise { // ── Step 3: Add scripts ──────────────────────────────────────────────── - const addedScripts = addScripts(root, port, platform); + const addedScripts = addScripts(root, port, platform, { + warmCdnCache: + options.cloudflare?.warmCdnCache ?? options.cloudflare?.cdnCache === "workers-cache", + }); // ── Step 4: Generate vite.config.ts ──────────────────────────────────── diff --git a/packages/vinext/src/server/prerender-manifest.ts b/packages/vinext/src/server/prerender-manifest.ts index fe228b7c0f..3b845c448c 100644 --- a/packages/vinext/src/server/prerender-manifest.ts +++ b/packages/vinext/src/server/prerender-manifest.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; -type PrerenderManifestRoute = { +export type PrerenderManifestRoute = { route: string; status?: string; revalidate?: number | false; @@ -18,6 +18,11 @@ export type PrerenderManifest = { pregeneratedConcretePaths?: Array<[string, string[]]>; }; +export type PrerenderedPathSelectionOptions = { + includeFallbackShells?: boolean; + includeErrorDocuments?: boolean; +}; + export function readPrerenderManifest(manifestPath: string): PrerenderManifest | null { if (!fs.existsSync(manifestPath)) return null; try { @@ -46,6 +51,17 @@ function groupRoutesByPattern(routes: PrerenderManifestRoute[]): Map(); + for (const route of routes) { + if (route.status !== "rendered") continue; + const pathname = route.path ?? route.route; + if (!options?.includeFallbackShells && isFallbackShellArtifactPath(pathname, route)) { + continue; + } + if (!options?.includeErrorDocuments && isErrorDocumentRoute(pathname, route)) { + continue; + } + if (seen.has(pathname)) continue; + seen.add(pathname); + paths.push(pathname); + } + return paths; +} diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 2e02237419..88587e5c6a 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -216,6 +216,10 @@ export type ProdServerOptions = { host?: string; /** Path to the build output directory */ outDir?: string; + /** Explicit App Router RSC entry path. Defaults to `/server/index.js`. */ + rscEntryPath?: string; + /** Explicit Pages Router server entry path. Defaults to `/server/entry.js`. */ + serverEntryPath?: string; /** Disable compression (default: false) */ noCompression?: boolean; /** @@ -975,6 +979,8 @@ export async function startProdServer(options: ProdServerOptions = {}) { port = process.env.PORT ? parseInt(process.env.PORT) : 3000, host = "0.0.0.0", outDir = path.resolve("dist"), + rscEntryPath: explicitRscEntryPath, + serverEntryPath: explicitServerEntryPath, noCompression = false, purpose, silent = false, @@ -986,8 +992,12 @@ export async function startProdServer(options: ProdServerOptions = {}) { const clientDir = path.join(resolvedOutDir, "client"); // Detect build type - const rscEntryPath = path.join(resolvedOutDir, "server", "index.js"); - const serverEntryPath = path.join(resolvedOutDir, "server", "entry.js"); + const rscEntryPath = explicitRscEntryPath + ? path.resolve(explicitRscEntryPath) + : path.join(resolvedOutDir, "server", "index.js"); + const serverEntryPath = explicitServerEntryPath + ? path.resolve(explicitServerEntryPath) + : path.join(resolvedOutDir, "server", "entry.js"); const isAppRouter = fs.existsSync(rscEntryPath); if (!isAppRouter && !fs.existsSync(serverEntryPath)) { diff --git a/tests/__snapshots__/init.test.ts.snap b/tests/__snapshots__/init.test.ts.snap index 3aa4b3125d..ff1b99d59a 100644 --- a/tests/__snapshots__/init.test.ts.snap +++ b/tests/__snapshots__/init.test.ts.snap @@ -26,7 +26,7 @@ export default function Home() { return
hi
} "dev:vinext": "vinext dev --port 3001", "build:vinext": "vinext build", "start:vinext": "wrangler dev --config dist/server/wrangler.json", - "deploy:vinext": "vinext-cloudflare deploy" + "deploy:vinext": "vinext-cloudflare deploy --warm-cdn-cache" } } @@ -142,7 +142,7 @@ export default function Home() { return
hi
} "dev:vinext": "vinext dev --port 3001", "build:vinext": "vinext build", "start:vinext": "wrangler dev --config dist/server/wrangler.json", - "deploy:vinext": "vinext-cloudflare deploy" + "deploy:vinext": "vinext-cloudflare deploy --warm-cdn-cache" } } diff --git a/tests/app-router-production-build.test.ts b/tests/app-router-production-build.test.ts index 015bc4c66f..ab87d7fd81 100644 --- a/tests/app-router-production-build.test.ts +++ b/tests/app-router-production-build.test.ts @@ -129,7 +129,12 @@ describe("App Router Production build", () => { // silently never written for pure App Router apps. const buildIdPath = path.join(outDir, "server", "BUILD_ID"); expect(fs.existsSync(buildIdPath)).toBe(true); - expect(fs.readFileSync(buildIdPath, "utf-8").trim().length).toBeGreaterThan(0); + const buildId = fs.readFileSync(buildIdPath, "utf-8").trim(); + expect(buildId.length).toBeGreaterThan(0); + + const warmupManifestPath = path.join(outDir, "server", "vinext-prerender-paths.json"); + expect(fs.existsSync(warmupManifestPath)).toBe(false); + expect(fs.existsSync(path.join(outDir, "server", "prerendered-routes"))).toBe(false); }, 30000); it("omits the browser server-action client when the app has no server actions", async () => { diff --git a/tests/cache-adapters-config.test.ts b/tests/cache-adapters-config.test.ts index 279f81d3f1..e6b050c4b0 100644 --- a/tests/cache-adapters-config.test.ts +++ b/tests/cache-adapters-config.test.ts @@ -87,6 +87,9 @@ describe("generateCacheAdaptersModule", () => { expect(code).toContain(`from "@vinext/cloudflare/cache/kv-data-adapter";`); expect(code).toContain("setDataCacheHandler(__vinextDataAdapterFactory("); expect(code).toContain("setCdnCacheAdapter(__vinextCdnAdapterFactory("); + expect(code).toContain( + "if (typeof process !== 'undefined' && process.env?.__VINEXT_PRERENDER_PATH_DISCOVERY === '1') return;", + ); expect(code).toContain("if (__vinextCacheAdaptersRegistered) return;"); expect(code).toContain("__vinextCacheAdaptersRegistered = true;"); }); diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts new file mode 100644 index 0000000000..e473a896b2 --- /dev/null +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -0,0 +1,467 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const execFileSyncMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: execFileSyncMock, + }; +}); + +let tmpDir: string; + +function writeFile(relativePath: string, content: string): void { + const fullPath = path.join(tmpDir, relativePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, "utf-8"); +} + +function formatFetchUrl(url: Parameters[0]): string { + if (url instanceof URL) return url.href; + if (typeof url === "string") return url; + return url.url; +} + +describe("Cloudflare CDN warmup deploy flow", () => { + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-cdn-warm-deploy-test-")); + execFileSyncMock.mockReset(); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("ok", { status: 200 })), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("warms the production custom domain through a 0% staged version override", async () => { + const events: string[] = []; + writeFile( + "wrangler.jsonc", + JSON.stringify({ + name: "my-worker", + custom_domains: ["app.example.com"], + }), + ); + vi.mocked(fetch).mockImplementation(async (url) => { + events.push(`fetch:${formatFetchUrl(url)}`); + return new Response("ok", { status: 200 }); + }); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) { + events.push("upload"); + return "Uploaded version 22222222-2222-4222-8222-222222222222\nhttps://preview.example.workers.dev\n"; + } + if (args.includes("status")) { + events.push("status"); + return JSON.stringify({ + versions: [{ version_id: "11111111-1111-4111-8111-111111111111", percentage: 100 }], + }); + } + if ( + args.includes("deploy") && + args.includes("11111111-1111-4111-8111-111111111111@100%") && + args.includes("22222222-2222-4222-8222-222222222222@0%") + ) { + events.push("stage"); + return "Staged version\nhttps://stable.example.workers.dev\n"; + } + if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@100%")) { + events.push("promote"); + return "Deployed version\nhttps://stable.example.workers.dev\n"; + } + if (args.includes("triggers")) { + events.push("triggers"); + return "Triggers deployed\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + const url = await deployWithCdnWarmup(tmpDir, ["/", "/about"], { + warmCdnConcurrency: 1, + }); + + expect(url).toBe("https://stable.example.workers.dev"); + expect(execFileSyncMock).toHaveBeenNthCalledWith( + 1, + process.execPath, + expect.arrayContaining(["versions", "upload"]), + expect.any(Object), + ); + expect(execFileSyncMock).toHaveBeenNthCalledWith( + 2, + process.execPath, + expect.arrayContaining(["deployments", "status", "--json"]), + expect.any(Object), + ); + expect(execFileSyncMock).toHaveBeenNthCalledWith( + 3, + process.execPath, + expect.arrayContaining([ + "versions", + "deploy", + "11111111-1111-4111-8111-111111111111@100%", + "22222222-2222-4222-8222-222222222222@0%", + ]), + expect.any(Object), + ); + expect(fetch).toHaveBeenCalledTimes(2); + const firstInit = vi.mocked(fetch).mock.calls[0]![1] as RequestInit; + expect(fetch).toHaveBeenNthCalledWith( + 1, + new URL("https://app.example.com/"), + expect.any(Object), + ); + expect(fetch).toHaveBeenNthCalledWith( + 2, + new URL("https://app.example.com/about"), + expect.any(Object), + ); + expect(new Headers(firstInit.headers).get("Cloudflare-Workers-Version-Overrides")).toBe( + 'my-worker="22222222-2222-4222-8222-222222222222"', + ); + expect(execFileSyncMock).toHaveBeenNthCalledWith( + 4, + process.execPath, + expect.arrayContaining(["triggers", "deploy"]), + expect.any(Object), + ); + expect(execFileSyncMock).toHaveBeenNthCalledWith( + 5, + process.execPath, + expect.arrayContaining(["versions", "deploy", "22222222-2222-4222-8222-222222222222@100%"]), + expect.any(Object), + ); + expect(events).toEqual([ + "upload", + "status", + "stage", + "triggers", + "fetch:https://app.example.com/", + "fetch:https://app.example.com/about", + "promote", + ]); + }); + + it("uses the env Worker name and env custom domain for version override warmup", async () => { + writeFile( + "wrangler.jsonc", + JSON.stringify({ + name: "my-worker", + custom_domains: ["app.example.com"], + env: { + staging: { + name: "my-worker-staging-custom", + custom_domains: ["staging.example.com"], + }, + }, + }), + ); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) { + return "Uploaded version 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("deploy") && args.includes("22222222-2222-4222-8222-222222222222@0%")) { + return "Staged version\nhttps://stable.example.workers.dev\n"; + } + if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@100%")) { + return "Deployed version\nhttps://stable.example.workers.dev\n"; + } + if (args.includes("triggers")) { + return "Triggers deployed\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await deployWithCdnWarmup(tmpDir, ["/"], { + env: "staging", + warmCdnConcurrency: 1, + }); + + expect(fetch).toHaveBeenCalledWith(new URL("https://staging.example.com/"), expect.any(Object)); + const firstInit = vi.mocked(fetch).mock.calls[0]![1] as RequestInit; + expect(new Headers(firstInit.headers).get("Cloudflare-Workers-Version-Overrides")).toBe( + 'my-worker-staging-custom="22222222-2222-4222-8222-222222222222"', + ); + for (const [, args] of execFileSyncMock.mock.calls as Array<[string, string[]]>) { + expect(args).toEqual(expect.arrayContaining(["--env", "staging"])); + } + }); + + it("applies triggers before post-promotion fallback warmup", async () => { + const events: string[] = []; + writeFile( + "wrangler.jsonc", + JSON.stringify({ + name: "my-worker", + custom_domains: ["app.example.com"], + }), + ); + vi.mocked(fetch).mockImplementation(async (url) => { + events.push(`fetch:${formatFetchUrl(url)}`); + return new Response("ok", { status: 200 }); + }); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) { + events.push("upload"); + return "Uploaded version 22222222-2222-4222-8222-222222222222\n"; + } + if (args.includes("status")) { + events.push("status"); + return JSON.stringify({ + versions: [ + { version_id: "11111111-1111-4111-8111-111111111111", percentage: 50 }, + { version_id: "33333333-3333-4333-8333-333333333333", percentage: 50 }, + ], + }); + } + if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@100%")) { + events.push("promote"); + return "Deployed version\nhttps://stable.example.workers.dev\n"; + } + if (args.includes("triggers")) { + events.push("triggers"); + return "Triggers deployed\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await deployWithCdnWarmup(tmpDir, ["/"], { + warmCdnConcurrency: 1, + }); + + expect(events).toEqual([ + "upload", + "status", + "promote", + "triggers", + "fetch:https://app.example.com/", + ]); + }); + + it("uses the triggers deploy URL for post-promotion fallback warmup", async () => { + const events: string[] = []; + writeFile( + "wrangler.jsonc", + JSON.stringify({ + name: "workers-cache", + }), + ); + vi.mocked(fetch).mockImplementation(async (url) => { + events.push(`fetch:${formatFetchUrl(url)}`); + return new Response("ok", { status: 200 }); + }); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) { + events.push("upload"); + return "Uploaded version 22222222-2222-4222-8222-222222222222\n"; + } + if (args.includes("status")) { + events.push("status"); + return JSON.stringify({ + versions: [ + { version_id: "11111111-1111-4111-8111-111111111111", percentage: 100 }, + { version_id: "33333333-3333-4333-8333-333333333333", percentage: 0 }, + ], + }); + } + if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@100%")) { + events.push("promote"); + return "Deployed workers-cache version 22222222-2222-4222-8222-222222222222 at 100%\n"; + } + if (args.includes("triggers")) { + events.push("triggers"); + return "Deployed workers-cache triggers\n https://workers-cache.vinext.workers.dev\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + const url = await deployWithCdnWarmup(tmpDir, ["/cached/intro"], { + warmCdnConcurrency: 1, + }); + + expect(url).toBe("https://workers-cache.vinext.workers.dev"); + expect(fetch).toHaveBeenCalledWith( + new URL("https://workers-cache.vinext.workers.dev/cached/intro"), + expect.any(Object), + ); + expect(events).toEqual([ + "upload", + "status", + "promote", + "triggers", + "fetch:https://workers-cache.vinext.workers.dev/cached/intro", + ]); + }); + + it("uses the explicit Worker name for version upload, override, promotion, and triggers", async () => { + writeFile( + "wrangler.jsonc", + JSON.stringify({ + name: "config-worker", + custom_domains: ["app.example.com"], + }), + ); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) { + return "Uploaded version 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("deploy") && args.includes("22222222-2222-4222-8222-222222222222@0%")) { + return "Staged version\nhttps://stable.example.workers.dev\n"; + } + if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@100%")) { + return "Deployed version\nhttps://stable.example.workers.dev\n"; + } + if (args.includes("triggers")) { + return "Triggers deployed\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await deployWithCdnWarmup(tmpDir, ["/"], { + name: "cli-worker", + warmCdnConcurrency: 1, + }); + + const firstInit = vi.mocked(fetch).mock.calls[0]![1] as RequestInit; + expect(new Headers(firstInit.headers).get("Cloudflare-Workers-Version-Overrides")).toBe( + 'cli-worker="22222222-2222-4222-8222-222222222222"', + ); + for (const [, args] of execFileSyncMock.mock.calls as Array<[string, string[]]>) { + expect(args).toEqual(expect.arrayContaining(["--name", "cli-worker"])); + } + }); + + it("explains staged version cleanup when strict pre-promotion warmup fails", async () => { + writeFile( + "wrangler.jsonc", + JSON.stringify({ + name: "my-worker", + custom_domains: ["app.example.com"], + }), + ); + vi.mocked(fetch).mockResolvedValue(new Response("nope", { status: 500 })); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) { + return "Uploaded version 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("deploy") && args.includes("22222222-2222-4222-8222-222222222222@0%")) { + return "Staged version\nhttps://stable.example.workers.dev\n"; + } + if (args.includes("triggers")) { + return "Triggers deployed\n"; + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await expect( + deployWithCdnWarmup(tmpDir, ["/"], { + warmCdnConcurrency: 1, + warmCdnRetries: 0, + warmCdnStrict: true, + }), + ).rejects.toThrow("may remain staged at 0%"); + }); + + it("explains staged version cleanup when trigger deployment fails after staging", async () => { + writeFile( + "wrangler.jsonc", + JSON.stringify({ + name: "my-worker", + custom_domains: ["app.example.com"], + }), + ); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) { + return "Uploaded version 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("deploy") && args.includes("22222222-2222-4222-8222-222222222222@0%")) { + return "Staged version\nhttps://stable.example.workers.dev\n"; + } + if (args.includes("triggers")) { + throw new Error("trigger deploy failed"); + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await expect( + deployWithCdnWarmup(tmpDir, ["/"], { + warmCdnConcurrency: 1, + }), + ).rejects.toThrow("may remain staged at 0%"); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("explains promoted version state when fallback trigger deployment fails", async () => { + writeFile( + "wrangler.jsonc", + JSON.stringify({ + name: "my-worker", + custom_domains: ["app.example.com"], + }), + ); + execFileSyncMock.mockImplementation((_file: string, args: string[]) => { + if (args.includes("upload")) { + return "Uploaded version 22222222-2222-4222-8222-222222222222\n"; + } + if (args.includes("status")) { + return JSON.stringify({ + versions: [ + { version_id: "11111111-1111-4111-8111-111111111111", percentage: 50 }, + { version_id: "33333333-3333-4333-8333-333333333333", percentage: 50 }, + ], + }); + } + if (args.includes("deploy") && args.includes("22222222-2222-4222-8222-222222222222@100%")) { + return "Deployed version\nhttps://stable.example.workers.dev\n"; + } + if (args.includes("triggers")) { + throw new Error("trigger deploy failed"); + } + throw new Error(`Unexpected Wrangler args: ${args.join(" ")}`); + }); + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await expect( + deployWithCdnWarmup(tmpDir, ["/"], { + warmCdnConcurrency: 1, + }), + ).rejects.toThrow("may already be promoted to 100%"); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts new file mode 100644 index 0000000000..6409119332 --- /dev/null +++ b/tests/cloudflare-cdn-warm.test.ts @@ -0,0 +1,340 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + buildWarmupUrl, + DEFAULT_CDN_WARM_TIMEOUT_MS, + warmCdnCache, + getWarmPathsFromPrerenderManifest, + readPrerenderWarmPaths, + warmCdnCacheFromPrerender, +} from "../packages/cloudflare/src/cdn-warm.js"; + +let tmpDir: string; + +function writeFile(relativePath: string, content: string): void { + const fullPath = path.join(tmpDir, relativePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, "utf-8"); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-cdn-warm-test-")); +}); + +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("Cloudflare CDN warmup", () => { + it("uses a 5 second default request timeout", () => { + expect(DEFAULT_CDN_WARM_TIMEOUT_MS).toBe(5_000); + }); + + it("reads warmable paths from the prerender manifest", () => { + writeFile( + "dist/server/vinext-prerender.json", + JSON.stringify({ + buildId: "build-a", + routes: [ + { route: "/", status: "rendered", router: "app", revalidate: false, fallback: false }, + { + route: "/docs/:slug", + path: "/docs/intro", + status: "rendered", + router: "pages", + revalidate: false, + fallback: false, + }, + { + route: "/blog/:slug", + path: "/blog/[slug]", + status: "rendered", + router: "app", + revalidate: 60, + fallback: true, + }, + { + route: "/500", + status: "rendered", + router: "pages", + revalidate: false, + fallback: false, + }, + { + route: "/_error", + status: "rendered", + router: "pages", + revalidate: false, + fallback: false, + }, + ], + }), + ); + writeFile("dist/server/BUILD_ID", "build-a\n"); + + expect(readPrerenderWarmPaths(tmpDir)).toEqual(["/", "/docs/intro"]); + }); + + it("prefers the build-discovered prerender path manifest", () => { + writeFile( + "dist/server/vinext-prerender-paths.json", + JSON.stringify({ + buildId: "build-a", + paths: ["/", "/cached/intro", "not-a-path"], + }), + ); + writeFile( + "dist/server/vinext-prerender.json", + JSON.stringify({ + buildId: "build-a", + routes: [ + { route: "/old", status: "rendered", router: "app", revalidate: false, fallback: false }, + ], + }), + ); + writeFile("dist/server/BUILD_ID", "build-a\n"); + + expect(readPrerenderWarmPaths(tmpDir)).toEqual(["/", "/cached/intro"]); + }); + + it("uses the full prerender manifest when fallback shell paths are requested", () => { + writeFile( + "dist/server/vinext-prerender-paths.json", + JSON.stringify({ + buildId: "build-a", + paths: ["/", "/cached/intro"], + }), + ); + writeFile( + "dist/server/vinext-prerender.json", + JSON.stringify({ + buildId: "build-a", + routes: [ + { route: "/", status: "rendered", router: "app", revalidate: false, fallback: false }, + { + route: "/blog/:slug", + path: "/blog/[slug]", + status: "rendered", + router: "app", + revalidate: 60, + fallback: true, + }, + ], + }), + ); + writeFile("dist/server/BUILD_ID", "build-a\n"); + + expect(readPrerenderWarmPaths(tmpDir, { includeFallbackShells: true })).toEqual([ + "/", + "/blog/[slug]", + ]); + }); + + it("warns when fallback shells are requested without a prerender manifest", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + writeFile( + "dist/server/vinext-prerender-paths.json", + JSON.stringify({ + buildId: "build-a", + paths: ["/", "/cached/intro"], + }), + ); + writeFile("dist/server/BUILD_ID", "build-a\n"); + + expect(readPrerenderWarmPaths(tmpDir, { includeFallbackShells: true })).toEqual([ + "/", + "/cached/intro", + ]); + expect(warn).toHaveBeenCalledWith( + "[vinext] CDN warmup fallback shells requested, but prerender manifest not found; warming build-discovered paths only.", + ); + }); + + it("skips warm paths when the path manifest build ID does not match the built Worker", () => { + writeFile( + "dist/server/vinext-prerender-paths.json", + JSON.stringify({ + buildId: "old-build", + paths: ["/"], + }), + ); + writeFile("dist/server/BUILD_ID", "new-build\n"); + + expect(readPrerenderWarmPaths(tmpDir)).toEqual([]); + }); + + it("skips warm paths when the manifest build ID does not match the built Worker", () => { + writeFile( + "dist/server/vinext-prerender.json", + JSON.stringify({ + buildId: "old-build", + routes: [ + { route: "/", status: "rendered", router: "app", revalidate: false, fallback: false }, + ], + }), + ); + writeFile("dist/server/BUILD_ID", "new-build\n"); + + expect(readPrerenderWarmPaths(tmpDir)).toEqual([]); + }); + + it("throws in strict mode when the manifest build ID does not match the built Worker", () => { + writeFile( + "dist/server/vinext-prerender.json", + JSON.stringify({ + buildId: "old-build", + routes: [ + { route: "/", status: "rendered", router: "app", revalidate: false, fallback: false }, + ], + }), + ); + writeFile("dist/server/BUILD_ID", "new-build\n"); + + expect(() => readPrerenderWarmPaths(tmpDir, { strict: true })).toThrow( + "prerender manifest buildId does not match", + ); + }); + + it("can select fallback-shell placeholder paths when requested", () => { + expect( + getWarmPathsFromPrerenderManifest( + { + routes: [ + { + route: "/blog/:slug", + path: "/blog/[slug]", + status: "rendered", + router: "app", + revalidate: 60, + fallback: true, + }, + ], + }, + { includeFallbackShells: true }, + ), + ).toEqual(["/blog/[slug]"]); + }); + + it("can select error documents when requested", () => { + expect( + getWarmPathsFromPrerenderManifest( + { + routes: [ + { + route: "/500", + status: "rendered", + router: "pages", + revalidate: false, + fallback: false, + }, + { + route: "/_error", + status: "rendered", + router: "pages", + revalidate: false, + fallback: false, + }, + ], + }, + { includeErrorDocuments: true }, + ), + ).toEqual(["/500", "/_error"]); + }); + + it("builds target URLs from root and nested paths", () => { + expect(buildWarmupUrl("https://worker.example.workers.dev", "/docs/intro").toString()).toBe( + "https://worker.example.workers.dev/docs/intro", + ); + }); + + it("requests every warmable path through the target URL", async () => { + writeFile( + "dist/server/vinext-prerender.json", + JSON.stringify({ + buildId: "build-a", + routes: [ + { route: "/", status: "rendered", router: "app", revalidate: false, fallback: false }, + { + route: "/about", + status: "rendered", + router: "pages", + revalidate: false, + fallback: false, + }, + ], + }), + ); + writeFile("dist/server/BUILD_ID", "build-a\n"); + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => new Response("ok", { status: 200 }), + ); + + const result = await warmCdnCacheFromPrerender({ + root: tmpDir, + targetUrl: "https://app.example.com", + concurrency: 1, + fetchImpl: fetchMock as typeof fetch, + }); + + expect(result).toMatchObject({ total: 2, warmed: 2, failed: 0 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + const firstInput = fetchMock.mock.calls[0]![0]; + const secondInput = fetchMock.mock.calls[1]![0]; + expect(firstInput).toBeInstanceOf(URL); + expect(secondInput).toBeInstanceOf(URL); + expect((firstInput as URL).href).toBe("https://app.example.com/"); + expect((secondInput as URL).href).toBe("https://app.example.com/about"); + expect(fetchMock.mock.calls[0]![1]).toMatchObject({ redirect: "follow" }); + }); + + it("warms an already resolved path list without rereading the manifest", async () => { + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => new Response("ok", { status: 200 }), + ); + + const result = await warmCdnCache({ + targetUrl: "https://app.example.com", + paths: ["/", "/about"], + concurrency: 1, + fetchImpl: fetchMock as typeof fetch, + }); + + expect(result).toMatchObject({ total: 2, warmed: 2, failed: 0 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("reports warmup failures and throws in strict mode", async () => { + writeFile( + "dist/server/vinext-prerender.json", + JSON.stringify({ + buildId: "build-a", + routes: [ + { + route: "/broken", + status: "rendered", + router: "app", + revalidate: false, + fallback: false, + }, + ], + }), + ); + writeFile("dist/server/BUILD_ID", "build-a\n"); + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response("nope", { status: 404 }), + ); + + await expect( + warmCdnCacheFromPrerender({ + root: tmpDir, + targetUrl: "https://app.example.com", + strict: true, + fetchImpl: fetchMock as typeof fetch, + }), + ).rejects.toThrow("CDN warmup failed for 1/1 path"); + }); +}); diff --git a/tests/cloudflare-version-deploy.test.ts b/tests/cloudflare-version-deploy.test.ts new file mode 100644 index 0000000000..215e6cf4eb --- /dev/null +++ b/tests/cloudflare-version-deploy.test.ts @@ -0,0 +1,251 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + buildWranglerDeploymentsStatusArgs, + buildWranglerTriggersDeployArgs, + buildWranglerVersionDeployArgs, + buildWranglerVersionUploadArgs, + parseVersionId, + parseWorkersDevUrl, + parseWranglerDeploymentStatusOutput, + parseWranglerVersionUploadOutput, + runWranglerVersionDeploy, + runWranglerVersionUpload, +} from "../packages/cloudflare/src/version-deploy.js"; + +describe("Cloudflare Wrangler version deployment helpers", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("builds version upload args for production", () => { + expect(buildWranglerVersionUploadArgs({})).toEqual({ + args: ["versions", "upload"], + env: undefined, + }); + }); + + it("builds version upload args for a named environment and preview alias", () => { + expect(buildWranglerVersionUploadArgs({ env: "staging", previewAlias: "warm-build" })).toEqual({ + args: ["versions", "upload", "--env", "staging", "--preview-alias", "warm-build"], + env: "staging", + }); + }); + + it("builds version upload args for an explicit Worker name", () => { + expect( + buildWranglerVersionUploadArgs({ + name: "custom-worker", + env: "staging", + previewAlias: "warm-build", + }), + ).toEqual({ + args: [ + "versions", + "upload", + "--name", + "custom-worker", + "--env", + "staging", + "--preview-alias", + "warm-build", + ], + env: "staging", + }); + }); + + it("builds version upload args for an explicit Wrangler config", () => { + expect(buildWranglerVersionUploadArgs({ config: "dist/server/wrangler.json" })).toEqual({ + args: ["versions", "upload", "--config", "dist/server/wrangler.json"], + env: undefined, + }); + }); + + it("builds non-interactive version deploy args for the uploaded version", () => { + expect( + buildWranglerVersionDeployArgs( + [{ versionId: "095f00a7-23a7-43b7-a227-e4c97cab5f22", percentage: 100 }], + {}, + ), + ).toEqual({ + args: ["versions", "deploy", "095f00a7-23a7-43b7-a227-e4c97cab5f22@100%", "--yes"], + env: undefined, + }); + }); + + it("builds non-interactive split deployment args for staging a version", () => { + expect( + buildWranglerVersionDeployArgs( + [ + { versionId: "11111111-1111-4111-8111-111111111111", percentage: 100 }, + { versionId: "22222222-2222-4222-8222-222222222222", percentage: 0 }, + ], + { env: "staging" }, + ), + ).toEqual({ + args: [ + "versions", + "deploy", + "11111111-1111-4111-8111-111111111111@100%", + "22222222-2222-4222-8222-222222222222@0%", + "--yes", + "--env", + "staging", + ], + env: "staging", + }); + }); + + it("logs distinct labels for staged and promoted version deploys", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const execute = vi.fn(() => "Deployed version\nhttps://app.example.workers.dev\n"); + + runWranglerVersionDeploy( + "/tmp/app", + [ + { versionId: "11111111-1111-4111-8111-111111111111", percentage: 100 }, + { versionId: "22222222-2222-4222-8222-222222222222", percentage: 0 }, + ], + {}, + "stage", + execute as never, + ); + runWranglerVersionDeploy( + "/tmp/app", + [{ versionId: "22222222-2222-4222-8222-222222222222", percentage: 100 }], + {}, + "promote-warmed", + execute as never, + ); + runWranglerVersionDeploy( + "/tmp/app", + [{ versionId: "22222222-2222-4222-8222-222222222222", percentage: 100 }], + { env: "staging" }, + "promote-uploaded", + execute as never, + ); + + expect(log).toHaveBeenCalledWith( + "\n Staging uploaded Worker version at 0% for CDN warmup in production...", + ); + expect(log).toHaveBeenCalledWith("\n Promoting warmed Worker version to production..."); + expect(log).toHaveBeenCalledWith("\n Promoting uploaded Worker version to env: staging..."); + }); + + it("asks for an initial deploy without CDN pre-warm when the Worker does not exist yet", () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const execute = vi.fn(() => { + throw Object.assign(new Error("Command failed"), { + stderr: + "✘ [ERROR] You cannot upload a new version of a Worker that does not yet exist. Please run the `deploy` command first.", + }); + }); + + expect(() => runWranglerVersionUpload("/tmp/app", {}, execute as never)).toThrow( + "Run `vinext-cloudflare deploy` once without `--warm-cdn-cache` to create the Worker", + ); + }); + + it("builds deployment status args for environments", () => { + expect(buildWranglerDeploymentsStatusArgs({ env: "preview" })).toEqual({ + args: ["deployments", "status", "--json", "--env", "preview"], + env: "preview", + }); + }); + + it("builds deployment status args for an explicit Worker name", () => { + expect(buildWranglerDeploymentsStatusArgs({ name: "custom-worker" })).toEqual({ + args: ["deployments", "status", "--json", "--name", "custom-worker"], + env: undefined, + }); + }); + + it("builds deployment status args for an explicit Wrangler config", () => { + expect(buildWranglerDeploymentsStatusArgs({ config: "dist/server/wrangler.json" })).toEqual({ + args: ["deployments", "status", "--json", "--config", "dist/server/wrangler.json"], + env: undefined, + }); + }); + + it("builds trigger deployment args for environments", () => { + expect(buildWranglerTriggersDeployArgs({ env: "preview" })).toEqual({ + args: ["triggers", "deploy", "--env", "preview"], + env: "preview", + }); + }); + + it("parses version IDs and preview URLs from Wrangler text output", () => { + const output = ` + Uploaded app 095f00a7-23a7-43b7-a227-e4c97cab5f22 + https://app-warm.example.workers.dev + `; + + expect(parseVersionId(output)).toBe("095f00a7-23a7-43b7-a227-e4c97cab5f22"); + expect(parseWorkersDevUrl(output)).toBe("https://app-warm.example.workers.dev"); + expect(parseWranglerVersionUploadOutput(output)).toMatchObject({ + versionId: "095f00a7-23a7-43b7-a227-e4c97cab5f22", + previewUrl: "https://app-warm.example.workers.dev", + }); + }); + + it("parses workers.dev URLs without depending on a broad URL regex", () => { + expect(parseWorkersDevUrl("Preview: .")).toBe( + "https://app.example.workers.dev/path?x=1", + ); + expect(parseWorkersDevUrl(`https://${"a".repeat(2048)}.example.com`)).toBeNull(); + }); + + it("parses version upload JSON output", () => { + expect( + parseWranglerVersionUploadOutput( + JSON.stringify({ + version: { id: "095f00a7-23a7-43b7-a227-e4c97cab5f22" }, + preview_url: "https://app-warm.example.workers.dev", + }), + ), + ).toMatchObject({ + versionId: "095f00a7-23a7-43b7-a227-e4c97cab5f22", + previewUrl: "https://app-warm.example.workers.dev", + }); + }); + + it("does not treat unrelated nested JSON IDs and URLs as upload metadata", () => { + expect( + parseWranglerVersionUploadOutput( + JSON.stringify({ + version_id: "095f00a7-23a7-43b7-a227-e4c97cab5f22", + preview_url: "https://app-warm.example.workers.dev", + diagnostics: { + id: "not-the-version", + url: "https://not-preview.example.workers.dev", + }, + }), + ), + ).toMatchObject({ + versionId: "095f00a7-23a7-43b7-a227-e4c97cab5f22", + previewUrl: "https://app-warm.example.workers.dev", + }); + }); + + it("throws when upload output lacks a version ID", () => { + expect(() => parseWranglerVersionUploadOutput("uploaded")).toThrow( + "Could not detect Worker version ID", + ); + }); + + it("keeps the uploaded version when Wrangler does not return a preview URL", () => { + expect(parseWranglerVersionUploadOutput("095f00a7-23a7-43b7-a227-e4c97cab5f22")).toMatchObject({ + versionId: "095f00a7-23a7-43b7-a227-e4c97cab5f22", + previewUrl: null, + }); + }); + + it("parses current deployment version traffic", () => { + expect( + parseWranglerDeploymentStatusOutput( + JSON.stringify({ + versions: [{ version_id: "11111111-1111-4111-8111-111111111111", percentage: 100 }], + }), + ).versions, + ).toEqual([{ versionId: "11111111-1111-4111-8111-111111111111", percentage: 100 }]); + }); +}); diff --git a/tests/create-vinext-app.test.ts b/tests/create-vinext-app.test.ts index b064bf1de8..68f59e44d5 100644 --- a/tests/create-vinext-app.test.ts +++ b/tests/create-vinext-app.test.ts @@ -34,6 +34,16 @@ const cloudflareInitOptions: ResolvedInitOptions = { }, }; +const warmCloudflareInitOptions: ResolvedInitOptions = { + platform: "cloudflare", + prerender: false, + cloudflare: { + dataCache: "kv", + cdnCache: "workers-cache", + imageOptimization: "cloudflare-images", + }, +}; + const nodeInitOptions: ResolvedInitOptions = { platform: "node", prerender: false, @@ -111,6 +121,26 @@ describe("createVinextApp", () => { }); }); + it("shows the warm CDN cache deploy command by default for Workers Cache init", async () => { + const appPath = path.join(tmpDir, "warm-app"); + + await withQuietConsole(() => + createVinextApp({ + appPath, + packageManager: "npm", + install: false, + git: false, + initOptions: warmCloudflareInitOptions, + }), + ); + + expect(readFile(appPath, "app/page.tsx")).toContain( + "pnpm exec vinext-cloudflare deploy --warm-cdn-cache", + ); + const pkg = readPkg(appPath); + expect(pkg.scripts?.["deploy:vinext"]).toBe("vinext-cloudflare deploy --warm-cdn-cache"); + }); + it("uses the selected package manager through the shared init install path", async () => { const appPath = path.join(tmpDir, "install-app"); const calls: string[] = []; diff --git a/tests/deploy-prerender-config.test.ts b/tests/deploy-prerender-config.test.ts index b539b1ba3e..3009646bb4 100644 --- a/tests/deploy-prerender-config.test.ts +++ b/tests/deploy-prerender-config.test.ts @@ -90,6 +90,41 @@ function writeProject(prerenderConfig: string, cacheConfig?: string): void { ); } +function writeApiOnlyProject(): void { + writeFile("package.json", JSON.stringify({ name: "warm-skip-build-app", type: "module" })); + writeFile( + "app/api/health/route.ts", + "export function GET() { return Response.json({ ok: true }); }\n", + ); + writeFile( + "node_modules/@cloudflare/vite-plugin/package.json", + JSON.stringify({ name: "@cloudflare/vite-plugin", type: "module", main: "index.js" }), + ); + writeFile( + "node_modules/@cloudflare/vite-plugin/index.js", + "export function cloudflare() { return { name: 'test-cloudflare-plugin' }; }\n", + ); + writeFile( + "wrangler.jsonc", + '{"main":"vinext/server/app-router-entry","assets":{"directory":"dist/client"}}\n', + ); + writeFile( + "vite.config.ts", + [ + 'import { defineConfig } from "vite";', + 'import { cloudflare } from "@cloudflare/vite-plugin";', + 'import vinext from "../packages/vinext/src/index";', + "", + "export default defineConfig({", + " plugins: [vinext(), cloudflare()],", + "});", + "", + ].join("\n"), + ); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); +} + function writeProjectWithThrowingViteConfig(): void { writeFile("package.json", JSON.stringify({ name: "prerender-config-app", type: "module" })); writeFile("app/page.tsx", "export default function Page() { return
home
; }\n"); @@ -172,6 +207,7 @@ describe("deploy prerender config wiring", () => { await deploy({ root: tmpDir, skipBuild: true, prerenderAll: true }); expect(runPrerenderMock).toHaveBeenCalledWith({ root: tmpDir, concurrency: undefined }); + expect(fs.existsSync(path.join(tmpDir, "dist/server/vinext-prerender-paths.json"))).toBe(false); }); it("does not load Vite config when static export already wins", async () => { @@ -249,4 +285,25 @@ describe("deploy prerender config wiring", () => { "deploy", ]); }); + + it("discovers warmup paths during skip-build warm CDN deploys", async () => { + writeApiOnlyProject(); + const { deploy } = await import("../packages/cloudflare/src/deploy.js"); + + await deploy({ root: tmpDir, skipBuild: true, warmCdnCache: true }); + + expect( + JSON.parse( + fs.readFileSync(path.join(tmpDir, "dist/server/vinext-prerender-paths.json"), "utf-8"), + ), + ).toEqual({ + buildId: "build-a", + trailingSlash: false, + paths: [], + }); + expect(vi.mocked(spawn).mock.calls.at(-1)?.[1]).toEqual([ + expect.stringContaining("wrangler"), + "deploy", + ]); + }); }); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index b1ac721f0d..5450a41003 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -11,7 +11,9 @@ import { buildWranglerKVBulkPutArgs, buildWranglerInvocation, buildWranglerDeployArgs, + getZeroPercentStagingTraffic, parseDeployArgs, + resolveWorkerNameForVersionOverride, resolveWranglerBin, runWranglerKVBulkPut, runWranglerDeploy, @@ -59,7 +61,7 @@ import { mergeHeaders, resolveStaticAssetSignal, } from "../packages/vinext/src/server/worker-utils.js"; -import { domainCandidates, parseWranglerConfig } from "../packages/cloudflare/src/tpr.js"; +import { domainCandidates, parseWranglerConfig, runTPR } from "../packages/cloudflare/src/tpr.js"; // ─── Test Helpers ──────────────────────────────────────────────────────────── @@ -155,6 +157,20 @@ describe("buildWranglerDeployArgs", () => { }); }); + it("passes through explicit Worker names", () => { + expect(buildWranglerDeployArgs({ name: "custom-worker", env: "staging" })).toEqual({ + args: ["deploy", "--name", "custom-worker", "--env", "staging"], + env: "staging", + }); + }); + + it("passes through explicit Wrangler config paths", () => { + expect(buildWranglerDeployArgs({ config: "dist/server/wrangler.json" })).toEqual({ + args: ["deploy", "--config", "dist/server/wrangler.json"], + env: undefined, + }); + }); + it("prefers explicit env over --preview shorthand", () => { expect(buildWranglerDeployArgs({ preview: true, env: "qa" })).toEqual({ args: ["deploy", "--env", "qa"], @@ -606,6 +622,8 @@ describe("parseDeployArgs", () => { expect(parsed.name).toBeUndefined(); expect(parsed.skipBuild).toBe(false); expect(parsed.dryRun).toBe(false); + expect(parsed.warmCdnCache).toBe(false); + expect(parsed.warmCdnStrict).toBe(false); }); it("parses --env with space-separated value", () => { @@ -624,6 +642,12 @@ describe("parseDeployArgs", () => { expect(parseDeployArgs(["--name=my-app"]).name).toBe("my-app"); }); + it("parses --config with space-separated value", () => { + expect(parseDeployArgs(["--config", "dist/server/wrangler.json"]).config).toBe( + "dist/server/wrangler.json", + ); + }); + it("parses boolean flags", () => { const parsed = parseDeployArgs(["--preview", "--skip-build", "--dry-run"]); expect(parsed.preview).toBe(true); @@ -671,6 +695,35 @@ describe("parseDeployArgs", () => { ); }); + it("parses CDN warmup flags", () => { + const parsed = parseDeployArgs([ + "--warm-cdn-cache", + "--warm-cdn-concurrency", + "6", + "--warm-cdn-timeout=1500", + "--warm-cdn-retries", + "0", + "--warm-cdn-strict", + "--warm-cdn-include-fallbacks", + ]); + + expect(parsed.warmCdnCache).toBe(true); + expect(parsed.warmCdnConcurrency).toBe(6); + expect(parsed.warmCdnTimeout).toBe(1500); + expect(parsed.warmCdnRetries).toBe(0); + expect(parsed.warmCdnStrict).toBe(true); + expect(parsed.warmCdnIncludeFallbacks).toBe(true); + }); + + it("throws for invalid CDN warmup numeric flags", () => { + expect(() => parseDeployArgs(["--warm-cdn-concurrency=0"])).toThrow( + '--warm-cdn-concurrency expects a positive integer, but got "0".', + ); + expect(() => parseDeployArgs(["--warm-cdn-retries=-1"])).toThrow( + '--warm-cdn-retries expects a non-negative integer, but got "-1".', + ); + }); + it("trims whitespace from --env value", () => { expect(parseDeployArgs(["--env", " staging "]).env).toBe("staging"); }); @@ -1350,6 +1403,7 @@ describe("readPagesRouterEntrySource", () => { it("exports internal deploy dependencies consumed by @vinext/cloudflare", () => { const exportsMap = readVinextPackageExports(); expect(hasPackageExport(exportsMap, "./internal/build/run-prerender")).toBe(true); + expect(hasPackageExport(exportsMap, "./internal/build/prerender-paths")).toBe(true); expect(hasPackageExport(exportsMap, "./internal/config/dotenv")).toBe(true); expect(hasPackageExport(exportsMap, "./internal/config/next-config")).toBe(true); expect(hasPackageExport(exportsMap, "./internal/config/prerender")).toBe(true); @@ -3034,6 +3088,77 @@ describe("domainCandidates", () => { // ─── parseWranglerConfig — TPR fields ──────────────────────────────────────── describe("parseWranglerConfig — custom domain extraction", () => { + it("extracts Worker name", () => { + writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ name: "my-worker" })); + const config = parseWranglerConfig(tmpDir); + expect(config?.name).toBe("my-worker"); + }); + + it("reads an explicit Wrangler config path", () => { + writeFile(tmpDir, "dist/server/wrangler.json", JSON.stringify({ name: "generated-worker" })); + const config = parseWranglerConfig(tmpDir, "dist/server/wrangler.json"); + expect(config?.name).toBe("generated-worker"); + }); + + it("uses an explicit Wrangler config path during TPR", async () => { + writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ name: "source-worker" })); + writeFile( + tmpDir, + "dist/server/wrangler.json", + JSON.stringify({ + name: "generated-worker", + custom_domains: ["app.example.com"], + }), + ); + + const previousToken = process.env.CLOUDFLARE_API_TOKEN; + process.env.CLOUDFLARE_API_TOKEN = "token"; + try { + const result = await runTPR({ + root: tmpDir, + config: "dist/server/wrangler.json", + coverage: 90, + limit: 100, + window: 24, + }); + + expect(result.skipped).toBe("no VINEXT_KV_CACHE KV namespace configured"); + } finally { + if (previousToken === undefined) delete process.env.CLOUDFLARE_API_TOKEN; + else process.env.CLOUDFLARE_API_TOKEN = previousToken; + } + }); + + it("parses JSONC comments and trailing commas", () => { + writeFile( + tmpDir, + "wrangler.jsonc", + `{ + // Wrangler accepts JSONC comments and trailing commas. + "name": "my-worker", + "custom_domains": ["app.example.com",], + "kv_namespaces": [ + { "binding": "VINEXT_KV_CACHE", "id": "abc123", }, + ], + "env": { + "staging": { + "name": "my-worker-staging", + "custom_domains": ["staging.example.com",], + }, + }, + }`, + ); + + const config = parseWranglerConfig(tmpDir); + expect(config?.name).toBe("my-worker"); + expect(config?.customDomain).toBe("app.example.com"); + expect(config?.kvNamespaceId).toBe("abc123"); + expect(config?.env?.staging).toEqual({ + name: "my-worker-staging", + customDomain: "staging.example.com", + }); + }); + it("extracts custom domain from routes array (string form)", () => { writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ routes: ["example.co.uk/*"] })); const config = parseWranglerConfig(tmpDir); @@ -3063,4 +3188,133 @@ describe("parseWranglerConfig — custom domain extraction", () => { const config = parseWranglerConfig(tmpDir); expect(config?.kvNamespaceId).toBe("abc123"); }); + + it("extracts environment Worker names and custom domains", () => { + writeFile( + tmpDir, + "wrangler.jsonc", + JSON.stringify({ + name: "my-worker", + env: { + staging: { + name: "my-worker-staging-custom", + custom_domains: ["staging.example.com"], + }, + }, + }), + ); + + const config = parseWranglerConfig(tmpDir); + expect(config?.env?.staging).toEqual({ + name: "my-worker-staging-custom", + customDomain: "staging.example.com", + }); + }); + + it("extracts environment custom domains from TOML route arrays", () => { + writeFile( + tmpDir, + "wrangler.toml", + ` +name = "my-worker" + +[env.staging] +routes = ["staging.example.com/*"] +`, + ); + + const config = parseWranglerConfig(tmpDir); + expect(config?.env?.staging?.customDomain).toBe("staging.example.com"); + }); + + it("extracts environment custom domains from TOML route blocks", () => { + writeFile( + tmpDir, + "wrangler.toml", + ` +name = "my-worker" + +[env.staging] +name = "my-worker-staging" + +[[env.staging.routes]] +pattern = "staging.example.com/*" +`, + ); + + const config = parseWranglerConfig(tmpDir); + expect(config?.env?.staging).toEqual({ + name: "my-worker-staging", + customDomain: "staging.example.com", + }); + }); +}); + +// ─── CDN warmup Worker version overrides ─────────────────────────────────── + +describe("resolveWorkerNameForVersionOverride", () => { + it("uses the top-level Worker name for production", () => { + writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ name: "my-worker" })); + expect(resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), {})).toBe("my-worker"); + }); + + it("uses the CLI Worker name exactly when provided", () => { + writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ name: "config-worker" })); + expect( + resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), { + name: "cli-worker", + env: "staging", + }), + ).toBe("cli-worker"); + }); + + it("appends the target environment for Wrangler legacy environments", () => { + writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ name: "my-worker" })); + expect( + resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), { env: "staging" }), + ).toBe("my-worker-staging"); + }); + + it("uses env-specific Worker names for Wrangler legacy environments", () => { + writeFile( + tmpDir, + "wrangler.jsonc", + JSON.stringify({ + name: "my-worker", + env: { staging: { name: "custom-staging-worker" } }, + }), + ); + expect( + resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), { env: "staging" }), + ).toBe("custom-staging-worker"); + }); + + it("keeps the service name for Wrangler service environments", () => { + writeFile( + tmpDir, + "wrangler.jsonc", + JSON.stringify({ + name: "my-worker", + legacy_env: false, + env: { staging: {} }, + }), + ); + expect( + resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), { env: "staging" }), + ).toBe("my-worker"); + }); +}); + +describe("getZeroPercentStagingTraffic", () => { + it("does not stage the uploaded version when it is already the current deployment", () => { + expect( + getZeroPercentStagingTraffic( + { + versions: [{ versionId: "22222222-2222-4222-8222-222222222222", percentage: 100 }], + output: "{}", + }, + "22222222-2222-4222-8222-222222222222", + ), + ).toBeNull(); + }); }); diff --git a/tests/image-adapters-config.test.ts b/tests/image-adapters-config.test.ts index 7bf5010c97..ee578efc60 100644 --- a/tests/image-adapters-config.test.ts +++ b/tests/image-adapters-config.test.ts @@ -68,6 +68,9 @@ describe("generateImageAdaptersModule", () => { const code = generateImageAdaptersModule({ optimizer: { adapter: "@vinext/cloudflare/images/images-optimizer" }, }); + expect(code).toContain( + "if (typeof process !== 'undefined' && process.env?.__VINEXT_PRERENDER_PATH_DISCOVERY === '1') return;", + ); expect(code).toContain("if (__vinextImageOptimizerRegistered) return;"); expect(code).toContain("__vinextImageOptimizerRegistered = true;"); }); diff --git a/tests/init-platform.test.ts b/tests/init-platform.test.ts index 71e47e8d74..eb50aed48a 100644 --- a/tests/init-platform.test.ts +++ b/tests/init-platform.test.ts @@ -7,10 +7,12 @@ import { parseCdnCacheArg, parseImageOptimizationArg, parsePrerenderArg, + parseWarmCdnCacheArg, resolveCloudflareInitOptions, resolveInitOptions, resolveInitPlatform, resolveInitPrerender, + resolveInitWarmCdnCache, } from "../packages/vinext/src/init-platform.js"; describe("parsePlatformArg", () => { @@ -221,6 +223,48 @@ describe("prerender init choice", () => { }); }); +describe("warm CDN cache init choice", () => { + it("parses explicit warm CDN cache flags", () => { + expect(parseWarmCdnCacheArg(["--warm-cdn-cache"])).toBe(true); + expect(parseWarmCdnCacheArg(["--no-warm-cdn-cache"])).toBe(false); + expect(parseWarmCdnCacheArg(["--warm-cdn-cache=true"])).toBe(true); + expect(parseWarmCdnCacheArg(["--warm-cdn-cache=false"])).toBe(false); + }); + + it("rejects unsupported explicit values", () => { + expect(() => parseWarmCdnCacheArg(["--warm-cdn-cache=maybe"])).toThrow( + "--warm-cdn-cache expects true or false", + ); + }); + + it("defaults non-interactive and agent environments to enabled", async () => { + await expect(resolveInitWarmCdnCache([], { env: {}, isInteractive: false })).resolves.toBe( + true, + ); + await expect( + resolveInitWarmCdnCache([], { env: { CODEX_THREAD_ID: "test" }, isInteractive: true }), + ).resolves.toBe(true); + }); + + it("defaults the interactive prompt to Yes", async () => { + const prompts: string[] = []; + const output = new PassThrough(); + await expect( + resolveInitWarmCdnCache([], { + env: {}, + isInteractive: true, + output, + question: async (prompt) => { + prompts.push(prompt); + return ""; + }, + }), + ).resolves.toBe(true); + expect(prompts).toEqual([" Pre-warm Workers Cache during deploy? [Y/n]: "]); + expect(output.read()?.toString()).toBe("\n"); + }); +}); + describe("isAgentEnvironment", () => { it("detects agents supported by am-i-vibing", () => { expect(isAgentEnvironment({ CODEX_THREAD_ID: "test" })).toBe(true); @@ -280,6 +324,19 @@ describe("resolveInitPlatform", () => { }); describe("resolveInitOptions", () => { + it("defaults Cloudflare Workers Cache init to CDN pre-warming", async () => { + await expect(resolveInitOptions([], { env: {}, isInteractive: false })).resolves.toEqual({ + platform: "cloudflare", + prerender: false, + cloudflare: { + dataCache: "kv", + cdnCache: "workers-cache", + imageOptimization: "cloudflare-images", + warmCdnCache: true, + }, + }); + }); + it("shares the full vinext init option selection flow", async () => { await expect( resolveInitOptions( @@ -299,7 +356,88 @@ describe("resolveInitOptions", () => { dataCache: "none", cdnCache: "data-cache", imageOptimization: "none", + warmCdnCache: false, + }, + }); + }); + + it("asks whether to pre-warm Workers Cache after the prerender prompt", async () => { + const prompts: string[] = []; + const answers = ["", "", "", "n", ""]; + + await expect( + resolveInitOptions(["--platform=cloudflare"], { + env: {}, + isInteractive: true, + question: async (prompt) => { + prompts.push(prompt); + return answers.shift() ?? ""; + }, + }), + ).resolves.toEqual({ + platform: "cloudflare", + prerender: false, + cloudflare: { + dataCache: "kv", + cdnCache: "workers-cache", + imageOptimization: "cloudflare-images", + warmCdnCache: true, }, }); + + expect(prompts).toEqual([ + " Choose a CDN cache:\n 1. Workers Cache (default)\n 2. Data cache\n CDN cache [1]: ", + " Choose a data cache:\n 1. Cloudflare KV (default)\n 2. None\n Data cache [1]: ", + " Choose image optimization:\n 1. Cloudflare Images (default)\n 2. None\n Image optimization [1]: ", + " Pre-render all static routes after build? [y/N]: ", + " Pre-warm Workers Cache during deploy? [Y/n]: ", + ]); + }); + + it("does not ask about pre-warming when Data cache is selected for CDN cache", async () => { + const prompts: string[] = []; + const answers = ["2", "", "", ""]; + + await expect( + resolveInitOptions(["--platform=cloudflare"], { + env: {}, + isInteractive: true, + question: async (prompt) => { + prompts.push(prompt); + return answers.shift() ?? ""; + }, + }), + ).resolves.toEqual({ + platform: "cloudflare", + prerender: false, + cloudflare: { + dataCache: "kv", + cdnCache: "data-cache", + imageOptimization: "cloudflare-images", + warmCdnCache: false, + }, + }); + + expect(prompts).toEqual([ + " Choose a CDN cache:\n 1. Workers Cache (default)\n 2. Data cache\n CDN cache [1]: ", + " Choose a data cache:\n 1. Cloudflare KV (default)\n 2. None\n Data cache [1]: ", + " Choose image optimization:\n 1. Cloudflare Images (default)\n 2. None\n Image optimization [1]: ", + " Pre-render all static routes after build? [y/N]: ", + ]); + }); + + it("rejects warm CDN cache when Data cache is selected for CDN cache", async () => { + await expect( + resolveInitOptions( + [ + "--platform=cloudflare", + "--cdn-cache=data-cache", + "--data-cache=kv", + "--image-optimization=none", + "--warm-cdn-cache", + ], + { env: {}, isInteractive: false }, + ), + ).rejects.toThrow("--warm-cdn-cache requires --cdn-cache=workers-cache"); }); }); diff --git a/tests/init.test.ts b/tests/init.test.ts index 81c7c0134a..1a2c5f2767 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -291,6 +291,16 @@ describe("addScripts", () => { expect(pkg.scripts["deploy:vinext"]).toBe("vinext-cloudflare deploy"); }); + it("adds the warm CDN cache flag to deploy:vinext when requested", () => { + setupProject(tmpDir, { router: "app" }); + + const added = addScripts(tmpDir, 3001, "cloudflare", { warmCdnCache: true }); + + expect(added).toContain("deploy:vinext"); + const pkg = readPkg(tmpDir) as { scripts: Record }; + expect(pkg.scripts["deploy:vinext"]).toBe("vinext-cloudflare deploy --warm-cdn-cache"); + }); + it("uses custom port", () => { setupProject(tmpDir, { router: "app" }); @@ -830,6 +840,31 @@ export default { plugins: [vinext({ cache: { data: customData() } })] }; expect(pkg.scripts["dev:vinext"]).toBe("vinext dev --port 3001"); expect(pkg.scripts["build:vinext"]).toBe("vinext build"); expect(pkg.scripts["start:vinext"]).toBe("wrangler dev --config dist/server/wrangler.json"); + expect(pkg.scripts["deploy:vinext"]).toBe("vinext-cloudflare deploy --warm-cdn-cache"); + }); + + it("adds a warm CDN cache deploy script by default for Workers Cache init", async () => { + setupProject(tmpDir, { router: "app" }); + + await runInit(tmpDir); + + const pkg = readPkg(tmpDir) as { scripts: Record }; + expect(pkg.scripts["deploy:vinext"]).toBe("vinext-cloudflare deploy --warm-cdn-cache"); + }); + + it("skips the warm CDN cache deploy flag when Cloudflare init opts out", async () => { + setupProject(tmpDir, { router: "app" }); + + await runInit(tmpDir, { + cloudflare: { + dataCache: "kv", + cdnCache: "workers-cache", + imageOptimization: "cloudflare-images", + warmCdnCache: false, + }, + }); + + const pkg = readPkg(tmpDir) as { scripts: Record }; expect(pkg.scripts["deploy:vinext"]).toBe("vinext-cloudflare deploy"); }); @@ -979,7 +1014,7 @@ describe("init — dependency installation", () => { "dev:vinext": "vinext dev --port 3001", "build:vinext": "vinext build", "start:vinext": "wrangler dev --config dist/server/wrangler.json", - "deploy:vinext": "vinext-cloudflare deploy", + "deploy:vinext": "vinext-cloudflare deploy --warm-cdn-cache", }); expect(setup.viteConfigExists).toBe(true); expect(setup.wranglerConfigExists).toBe(true); @@ -1006,7 +1041,7 @@ describe("init — dependency installation", () => { "dev:vinext": "vinext dev --port 3001", "build:vinext": "vinext build", "start:vinext": "wrangler dev --config dist/server/wrangler.json", - "deploy:vinext": "vinext-cloudflare deploy", + "deploy:vinext": "vinext-cloudflare deploy --warm-cdn-cache", }, }); expect(fs.existsSync(path.join(tmpDir, "vite.config.ts"))).toBe(true); diff --git a/tests/pregenerated-concrete-paths.test.ts b/tests/pregenerated-concrete-paths.test.ts index 2cf9c00d4b..d5b27caab0 100644 --- a/tests/pregenerated-concrete-paths.test.ts +++ b/tests/pregenerated-concrete-paths.test.ts @@ -6,7 +6,10 @@ import { initPregeneratedPathsFromGlobals, normalizePregeneratedPathname, } from "../packages/vinext/src/server/pregenerated-concrete-paths.js"; -import { isFallbackShellArtifactPath } from "../packages/vinext/src/server/prerender-manifest.js"; +import { + getPrerenderedConcretePaths, + isFallbackShellArtifactPath, +} from "../packages/vinext/src/server/prerender-manifest.js"; describe("pregenerated concrete paths", () => { afterEach(() => { @@ -127,4 +130,123 @@ describe("pregenerated concrete paths", () => { ).toBe(false); }); }); + + describe("getPrerenderedConcretePaths", () => { + it("selects unique rendered App and Pages paths and skips fallback shells by default", () => { + expect( + getPrerenderedConcretePaths({ + routes: [ + { route: "/", status: "rendered", router: "app", revalidate: false, fallback: false }, + { + route: "/blog/:slug", + path: "/blog/hello", + status: "rendered", + router: "app", + revalidate: 60, + fallback: false, + }, + { + route: "/blog/:slug", + path: "/blog/[slug]", + status: "rendered", + router: "app", + revalidate: 60, + fallback: true, + }, + { + route: "/docs/:slug", + path: "/docs/intro", + status: "rendered", + router: "pages", + revalidate: false, + fallback: false, + }, + { + route: "/404", + status: "rendered", + router: "pages", + revalidate: false, + fallback: false, + }, + { + route: "/500", + status: "rendered", + router: "pages", + revalidate: false, + fallback: false, + }, + { + route: "/_error", + status: "rendered", + router: "pages", + revalidate: false, + fallback: false, + }, + { + route: "/docs/:slug", + path: "/docs/intro", + status: "rendered", + router: "pages", + revalidate: false, + fallback: false, + }, + { route: "/api/hello", status: "skipped" }, + ], + }), + ).toEqual(["/", "/blog/hello", "/docs/intro"]); + }); + + it("can include error documents when explicitly requested", () => { + expect( + getPrerenderedConcretePaths( + { + routes: [ + { + route: "/404", + status: "rendered", + router: "pages", + revalidate: false, + fallback: false, + }, + { + route: "/500", + status: "rendered", + router: "pages", + revalidate: false, + fallback: false, + }, + { + route: "/_error", + status: "rendered", + router: "pages", + revalidate: false, + fallback: false, + }, + ], + }, + { includeErrorDocuments: true }, + ), + ).toEqual(["/404", "/500", "/_error"]); + }); + + it("can include fallback-shell placeholder paths for explicit warmup requests", () => { + expect( + getPrerenderedConcretePaths( + { + routes: [ + { + route: "/blog/:slug", + path: "/blog/[slug]", + status: "rendered", + router: "app", + revalidate: 60, + fallback: true, + }, + ], + }, + { includeFallbackShells: true }, + ), + ).toEqual(["/blog/[slug]"]); + }); + }); }); diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts new file mode 100644 index 0000000000..9508bebdc7 --- /dev/null +++ b/tests/prerender-paths.test.ts @@ -0,0 +1,248 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const closeMock = vi.hoisted(() => vi.fn((callback: () => void) => callback())); +const startProdServerMock = vi.hoisted(() => + vi.fn(async () => ({ server: { close: closeMock }, port: 43210 })), +); + +vi.mock("../packages/vinext/src/server/prod-server.js", () => ({ + startProdServer: startProdServerMock, +})); + +let tmpDir: string; + +function writeFile(relativePath: string, content: string): void { + const fullPath = path.join(tmpDir, relativePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, "utf-8"); +} + +describe("prerender path manifest", () => { + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-prerender-paths-test-")); + closeMock.mockClear(); + startProdServerMock.mockClear(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const rawUrl = + input instanceof URL ? input.href : typeof input === "string" ? input : input.url; + const url = new URL(rawUrl); + if ( + url.pathname === "/__vinext/prerender/static-params" && + url.searchParams.get("pattern") === "/cached/:slug" + ) { + return Response.json([{ slug: "intro" }, { slug: "featured" }]); + } + return new Response("null", { headers: { "content-type": "application/json" } }); + }), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("writes concrete warmup paths without rendering page artifacts", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile("app/page.tsx", "export default function Page() { return null; }\n"); + writeFile( + "app/cached/[slug]/page.tsx", + [ + "export const revalidate = 60;", + "export function generateStaticParams() { return [{ slug: 'intro' }, { slug: 'featured' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile( + "app/dynamic/page.tsx", + "export const dynamic = 'force-dynamic'; export default function Page() { return null; }\n", + ); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + + const manifest = await emitPrerenderPathManifest({ root: tmpDir }); + + expect(manifest).toEqual({ + buildId: "build-a", + trailingSlash: false, + paths: ["/", "/cached/intro", "/cached/featured"], + }); + expect(fs.existsSync(path.join(tmpDir, "dist/server/prerendered-routes"))).toBe(false); + expect( + JSON.parse( + fs.readFileSync(path.join(tmpDir, "dist/server/vinext-prerender-paths.json"), "utf-8"), + ), + ).toEqual(manifest); + expect(fetch).toHaveBeenCalledWith( + "http://127.0.0.1:43210/__vinext/prerender/static-params?pattern=%2Fcached%2F%3Aslug", + expect.any(Object), + ); + expect(startProdServerMock).toHaveBeenCalledOnce(); + expect(startProdServerMock).toHaveBeenCalledWith( + expect.objectContaining({ + rscEntryPath: path.join(tmpDir, "dist/server/index.js"), + }), + ); + expect(closeMock).toHaveBeenCalledOnce(); + }); + + it("skips dynamic warmup paths when static params discovery aborts", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + const error = new Error("aborted"); + error.name = "AbortError"; + throw error; + }), + ); + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/cached/[slug]/page.tsx", + [ + "export const revalidate = 60;", + "export function generateStaticParams() { return [{ slug: 'intro' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + + const manifest = await emitPrerenderPathManifest({ root: tmpDir }); + + expect(manifest?.paths).toEqual([]); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("failed to discover warmup path(s) for /cached/:slug"), + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("timed out after 30000ms")); + }); + + it("passes a custom RSC bundle path to the path discovery server", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/custom-rsc/BUILD_ID", "build-a\n"); + writeFile("dist/custom-rsc/index.js", "export default {};\n"); + writeFile( + "app/cached/[slug]/page.tsx", + [ + "export const revalidate = 60;", + "export function generateStaticParams() { return [{ slug: 'intro' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const rscBundlePath = path.join(tmpDir, "dist/custom-rsc/index.js"); + + await emitPrerenderPathManifest({ root: tmpDir, rscBundlePath }); + + expect(startProdServerMock).toHaveBeenCalledWith( + expect.objectContaining({ + outDir: path.join(tmpDir, "dist"), + rscEntryPath: rscBundlePath, + }), + ); + }); + + it("derives a custom RSC bundle path from route metadata", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/custom-rsc/BUILD_ID", "build-a\n"); + writeFile("dist/custom-rsc/index.js", "export default {};\n"); + writeFile( + "app/cached/[slug]/page.tsx", + [ + "export const revalidate = 60;", + "export function generateStaticParams() { return [{ slug: 'intro' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + + await emitPrerenderPathManifest({ + root: tmpDir, + routeRootConfig: { rscOutDir: "dist/custom-rsc" }, + }); + + expect(startProdServerMock).toHaveBeenCalledWith( + expect.objectContaining({ + outDir: path.join(tmpDir, "dist"), + rscEntryPath: path.join(tmpDir, "dist/custom-rsc/index.js"), + }), + ); + expect(fs.existsSync(path.join(tmpDir, "dist/custom-rsc/vinext-prerender-paths.json"))).toBe( + false, + ); + expect(fs.existsSync(path.join(tmpDir, "dist/server/vinext-prerender-paths.json"))).toBe(true); + }); + + it("loads next.config with the production build phase", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile( + "next.config.mjs", + "export default (phase) => ({ trailingSlash: phase === 'phase-production-build' });\n", + ); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile("app/page.tsx", "export default function Page() { return null; }\n"); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + + const manifest = await emitPrerenderPathManifest({ root: tmpDir }); + + expect(manifest?.trailingSlash).toBe(true); + expect(manifest?.paths).toEqual(["/"]); + expect(startProdServerMock).not.toHaveBeenCalled(); + }); + + it("honors a configured route root when discovering paths", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile("app/wrong/page.tsx", "export default function Page() { return null; }\n"); + writeFile("custom/app/right/page.tsx", "export default function Page() { return null; }\n"); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + routeRootConfig: { appDir: "custom" }, + }); + + expect(manifest?.paths).toEqual(["/right"]); + }); + + it("honors disabled App Router when discovering paths", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile("app/app-only/page.tsx", "export default function Page() { return null; }\n"); + writeFile("pages/pages-only.tsx", "export default function Page() { return null; }\n"); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + routeRootConfig: { disableAppRouter: true }, + }); + + expect(manifest?.paths).toEqual(["/pages-only"]); + }); +});