From 7de4fbc06046c60a61056fb760d4857eba768ec7 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 29 Jun 2026 18:09:41 +0100 Subject: [PATCH 1/5] feat(build): support prerender vite config --- packages/vinext/src/cli.ts | 48 +++++++--- packages/vinext/src/config/prerender.ts | 111 ++++++++++++++++++++++++ packages/vinext/src/deploy.ts | 52 +++++++---- packages/vinext/src/index.ts | 30 +++++++ tests/deploy-prerender-config.test.ts | 102 ++++++++++++++++++++++ tests/prerender-config.test.ts | 87 +++++++++++++++++++ 6 files changed, 398 insertions(+), 32 deletions(-) create mode 100644 packages/vinext/src/config/prerender.ts create mode 100644 tests/deploy-prerender-config.test.ts create mode 100644 tests/prerender-config.test.ts diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 3ddc123490..49e9e6a4f6 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -52,6 +52,12 @@ import { import { generateRouteTypes } from "./typegen.js"; import { normalizePathSeparators } from "./utils/path.js"; import { createDevServerConfigPlugin, normalizeDevServerHostname } from "./cli-dev-config.js"; +import { + findVinextPrerenderConfigInPlugins, + formatVinextPrerenderLabel, + resolveVinextPrerenderDecision, + type ResolvedVinextPrerenderConfig, +} from "./config/prerender.js"; // ─── Resolve Vite from the project root ──────────────────────────────────────── // @@ -219,8 +225,16 @@ function hasPagesDir(): boolean { ); } -async function loadBuildEmptyOutDir(vite: ViteModule, root: string): Promise { - if (!hasViteConfig(root)) return undefined; +type BuildViteConfigMetadata = { + emptyOutDir?: boolean; + prerenderConfig: ResolvedVinextPrerenderConfig | null; +}; + +async function loadBuildViteConfigMetadata( + vite: ViteModule, + root: string, +): Promise { + if (!hasViteConfig(root)) return { prerenderConfig: null }; // Read the raw user config before the multi-environment build so // `build.emptyOutDir: false` remains an escape hatch for vinext's upfront clean. @@ -230,7 +244,10 @@ async function loadBuildEmptyOutDir(vite: ViteModule, root: string): Promise Maximum number of routes to pre-render in parallel --precompress Precompress static assets at build time (.br, .gz, .zst) @@ -929,8 +949,8 @@ function printHelp(cmd?: string) { --name Custom Worker name (default: from package.json) --skip-build Skip the build step (use existing dist/) --dry-run Generate config files without building or deploying - --prerender-all Pre-render discovered routes after building (future - releases will auto-populate the remote cache) + --prerender-all Pre-render discovered routes after building. Equivalent + to vinext({ prerender: true }) and takes priority. --prerender-concurrency Maximum number of routes to pre-render in parallel -h, --help Show this help diff --git a/packages/vinext/src/config/prerender.ts b/packages/vinext/src/config/prerender.ts new file mode 100644 index 0000000000..b8d0148d11 --- /dev/null +++ b/packages/vinext/src/config/prerender.ts @@ -0,0 +1,111 @@ +import type { PluginOption } from "vite"; +import { isUnknownRecord } from "../utils/record.js"; + +export type VinextPrerenderConfig = + | true + | { + /** + * Routes to pre-render after the production build. + * + * Currently only `"*"` is supported, which pre-renders every discovered + * App Router and Pages Router route that vinext can statically render. + */ + routes: "*"; + }; + +export type ResolvedVinextPrerenderConfig = { + routes: "*"; +}; + +export type VinextPrerenderDecisionReason = "flag" | "next-export" | "vinext-config"; + +export type VinextPrerenderDecision = ResolvedVinextPrerenderConfig & { + reason: VinextPrerenderDecisionReason; +}; + +export const VINEXT_PRERENDER_CONFIG_PLUGIN_PROPERTY = "__vinextPrerenderConfig"; + +type VinextPrerenderConfigPlugin = { + [VINEXT_PRERENDER_CONFIG_PLUGIN_PROPERTY]?: ResolvedVinextPrerenderConfig | null; +}; + +type ViteConfigLoader = { + loadConfigFromFile: typeof import("vite").loadConfigFromFile; +}; + +export function normalizeVinextPrerenderConfig( + config: VinextPrerenderConfig | undefined, +): ResolvedVinextPrerenderConfig | null { + if (config === undefined) return null; + if (config === true) return { routes: "*" }; + + if (!isUnknownRecord(config)) { + throw new Error('[vinext] Invalid `prerender` config. Use `true` or `{ routes: "*" }`.'); + } + + if (config.routes === "*") return { routes: "*" }; + + throw new Error( + '[vinext] Unsupported `prerender.routes` config. Currently only `routes: "*"` is supported.', + ); +} + +function flattenPluginOptions(value: unknown, target: unknown[]): void { + if (Array.isArray(value)) { + for (const item of value) flattenPluginOptions(item, target); + return; + } + if (value) target.push(value); +} + +export function findVinextPrerenderConfigInPlugins( + plugins: PluginOption[] | undefined, +): ResolvedVinextPrerenderConfig | null { + const flattened: unknown[] = []; + flattenPluginOptions(plugins, flattened); + + for (const plugin of flattened) { + if (!isUnknownRecord(plugin)) continue; + const prerenderConfig = (plugin as VinextPrerenderConfigPlugin)[ + VINEXT_PRERENDER_CONFIG_PLUGIN_PROPERTY + ]; + if (prerenderConfig) return prerenderConfig; + } + + return null; +} + +export async function loadVinextPrerenderConfigFromViteConfig( + vite: ViteConfigLoader, + root: string, +): Promise { + const loaded = await vite.loadConfigFromFile( + { command: "build", mode: "production" }, + undefined, + root, + ); + return findVinextPrerenderConfigInPlugins(loaded?.config.plugins); +} + +export function resolveVinextPrerenderDecision(options: { + prerenderAllFlag?: boolean; + vinextPrerenderConfig?: ResolvedVinextPrerenderConfig | null; + nextOutput?: string; +}): VinextPrerenderDecision | null { + if (options.prerenderAllFlag) return { routes: "*", reason: "flag" }; + if (options.nextOutput === "export") return { routes: "*", reason: "next-export" }; + if (options.vinextPrerenderConfig?.routes === "*") { + return { routes: "*", reason: "vinext-config" }; + } + return null; +} + +export function formatVinextPrerenderLabel(decision: VinextPrerenderDecision): string { + if (decision.reason === "next-export") { + return "Pre-rendering all routes (output: 'export')..."; + } + if (decision.reason === "vinext-config") { + return "Pre-rendering all routes (vinext prerender config)..."; + } + return "Pre-rendering all routes..."; +} diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 90c27685be..ad5ea37af5 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -20,6 +20,11 @@ import { runPrerender } from "./build/run-prerender.js"; import { loadDotenv } from "./config/dotenv.js"; import { loadNextConfig, resolveNextConfig } from "./config/next-config.js"; import { parsePositiveIntegerArg } from "./cli-args.js"; +import { + formatVinextPrerenderLabel, + loadVinextPrerenderConfigFromViteConfig, + resolveVinextPrerenderDecision, +} from "./config/prerender.js"; import { detectProject, getMissingDeps, type ProjectInfo } from "./cloudflare/project.js"; import { formatMissingCacheAdapterError, @@ -60,6 +65,8 @@ type DeployOptions = { tprWindow?: number; }; +type ProjectViteApi = Pick; + // ─── CLI arg parsing (uses Node.js util.parseArgs) ────────────────────────── /** Deploy command flag definitions for util.parseArgs. */ @@ -145,23 +152,25 @@ export async function withCloudflareEnv( } } -async function runBuild(info: ProjectInfo, env: string | undefined): Promise { - console.log("\n Building for Cloudflare Workers...\n"); - +async function loadProjectViteApi(root: string): Promise { // Resolve Vite from the project root so that symlinked vinext installs // (bun link / npm link) use the project's Vite, not the monorepo copy. // This mirrors the loadVite() pattern in cli.ts. let vitePath: string; try { - const req = createRequire(path.join(info.root, "package.json")); + const req = createRequire(path.join(root, "package.json")); vitePath = req.resolve("vite"); } catch { vitePath = "vite"; } const viteUrl = vitePath === "vite" ? vitePath : pathToFileURL(vitePath).href; - const { createBuilder } = (await import(/* @vite-ignore */ viteUrl)) as { - createBuilder: typeof import("vite").createBuilder; - }; + return (await import(/* @vite-ignore */ viteUrl)) as ProjectViteApi; +} + +async function runBuild(info: ProjectInfo, env: string | undefined): Promise { + console.log("\n Building for Cloudflare Workers...\n"); + + const { createBuilder } = await loadProjectViteApi(info.root); // Use Vite's JS API for the build. The Vite config prepared by `vinext init` // has the cloudflare() plugin which handles the Worker output format. @@ -362,26 +371,33 @@ export async function deploy(options: DeployOptions): Promise { } // Step 5: Build + const buildEnv = deployEnv === "production" && !options.env ? undefined : deployEnv; if (!options.skipBuild) { - await runBuild(info, deployEnv === "production" && !options.env ? undefined : deployEnv); + await runBuild(info, buildEnv); } else { console.log("\n Skipping build (--skip-build)"); } // Step 6a: prerender — render every discovered route into dist. - // Triggered by --prerender-all, or automatically when next.config.js - // sets `output: 'export'` (every route must be statically exportable). + // 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. { const rawNextConfig = await loadNextConfig(info.root); const nextConfig = await resolveNextConfig(rawNextConfig, info.root); - const isStaticExport = nextConfig.output === "export"; - - if (options.prerenderAll || isStaticExport) { - const label = - isStaticExport && !options.prerenderAll - ? "Pre-rendering all routes (output: 'export')..." - : "Pre-rendering all routes..."; - console.log(`\n ${label}`); + + const vite = await loadProjectViteApi(info.root); + const vinextPrerenderConfig = await withCloudflareEnv(buildEnv, () => + loadVinextPrerenderConfigFromViteConfig(vite, info.root), + ); + 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); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index cf7cff4e50..476d619a68 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -227,6 +227,11 @@ import { getViteMajorVersion, serializeViteDefine, } from "./utils/vite-version.js"; +import { + normalizeVinextPrerenderConfig, + VINEXT_PRERENDER_CONFIG_PLUGIN_PROPERTY, + type VinextPrerenderConfig, +} from "./config/prerender.js"; // Install the process-level peer-disconnect backstop at module load. // Vite plugin lifecycle hooks (config / configureServer) proved @@ -854,6 +859,26 @@ export type VinextOptions = { * @default false */ precompress?: boolean; + /** + * Pre-render routes after `vinext build` without passing + * `--prerender-all`. + * + * Use `true` as shorthand for `{ routes: "*" }`. The object form is + * available so future releases can support narrower route selections, but + * currently only `"*"` is supported. + * + * The `vinext build --prerender-all` and `vinext deploy --prerender-all` + * flags still work and take priority when present. + * + * @example + * vinext({ prerender: true }) + * + * @example + * vinext({ prerender: { routes: "*" } }) + * + * @default undefined + */ + prerender?: VinextPrerenderConfig; /** * Configure cache handlers declaratively, so you don't need a custom worker * entry that calls `setDataCacheHandler()` / `setCdnCacheAdapter()`. Each slot @@ -908,6 +933,7 @@ type NitroSetupContext = { }; export default function vinext(options: VinextOptions = {}): PluginOption[] { + const prerenderConfig = normalizeVinextPrerenderConfig(options.prerender); const viteMajorVersion = getViteMajorVersion(); let root: string; let pagesDir: string; @@ -1330,6 +1356,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { { name: "vinext:config", enforce: "pre", + ...({ [VINEXT_PRERENDER_CONFIG_PLUGIN_PROPERTY]: prerenderConfig } as Record< + string, + unknown + >), async config(config, env) { root = normalizePathSeparators(config.root ?? process.cwd()); diff --git a/tests/deploy-prerender-config.test.ts b/tests/deploy-prerender-config.test.ts new file mode 100644 index 0000000000..c3e8deca3f --- /dev/null +++ b/tests/deploy-prerender-config.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import fs from "node:fs"; +import path from "node:path"; + +const runPrerenderMock = vi.hoisted(() => vi.fn(async () => ({ routes: [] }))); + +vi.mock("../packages/vinext/src/build/run-prerender.js", () => ({ + runPrerender: runPrerenderMock, +})); + +vi.mock("../packages/vinext/src/cloudflare/project.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getMissingDeps: vi.fn(() => []), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: vi.fn(() => "Published app\n https://app.example.workers.dev\n"), + }; +}); + +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 writeProject(prerenderConfig: string): void { + writeFile("package.json", JSON.stringify({ name: "prerender-config-app", type: "module" })); + writeFile("app/page.tsx", "export default function Page() { return
home
; }\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({ prerender: ${prerenderConfig} }), cloudflare()],`, + "});", + "", + ].join("\n"), + ); +} + +describe("deploy prerender config wiring", () => { + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(process.cwd(), ".tmp-vinext-deploy-prerender-")); + runPrerenderMock.mockClear(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("runs prerender during deploy when vinext config uses the true shorthand", async () => { + writeProject("true"); + const { deploy } = await import("../packages/vinext/src/deploy.js"); + + await deploy({ root: tmpDir, skipBuild: true }); + + expect(runPrerenderMock).toHaveBeenCalledWith({ root: tmpDir, concurrency: undefined }); + }); + + it("runs prerender during deploy when vinext config uses routes star", async () => { + writeProject('{ routes: "*" }'); + const { deploy } = await import("../packages/vinext/src/deploy.js"); + + await deploy({ root: tmpDir, skipBuild: true }); + + expect(runPrerenderMock).toHaveBeenCalledWith({ root: tmpDir, concurrency: undefined }); + }); + + it("passes deploy prerender concurrency through config-triggered prerender", async () => { + writeProject('{ routes: "*" }'); + const { deploy } = await import("../packages/vinext/src/deploy.js"); + + await deploy({ root: tmpDir, skipBuild: true, prerenderConcurrency: 3 }); + + expect(runPrerenderMock).toHaveBeenCalledWith({ root: tmpDir, concurrency: 3 }); + }); +}); diff --git a/tests/prerender-config.test.ts b/tests/prerender-config.test.ts new file mode 100644 index 0000000000..055331bde9 --- /dev/null +++ b/tests/prerender-config.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vite-plus/test"; +import vinext from "../packages/vinext/src/index.js"; +import { + findVinextPrerenderConfigInPlugins, + formatVinextPrerenderLabel, + normalizeVinextPrerenderConfig, + resolveVinextPrerenderDecision, +} from "../packages/vinext/src/config/prerender.js"; + +describe("vinext prerender config", () => { + it("normalizes true to all routes", () => { + expect(normalizeVinextPrerenderConfig(true)).toEqual({ routes: "*" }); + }); + + it("accepts the explicit all-routes object form", () => { + expect(normalizeVinextPrerenderConfig({ routes: "*" })).toEqual({ routes: "*" }); + }); + + it("treats undefined as disabled", () => { + expect(normalizeVinextPrerenderConfig(undefined)).toBeNull(); + }); + + it("rejects false", () => { + expect(() => normalizeVinextPrerenderConfig(false as never)).toThrow( + 'Use `true` or `{ routes: "*" }`', + ); + }); + + it("rejects route selections other than star", () => { + expect(() => + normalizeVinextPrerenderConfig({ routes: ["/"] } as unknown as { routes: "*" }), + ).toThrow('Currently only `routes: "*"` is supported'); + }); + + it("exposes normalized config on the vinext plugin for build-time config loading", () => { + const plugins = vinext({ prerender: true }); + expect(findVinextPrerenderConfigInPlugins(plugins)).toEqual({ routes: "*" }); + }); + + it("exposes object-form config on the vinext plugin", () => { + const plugins = vinext({ prerender: { routes: "*" } }); + expect(findVinextPrerenderConfigInPlugins(plugins)).toEqual({ routes: "*" }); + }); + + it("chooses the CLI flag before config or static export", () => { + expect( + resolveVinextPrerenderDecision({ + prerenderAllFlag: true, + vinextPrerenderConfig: { routes: "*" }, + nextOutput: "export", + }), + ).toEqual({ routes: "*", reason: "flag" }); + }); + + it("uses next.config static export before vinext config", () => { + expect( + resolveVinextPrerenderDecision({ + vinextPrerenderConfig: { routes: "*" }, + nextOutput: "export", + }), + ).toEqual({ routes: "*", reason: "next-export" }); + }); + + it("uses vinext config when no higher-priority prerender trigger exists", () => { + expect( + resolveVinextPrerenderDecision({ + vinextPrerenderConfig: { routes: "*" }, + }), + ).toEqual({ routes: "*", reason: "vinext-config" }); + }); + + it("returns no decision when prerendering is not configured", () => { + expect(resolveVinextPrerenderDecision({ nextOutput: undefined })).toBeNull(); + }); + + it("formats labels for each prerender trigger", () => { + expect(formatVinextPrerenderLabel({ routes: "*", reason: "flag" })).toBe( + "Pre-rendering all routes...", + ); + expect(formatVinextPrerenderLabel({ routes: "*", reason: "next-export" })).toBe( + "Pre-rendering all routes (output: 'export')...", + ); + expect(formatVinextPrerenderLabel({ routes: "*", reason: "vinext-config" })).toBe( + "Pre-rendering all routes (vinext prerender config)...", + ); + }); +}); From 8826b5e36eb7efd7843e607a486b7d89f1cf792e Mon Sep 17 00:00:00 2001 From: James Date: Mon, 29 Jun 2026 18:23:42 +0100 Subject: [PATCH 2/5] feat(init): offer prerender config opt-in --- packages/vinext/src/cli.ts | 7 ++- packages/vinext/src/init-cloudflare.ts | 63 +++++++++++++++++-- packages/vinext/src/init-platform.ts | 56 +++++++++++++++++ packages/vinext/src/init.ts | 12 +++- playwright.config.ts | 2 +- tests/fixtures/ppr-impact-demo/README.md | 2 +- tests/fixtures/ppr-impact-demo/package.json | 2 +- tests/fixtures/ppr-impact-demo/vite.config.ts | 2 +- tests/init-cloudflare.test.ts | 48 ++++++++++++++ tests/init-platform.test.ts | 52 +++++++++++++++ tests/init.test.ts | 58 +++++++++++++++++ 11 files changed, 290 insertions(+), 14 deletions(-) diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 49e9e6a4f6..c1e2981046 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -31,7 +31,7 @@ import { import { deploy as runDeploy, parseDeployArgs } from "./deploy.js"; import { runCheck, formatReport } from "./check.js"; import { init as runInit, getReactUpgradeDeps } from "./init.js"; -import { INIT_PLATFORMS, resolveInitPlatform } from "./init-platform.js"; +import { INIT_PLATFORMS, resolveInitPlatform, resolveInitPrerender } from "./init-platform.js"; import { loadDotenv } from "./config/dotenv.js"; import { createRscCompatibilityId, @@ -861,6 +861,7 @@ async function initCommand() { const force = rawArgs.includes("--force"); const platform = await resolveInitPlatform(rawArgs); const platformOptions = await INIT_PLATFORMS[platform].options(rawArgs); + const prerender = await resolveInitPrerender(rawArgs); await runInit({ root: process.cwd(), @@ -868,6 +869,7 @@ async function initCommand() { skipCheck, force, platform, + prerender, cloudflare: platform === "cloudflare" ? platformOptions : undefined, }); } @@ -1011,6 +1013,8 @@ function printHelp(cmd?: string) { --skip-check Skip the compatibility check step --force Overwrite existing vite.config.ts --platform Deployment target: cloudflare or node + --prerender Configure vinext build to pre-render all static routes + (default: prompt, with No selected by default) --data-cache Cloudflare data cache: kv or none (default: kv) --image-optimization Cloudflare image optimization: cloudflare-images or none @@ -1023,6 +1027,7 @@ function printHelp(cmd?: string) { Configure the default Cloudflare cache handlers vinext init --platform=cloudflare --image-optimization=none Do not configure Cloudflare Images + vinext init --prerender Add prerender: { routes: "*" } to vite.config.ts 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/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index b059caf06c..6c8962b63c 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -25,6 +25,7 @@ export type CloudflarePlatformSetupContext = { root: string; isAppRouter: boolean; existingViteConfigPath?: string; + prerender?: boolean; today?: string; }; @@ -67,6 +68,7 @@ export function validateCloudflarePlatformSetup( nativeModulesToStub: projectInfo.nativeModulesToStub, cache: cloudflare, imagesBinding, + prerender: context.prerender, }, ); } @@ -95,6 +97,7 @@ export function setupCloudflarePlatform( nativeModulesToStub: projectInfo.nativeModulesToStub, cache: cloudflare, imagesBinding, + prerender: context.prerender, }, ); if (updatedConfig !== currentConfig) { @@ -105,8 +108,8 @@ export function setupCloudflarePlatform( } } else { const configContent = context.isAppRouter - ? generateAppRouterViteConfig(projectInfo, cloudflare, imagesBinding) - : generatePagesRouterViteConfig(projectInfo, cloudflare, imagesBinding); + ? generateAppRouterViteConfig(projectInfo, cloudflare, imagesBinding, context.prerender) + : generatePagesRouterViteConfig(projectInfo, cloudflare, imagesBinding, context.prerender); fs.writeFileSync(path.join(context.root, "vite.config.ts"), configContent, "utf-8"); generatedViteConfig = true; } @@ -637,6 +640,7 @@ function vinextExpression( binding = "vinext", imageBinding = "imagesOptimizer", imagesBinding = "IMAGES", + prerender = false, ): string { const cacheEntries: string[] = []; if (options.dataCache === "kv") { @@ -652,6 +656,9 @@ function vinextExpression( imagesBinding === "IMAGES" ? "" : `{ binding: ${JSON.stringify(imagesBinding)} }`; optionEntries.push(`images: { optimizer: ${imageBinding}(${adapterOptions}) }`); } + if (prerender) { + optionEntries.push(`prerender: { routes: "*" }`); + } return optionEntries.length === 0 ? `${binding}()` : `${binding}({\n ${optionEntries.join(",\n ")},\n})`; @@ -662,6 +669,7 @@ export function generateAppRouterViteConfig( info?: CloudflareProjectInfo, options: CloudflareInitOptions = DEFAULT_CLOUDFLARE_INIT_OPTIONS, imagesBinding = "IMAGES", + prerender = false, ): string { const imports: string[] = [ `import { defineConfig } from "vite";`, @@ -680,7 +688,7 @@ export function generateAppRouterViteConfig( plugins.push(` // vinext auto-injects @mdx-js/rollup with plugins from next.config`); } plugins.push( - ` ${vinextExpression(options, "vinext", "imagesOptimizer", imagesBinding).replace(/\n/g, "\n ")},`, + ` ${vinextExpression(options, "vinext", "imagesOptimizer", imagesBinding, prerender).replace(/\n/g, "\n ")},`, ); plugins.push(` cloudflare({ @@ -720,6 +728,7 @@ export function generatePagesRouterViteConfig( info?: CloudflareProjectInfo, options: CloudflareInitOptions = DEFAULT_CLOUDFLARE_INIT_OPTIONS, imagesBinding = "IMAGES", + prerender = false, ): string { const imports: string[] = [ `import { defineConfig } from "vite";`, @@ -751,7 +760,7 @@ export function generatePagesRouterViteConfig( export default defineConfig({ plugins: [ - ${vinextExpression(options, "vinext", "imagesOptimizer", imagesBinding).replace(/\n/g, "\n ")}, + ${vinextExpression(options, "vinext", "imagesOptimizer", imagesBinding, prerender).replace(/\n/g, "\n ")}, cloudflare(), ],${resolveBlock} }); @@ -1193,6 +1202,18 @@ function getVinextImageOptimizer( return findProperty(images.value as AstObject, "optimizer"); } +function hasVinextPrerender(call: (ESTree.CallExpression & AstNode) | undefined): boolean { + const firstArgument = call?.arguments[0]; + if ( + !firstArgument || + firstArgument.type === "SpreadElement" || + firstArgument.type !== "ObjectExpression" + ) { + return false; + } + return Boolean(findProperty(firstArgument as AstObject, "prerender")); +} + function isUsableImageOptimizer(property: AstProperty | undefined): boolean { if (!property) return false; const value = property.value as AstNode & { name?: string; value?: unknown }; @@ -1310,6 +1331,29 @@ function ensureVinextImageOptimizer( } } +function ensureVinextPrerender( + output: MagicString, + config: AstObject, + vinextBinding: string, + prerender: boolean | undefined, + code: string, +): void { + if (!prerender) return; + const call = findPluginCall(config, vinextBinding); + if (!call || hasVinextPrerender(call)) return; + if (call.arguments.length === 0) { + output.appendLeft(call.end - 1, `{ prerender: { routes: "*" } }`); + return; + } + const firstArgument = call.arguments[0]; + if (firstArgument.type === "SpreadElement" || firstArgument.type !== "ObjectExpression") { + throw new Error( + "The vinext() plugin options must be a static object for vinext init to add prerender config.", + ); + } + insertObjectProperty(output, firstArgument as AstObject, ` prerender: { routes: "*" },`, code); +} + function indentBlock(source: string, indent: string): string { return source .split("\n") @@ -1461,6 +1505,7 @@ export function updateViteConfigForCloudflare( nativeModulesToStub: string[]; cache?: CloudflareInitOptions; imagesBinding?: string; + prerender?: boolean; }, ): string { const program = parseViteConfig(filePath, code); @@ -1497,6 +1542,7 @@ export function updateViteConfigForCloudflare( : ensureDefaultImport(program, output, "vinext", vinextLocal); const existingVinextCall = findPluginCall(config, vinextBinding); const existingImageOptimizer = getVinextImageOptimizer(existingVinextCall); + const needsPrerender = Boolean(options.prerender && !hasVinextPrerender(existingVinextCall)); const configureCaches = options.cache !== undefined; const cacheAdditions: Array<{ name: "data" | "cdn"; expression: string }> = []; if (cacheOptions.dataCache === "kv" && !hasVinextCacheSlot(existingVinextCall, "data")) { @@ -1573,13 +1619,14 @@ export function updateViteConfigForCloudflare( { expression: existingVinextCall ? `${vinextBinding}()` - : options.cache + : options.cache || options.prerender ? vinextExpression( cacheOptions, vinextBinding, imageOptimizerExpression?.slice(0, imageOptimizerExpression.indexOf("(")) || "imagesOptimizer", options.imagesBinding, + options.prerender, ) : `${vinextBinding}()`, binding: vinextBinding, @@ -1594,7 +1641,7 @@ export function updateViteConfigForCloudflare( if (existingVinextCall) { if ( existingVinextCall.arguments.length === 0 && - (cacheAdditions.length > 0 || imageOptimizerExpression) + (cacheAdditions.length > 0 || imageOptimizerExpression || needsPrerender) ) { const properties: string[] = []; if (cacheAdditions.length > 0) { @@ -1605,6 +1652,9 @@ export function updateViteConfigForCloudflare( if (imageOptimizerExpression) { properties.push(`images: { optimizer: ${imageOptimizerExpression} }`); } + if (needsPrerender) { + properties.push(`prerender: { routes: "*" }`); + } const plugins = findProperty(config, "plugins"); const propertyIndent = plugins ? (code @@ -1622,6 +1672,7 @@ export function updateViteConfigForCloudflare( } else { ensureVinextCache(output, config, vinextBinding, cacheAdditions, code); ensureVinextImageOptimizer(output, config, vinextBinding, imageOptimizerExpression, code); + ensureVinextPrerender(output, config, vinextBinding, options.prerender, code); } } diff --git a/packages/vinext/src/init-platform.ts b/packages/vinext/src/init-platform.ts index bc2628a8d4..df55a5f255 100644 --- a/packages/vinext/src/init-platform.ts +++ b/packages/vinext/src/init-platform.ts @@ -105,6 +105,21 @@ export function parseImageOptimizationArg(args: string[]): InitImageOptimization return parseChoiceArg(args, "--image-optimization", ["cloudflare-images", "none"]); } +export function parsePrerenderArg(args: string[]): boolean | undefined { + for (const arg of args) { + if (arg === "--prerender") return true; + if (arg === "--no-prerender") return false; + if (!arg.startsWith("--prerender=")) continue; + + const value = arg.slice("--prerender=".length).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.'); + } + + return undefined; +} + export async function resolveInitPlatform( args: string[], options: PlatformPromptOptions = {}, @@ -156,6 +171,47 @@ export async function resolveInitPlatform( } } +export async function resolveInitPrerender( + args: string[], + options: PlatformPromptOptions = {}, +): Promise { + const explicitPrerender = parsePrerenderArg(args); + if (explicitPrerender !== undefined) return explicitPrerender; + + 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 false; + + 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-render all static routes after build? [y/N]: ")) + .trim() + .toLowerCase(); + if (answer === "") { + output.write("\n"); + return false; + } + 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 f79b865cf3..f5e02f1131 100644 --- a/packages/vinext/src/init.ts +++ b/packages/vinext/src/init.ts @@ -97,6 +97,8 @@ export type InitOptions = { force?: boolean; /** Deployment target selected by the user */ platform?: InitPlatform; + /** Configure build-time pre-rendering for all discovered static routes */ + prerender?: boolean; /** Cloudflare cache and image choices. */ cloudflare?: CloudflareInitOptions; /** @internal — override exec for testing (avoids ESM spy issues) */ @@ -133,12 +135,13 @@ type InitResult = { // ─── Vite Config Generation (minimal, non-Cloudflare) ──────────────────────── -export function generateViteConfig(_isAppRouter: boolean): string { +export function generateViteConfig(_isAppRouter: boolean, prerender = false): string { + const vinextCall = prerender ? `vinext({ prerender: { routes: "*" } })` : "vinext()"; return `import vinext from "vinext"; import { defineConfig } from "vite"; export default defineConfig({ - plugins: [vinext()], + plugins: [${vinextCall}], }); `; } @@ -311,6 +314,7 @@ type PlatformSetupContext = { existingViteConfigPath?: string; viteConfigExists: boolean; force: boolean; + prerender?: boolean; today?: string; }; @@ -333,7 +337,7 @@ function setupNodePlatform(context: PlatformSetupContext): PlatformSetupResult { fs.writeFileSync( context.existingViteConfigPath ?? path.join(context.root, "vite.config.ts"), - generateViteConfig(context.isAppRouter), + generateViteConfig(context.isAppRouter, context.prerender), "utf-8", ); return { @@ -422,6 +426,7 @@ export async function init(options: InitOptions): Promise { root, isAppRouter: isApp, existingViteConfigPath, + prerender: options.prerender, today: options._today, }, options.cloudflare!, @@ -466,6 +471,7 @@ export async function init(options: InitOptions): Promise { existingViteConfigPath, viteConfigExists, force: options.force ?? false, + prerender: options.prerender, today: options._today, }; const platformSetup = diff --git a/playwright.config.ts b/playwright.config.ts index 6bbfd07605..0f81e294a9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -259,7 +259,7 @@ const projectServers = { use: { baseURL: "http://localhost:4187" }, server: { command: - "npx vp run vinext#build && node ../../../packages/vinext/dist/cli.js build --prerender-all && node ../../../packages/vinext/dist/cli.js start --port 4187", + "npx vp run vinext#build && node ../../../packages/vinext/dist/cli.js build && node ../../../packages/vinext/dist/cli.js start --port 4187", cwd: "./tests/fixtures/ppr-impact-demo", port: 4187, reuseExistingServer: !process.env.CI, diff --git a/tests/fixtures/ppr-impact-demo/README.md b/tests/fixtures/ppr-impact-demo/README.md index 72a598552d..f4b33ccf81 100644 --- a/tests/fixtures/ppr-impact-demo/README.md +++ b/tests/fixtures/ppr-impact-demo/README.md @@ -6,7 +6,7 @@ disabled by default and unknown blog slugs use a normal full render. ```bash vp run vinext#build -node ../../../packages/vinext/dist/cli.js build --prerender-all +node ../../../packages/vinext/dist/cli.js build node ../../../packages/vinext/dist/cli.js start --port 4187 ``` diff --git a/tests/fixtures/ppr-impact-demo/package.json b/tests/fixtures/ppr-impact-demo/package.json index e3d66cfb17..2a68b2029d 100644 --- a/tests/fixtures/ppr-impact-demo/package.json +++ b/tests/fixtures/ppr-impact-demo/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "build": "vinext build --prerender-all", + "build": "vinext build", "start": "vinext start --port 4187", "wrangler": "wrangler dev --config dist/server/wrangler.json --port 4187" }, diff --git a/tests/fixtures/ppr-impact-demo/vite.config.ts b/tests/fixtures/ppr-impact-demo/vite.config.ts index 27a94ee9a8..bef6669b21 100644 --- a/tests/fixtures/ppr-impact-demo/vite.config.ts +++ b/tests/fixtures/ppr-impact-demo/vite.config.ts @@ -4,7 +4,7 @@ import vinext from "vinext"; export default defineConfig({ plugins: [ - vinext(), + vinext({ prerender: { routes: "*" } }), cloudflare({ viteEnvironment: { name: "rsc", diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index f061ce4d75..e0ae2611a2 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { parseSync } from "vite"; import { generateAppRouterViteConfig, + generatePagesRouterViteConfig, generatePagesRouterWorkerEntry, getWranglerImagesBinding, updateViteConfigForCloudflare, @@ -259,6 +260,39 @@ export default { plugins: [vinext()] }; ); }); + it("adds prerender to an existing vinext options object", () => { + const output = updateViteConfigForCloudflare( + "vite.config.ts", + `import vinext from "vinext"; +export default { plugins: [vinext({ cache: { data: customData() } })] }; +`, + { + isAppRouter: false, + nativeModulesToStub: [], + cache: { dataCache: "none", cdnCache: "data-cache", imageOptimization: "none" }, + prerender: true, + }, + ); + expectValidConfig(output); + expect(output).toContain("cache: { data: customData() }"); + expect(output).toContain('prerender: { routes: "*" }'); + }); + + it("preserves an existing prerender option", () => { + const input = `import vinext from "vinext"; +export default { plugins: [vinext({ prerender: true })] }; +`; + const output = updateViteConfigForCloudflare("vite.config.ts", input, { + isAppRouter: false, + nativeModulesToStub: [], + cache: { dataCache: "none", cdnCache: "data-cache", imageOptimization: "none" }, + prerender: true, + }); + expectValidConfig(output); + expect(output.match(/prerender/g)).toHaveLength(1); + expect(output).toContain("prerender: true"); + }); + it.each(["undefined", "null"])("replaces an unusable %s image optimizer", (value) => { const output = updateViteConfigForCloudflare( "vite.config.ts", @@ -481,6 +515,20 @@ export default { plugins: [vinext({ imageOptimization: true })] }; expect(vite).toContain('imagesOptimizer({ binding: "CUSTOM_IMAGES" })'); }); + it("generates Cloudflare Vite config with prerender when opted in", () => { + const options = { + dataCache: "none" as const, + cdnCache: "data-cache" as const, + imageOptimization: "none" as const, + }; + expect(generateAppRouterViteConfig(undefined, options, "IMAGES", true)).toContain( + 'prerender: { routes: "*" }', + ); + expect(generatePagesRouterViteConfig(undefined, options, "IMAGES", true)).toContain( + 'prerender: { routes: "*" }', + ); + }); + it("repairs an unusable Wrangler Images binding", () => { const output = updateWranglerConfigForCloudflare(`{ "images": null }\n`, { dataCache: "none", diff --git a/tests/init-platform.test.ts b/tests/init-platform.test.ts index 111c994e51..edcb80c5f8 100644 --- a/tests/init-platform.test.ts +++ b/tests/init-platform.test.ts @@ -6,8 +6,10 @@ import { parseDataCacheArg, parseCdnCacheArg, parseImageOptimizationArg, + parsePrerenderArg, resolveCloudflareInitOptions, resolveInitPlatform, + resolveInitPrerender, } from "../packages/vinext/src/init-platform.js"; describe("parsePlatformArg", () => { @@ -127,6 +129,56 @@ describe("Cloudflare init choices", () => { }); }); +describe("prerender init choice", () => { + it("parses explicit prerender flags", () => { + expect(parsePrerenderArg(["--prerender"])).toBe(true); + expect(parsePrerenderArg(["--no-prerender"])).toBe(false); + expect(parsePrerenderArg(["--prerender=true"])).toBe(true); + expect(parsePrerenderArg(["--prerender=false"])).toBe(false); + }); + + it("rejects unsupported explicit values", () => { + expect(() => parsePrerenderArg(["--prerender=maybe"])).toThrow( + "--prerender expects true or false", + ); + }); + + it("defaults non-interactive and agent environments to disabled", async () => { + await expect(resolveInitPrerender([], { env: {}, isInteractive: false })).resolves.toBe(false); + await expect( + resolveInitPrerender([], { env: { CODEX_THREAD_ID: "test" }, isInteractive: true }), + ).resolves.toBe(false); + }); + + it("defaults the interactive prompt to No", async () => { + const prompts: string[] = []; + const output = new PassThrough(); + await expect( + resolveInitPrerender([], { + env: {}, + isInteractive: true, + output, + question: async (prompt) => { + prompts.push(prompt); + return ""; + }, + }), + ).resolves.toBe(false); + expect(prompts).toEqual([" Pre-render all static routes after build? [y/N]: "]); + expect(output.read()?.toString()).toBe("\n"); + }); + + it("accepts Yes from the interactive prompt", async () => { + await expect( + resolveInitPrerender([], { + env: {}, + isInteractive: true, + question: async () => "yes", + }), + ).resolves.toBe(true); + }); +}); + describe("isAgentEnvironment", () => { it("detects agents supported by am-i-vibing", () => { expect(isAgentEnvironment({ CODEX_THREAD_ID: "test" })).toBe(true); diff --git a/tests/init.test.ts b/tests/init.test.ts index e34c1fb2a8..f4a0b47b88 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -254,6 +254,11 @@ describe("generateViteConfig", () => { expect(generateViteConfig(true)).toContain("defineConfig"); expect(generateViteConfig(false)).toContain("defineConfig"); }); + + it("can configure prerender for all routes", () => { + const config = generateViteConfig(true, true); + expect(config).toContain('vinext({ prerender: { routes: "*" } })'); + }); }); // ─── Unit Tests: addScripts ────────────────────────────────────────────────── @@ -487,6 +492,33 @@ describe("init — basic functionality", () => { }); }); + it("does not configure prerender unless opted in", async () => { + setupProject(tmpDir, { router: "app" }); + + await runInit(tmpDir); + + expect(readFile(tmpDir, "vite.config.ts")).not.toContain("prerender:"); + }); + + it("generates Node vite.config.ts with prerender when opted in", async () => { + setupProject(tmpDir, { router: "pages" }); + + await runInit(tmpDir, { platform: "node", prerender: true }); + + expect(readFile(tmpDir, "vite.config.ts")).toContain('vinext({ prerender: { routes: "*" } })'); + }); + + it("generates Cloudflare vite.config.ts with prerender when opted in", async () => { + setupProject(tmpDir, { router: "app" }); + + await runInit(tmpDir, { prerender: true }); + + const config = readFile(tmpDir, "vite.config.ts"); + expect(config).toContain('prerender: { routes: "*" }'); + expect(config).toContain("data: kvDataAdapter()"); + expect(config).toContain("images: { optimizer: imagesOptimizer() }"); + }); + it("prints explicit steps to finish Cloudflare KV setup", async () => { setupProject(tmpDir, { router: "app" }); @@ -655,6 +687,32 @@ export default { plugins: [vinext({ cache: { data: customData() } })] }; expect(readFile(tmpDir, "worker/index.ts")).toBe("export default { fetch() {} };\n"); }); + it("additively fills missing prerender config on rerun", async () => { + setupProject(tmpDir, { router: "app" }); + writeFile( + tmpDir, + "vite.config.ts", + `import vinext from "vinext"; + +export default { plugins: [vinext({ cache: { data: customData() } })] }; +`, + ); + + await runInit(tmpDir, { + platform: "cloudflare", + prerender: true, + cloudflare: { + dataCache: "none", + cdnCache: "data-cache", + imageOptimization: "none", + }, + }); + + const config = readFile(tmpDir, "vite.config.ts"); + expect(config).toContain("cache: { data: customData() }"); + expect(config).toContain('prerender: { routes: "*" }'); + }); + it("rejects Wrangler TOML", async () => { setupProject(tmpDir, { router: "app" }); writeFile( From ff8714ba95f02e1d08cfdd7dcffe20971911d60f Mon Sep 17 00:00:00 2001 From: James Date: Mon, 29 Jun 2026 18:44:59 +0100 Subject: [PATCH 3/5] fix(cloudflare): export prerender deploy helper --- packages/vinext/package.json | 4 ++++ tests/deploy.test.ts | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/vinext/package.json b/packages/vinext/package.json index a742bdbc2b..3084b0d63b 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -93,6 +93,10 @@ "types": "./dist/config/next-config.d.ts", "import": "./dist/config/next-config.js" }, + "./internal/config/prerender": { + "types": "./dist/config/prerender.d.ts", + "import": "./dist/config/prerender.js" + }, "./server/app-rsc-handler": { "types": "./dist/server/app-rsc-handler.d.ts", "import": "./dist/server/app-rsc-handler.js" diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 548794b955..79211b9d8f 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -1089,6 +1089,15 @@ describe("readPagesRouterEntrySource", () => { expect(hasPackageExport(exportsMap, "./server/pages-router-entry")).toBe(true); }); + 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/config/dotenv")).toBe(true); + expect(hasPackageExport(exportsMap, "./internal/config/next-config")).toBe(true); + expect(hasPackageExport(exportsMap, "./internal/config/prerender")).toBe(true); + expect(hasPackageExport(exportsMap, "./internal/utils/project")).toBe(true); + }); + it("merges middleware and config headers into responses with correct precedence", () => { const content = readPagesRouterEntrySource(); // mergeHeaders is now called inside runPagesRequest. From 3f7dc728c6a7d6c21c3c0991fff8ed9b1ed5767a Mon Sep 17 00:00:00 2001 From: James Date: Mon, 29 Jun 2026 18:59:52 +0100 Subject: [PATCH 4/5] fix(cloudflare): avoid redundant prerender config load --- packages/cloudflare/src/deploy.ts | 11 ++++-- packages/vinext/src/config/prerender.ts | 2 + packages/vinext/src/index.ts | 2 + tests/deploy-prerender-config.test.ts | 50 +++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 63a201da44..3e92c291a2 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -402,10 +402,13 @@ export async function deploy(options: DeployOptions): Promise { const rawNextConfig = await loadNextConfig(info.root); const nextConfig = await resolveNextConfig(rawNextConfig, info.root); - const vite = await loadProjectViteApi(info.root); - const vinextPrerenderConfig = await withCloudflareEnv(buildEnv, () => - loadVinextPrerenderConfigFromViteConfig(vite, 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, diff --git a/packages/vinext/src/config/prerender.ts b/packages/vinext/src/config/prerender.ts index b8d0148d11..b98c291aac 100644 --- a/packages/vinext/src/config/prerender.ts +++ b/packages/vinext/src/config/prerender.ts @@ -23,6 +23,8 @@ export type VinextPrerenderDecision = ResolvedVinextPrerenderConfig & { reason: VinextPrerenderDecisionReason; }; +// 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"; type VinextPrerenderConfigPlugin = { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 633f139bcb..ea01844fbb 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1360,6 +1360,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { { name: "vinext:config", enforce: "pre", + // Expose normalized prerender config to build/deploy metadata loaders that + // inspect the Vite plugin array after a fresh config load. ...({ [VINEXT_PRERENDER_CONFIG_PLUGIN_PROPERTY]: prerenderConfig } as Record< string, unknown diff --git a/tests/deploy-prerender-config.test.ts b/tests/deploy-prerender-config.test.ts index 8e3d208149..9e2fa32632 100644 --- a/tests/deploy-prerender-config.test.ts +++ b/tests/deploy-prerender-config.test.ts @@ -62,6 +62,37 @@ function writeProject(prerenderConfig: string): void { ); } +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"); + 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("throws-on-load.js", 'throw new Error("vite config loaded unexpectedly");\n'); + writeFile( + "vite.config.ts", + [ + 'import "./throws-on-load.js";', + 'import { cloudflare } from "@cloudflare/vite-plugin";', + 'import vinext from "../packages/vinext/src/index";', + "", + "export default {", + " plugins: [vinext(), cloudflare({ viteEnvironment: { name: 'rsc', childEnvironments: ['ssr'] } })],", + "};", + "", + ].join("\n"), + ); +} + describe("deploy prerender config wiring", () => { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(process.cwd(), ".tmp-vinext-deploy-prerender-")); @@ -98,4 +129,23 @@ describe("deploy prerender config wiring", () => { expect(runPrerenderMock).toHaveBeenCalledWith({ root: tmpDir, concurrency: 3 }); }); + + it("does not load Vite config when the prerender-all flag already wins", async () => { + writeProjectWithThrowingViteConfig(); + const { deploy } = await import("../packages/cloudflare/src/deploy.js"); + + await deploy({ root: tmpDir, skipBuild: true, prerenderAll: true }); + + expect(runPrerenderMock).toHaveBeenCalledWith({ root: tmpDir, concurrency: undefined }); + }); + + it("does not load Vite config when static export already wins", async () => { + writeProjectWithThrowingViteConfig(); + writeFile("next.config.mjs", 'export default { output: "export" };\n'); + const { deploy } = await import("../packages/cloudflare/src/deploy.js"); + + await deploy({ root: tmpDir, skipBuild: true }); + + expect(runPrerenderMock).toHaveBeenCalledWith({ root: tmpDir, concurrency: undefined }); + }); }); From 3a81d0b601f9bc0bf03a77ab428aa8c0d4e4829c Mon Sep 17 00:00:00 2001 From: James Date: Mon, 29 Jun 2026 19:03:05 +0100 Subject: [PATCH 5/5] fix(build): quiet adapter fallback warnings --- .../vinext/src/cache/cache-adapters-virtual.ts | 13 +++++++++++-- .../vinext/src/image/image-adapters-virtual.ts | 11 ++++++++++- tests/cache-adapters-config.test.ts | 17 +++++++++++++++++ tests/image-adapters-config.test.ts | 12 ++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/cache/cache-adapters-virtual.ts b/packages/vinext/src/cache/cache-adapters-virtual.ts index 28f83c4e44..fd57e853dd 100644 --- a/packages/vinext/src/cache/cache-adapters-virtual.ts +++ b/packages/vinext/src/cache/cache-adapters-virtual.ts @@ -98,6 +98,15 @@ export function generateCacheAdaptersModule(cache?: VinextCacheConfig): string { "", "// A factory that throws (e.g. a missing binding on an incompatible runtime)", "// is logged and skipped so the default handler stays in place.", + "function __vinextFormatAdapterError(error) {", + " if (error instanceof Error && error.message) return error.message;", + " try {", + " return String(error);", + " } catch {", + " return '';", + " }", + "}", + "", "let __vinextCacheAdaptersRegistered = false;", "", "export function registerConfiguredCacheAdapters(env) {", @@ -113,7 +122,7 @@ export function generateCacheAdaptersModule(cache?: VinextCacheConfig): string { )} }));`, " } catch (error) {", ' console.warn("[vinext] failed to initialize the configured data cache adapter; ' + - 'using the default handler.", error);', + 'using the default handler.\\n" + __vinextFormatAdapterError(error));', " }", ); } @@ -126,7 +135,7 @@ export function generateCacheAdaptersModule(cache?: VinextCacheConfig): string { )} }));`, " } catch (error) {", ' console.warn("[vinext] failed to initialize the configured CDN cache adapter; ' + - 'using the default adapter.", error);', + 'using the default adapter.\\n" + __vinextFormatAdapterError(error));', " }", ); } diff --git a/packages/vinext/src/image/image-adapters-virtual.ts b/packages/vinext/src/image/image-adapters-virtual.ts index 685a826a86..5d6657d419 100644 --- a/packages/vinext/src/image/image-adapters-virtual.ts +++ b/packages/vinext/src/image/image-adapters-virtual.ts @@ -95,6 +95,15 @@ export function generateImageAdaptersModule(images?: VinextImageConfig): string "", "// A factory that throws (e.g. a missing binding on an incompatible runtime)", "// is logged and skipped so images fall back to unoptimized passthrough.", + "function __vinextFormatAdapterError(error) {", + " if (error instanceof Error && error.message) return error.message;", + " try {", + " return String(error);", + " } catch {", + " return '';", + " }", + "}", + "", "let __vinextImageOptimizerRegistered = false;", "", "export function registerConfiguredImageOptimizer(env) {", @@ -107,7 +116,7 @@ export function generateImageAdaptersModule(images?: VinextImageConfig): string )} }));`, " } catch (error) {", ' console.warn("[vinext] failed to initialize the configured image optimizer; ' + - 'serving images unoptimized.", error);', + 'serving images unoptimized.\\n" + __vinextFormatAdapterError(error));', " }", "}", "", diff --git a/tests/cache-adapters-config.test.ts b/tests/cache-adapters-config.test.ts index 3557a46c00..3c8387620d 100644 --- a/tests/cache-adapters-config.test.ts +++ b/tests/cache-adapters-config.test.ts @@ -89,6 +89,23 @@ describe("generateCacheAdaptersModule", () => { expect(code).toContain("__vinextCacheAdaptersRegistered = true;"); }); + it("logs registration failures without printing raw Error stack traces", () => { + const code = generateCacheAdaptersModule({ + cdn: { adapter: "@vinext/cloudflare/cache/cdn-adapter" }, + data: { adapter: "@vinext/cloudflare/cache/kv-data-adapter" }, + }); + expect(code).toContain("function __vinextFormatAdapterError(error)"); + expect(code).toContain( + 'console.warn("[vinext] failed to initialize the configured data cache adapter; ' + + 'using the default handler.\\n" + __vinextFormatAdapterError(error));', + ); + expect(code).toContain( + 'console.warn("[vinext] failed to initialize the configured CDN cache adapter; ' + + 'using the default adapter.\\n" + __vinextFormatAdapterError(error));', + ); + expect(code).not.toContain('", error);'); + }); + it("escapes adapter specifiers so absolute paths are safe", () => { // require.resolve() yields an absolute path which may contain characters // that must not break the generated import statement. diff --git a/tests/image-adapters-config.test.ts b/tests/image-adapters-config.test.ts index 60c0cc28bb..7bf5010c97 100644 --- a/tests/image-adapters-config.test.ts +++ b/tests/image-adapters-config.test.ts @@ -72,6 +72,18 @@ describe("generateImageAdaptersModule", () => { expect(code).toContain("__vinextImageOptimizerRegistered = true;"); }); + it("logs registration failures without printing raw Error stack traces", () => { + const code = generateImageAdaptersModule({ + optimizer: { adapter: "@vinext/cloudflare/images/images-optimizer" }, + }); + expect(code).toContain("function __vinextFormatAdapterError(error)"); + expect(code).toContain( + 'console.warn("[vinext] failed to initialize the configured image optimizer; ' + + 'serving images unoptimized.\\n" + __vinextFormatAdapterError(error));', + ); + expect(code).not.toContain('", error);'); + }); + it("escapes adapter specifiers so absolute paths are safe", () => { const weird = `/tmp/some path/with"quote/adapter.js`; const code = generateImageAdaptersModule({ optimizer: { adapter: weird } });