diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 827e7e5e..910c3da0 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -119,6 +119,8 @@ type ProjectInfo = { projectName: string; /** Pages that use `revalidate` (ISR) */ hasISR: boolean; + /** next.config.js sets output: 'export' (full static export) */ + isStaticExport: boolean; /** package.json has "type": "module" */ hasTypeModule: boolean; /** .mdx files detected in app/ or pages/ */ @@ -222,6 +224,9 @@ export function detectProject(root: string): ProjectInfo { // Detect ISR usage (rough heuristic: search for `revalidate` exports) const hasISR = detectISR(root, isAppRouter); + // Detect output: 'export' in next.config + const isStaticExport = detectStaticExport(root); + // Detect "type": "module" in package.json const hasTypeModule = pkg?.type === "module"; @@ -250,6 +255,7 @@ export function detectProject(root: string): ProjectInfo { hasWrangler, projectName, hasISR, + isStaticExport, hasTypeModule, hasMDX, hasCodeHike, @@ -257,6 +263,34 @@ export function detectProject(root: string): ProjectInfo { }; } +/** + * Heuristic: does the next.config file set `output: 'export'`? + * + * Limitations (consistent with detectISR): variable indirection like + * `const o = "export"; { output: o }` is not detected. Template-literal + * syntax (`output: \`export\``) is also not detected (uncommon in configs). + */ +export function detectStaticExport(root: string): boolean { + const configNames = ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.cjs"]; + for (const name of configNames) { + const configPath = path.join(root, name); + if (fs.existsSync(configPath)) { + try { + const content = fs.readFileSync(configPath, "utf-8"); + // Strip comments to avoid matching commented-out config. + const stripped = content + .replace(/\/\/.*$/gm, "") // single-line comments + .replace(/\/\*[\s\S]*?\*\//g, ""); // block comments + // Match output: "export" or output: 'export' (enforcing matching quotes). + return /output\s*:\s*(?:"export"|'export')/.test(stripped); + } catch { + return false; + } + } + } + return false; +} + function detectISR(root: string, isAppRouter: boolean): boolean { // ISR detection is only implemented for App Router (scans for `export const revalidate`). // Pages Router ISR (getStaticProps + revalidate) is not detected here — wrangler.jsonc @@ -396,8 +430,18 @@ export function generateWranglerConfig(info: ProjectInfo): string { name: info.projectName, compatibility_date: today, compatibility_flags: ["nodejs_compat"], - main: "./worker/index.ts", - assets: { + }; + + if (info.isStaticExport) { + // Static export: serve pre-rendered files directly — no worker needed. + // Cloudflare's built-in asset serving handles everything. + config.assets = { + directory: "dist/client", + not_found_handling: "404-page", + }; + } else { + config.main = "./worker/index.ts"; + config.assets = { // Wrangler 4.69+ requires `directory` when `assets` is an object. // The @cloudflare/vite-plugin always writes static assets to dist/client/. directory: "dist/client", @@ -405,22 +449,22 @@ export function generateWranglerConfig(info: ProjectInfo): string { // Expose static assets to the Worker via env.ASSETS so the image // optimization handler can fetch source images programmatically. binding: "ASSETS", - }, + }; // Cloudflare Images binding for next/image optimization. // Enables resize, format negotiation (AVIF/WebP), and quality transforms // at the edge. No user setup needed — wrangler creates the binding automatically. - images: { + config.images = { binding: "IMAGES", - }, - }; + }; - if (info.hasISR) { - config.kv_namespaces = [ - { - binding: "VINEXT_CACHE", - id: "", - }, - ]; + if (info.hasISR) { + config.kv_namespaces = [ + { + binding: "VINEXT_CACHE", + id: "", + }, + ]; + } } return JSON.stringify(config, null, 2) + "\n"; @@ -1112,7 +1156,7 @@ export function getFilesToGenerate(info: ProjectInfo): GeneratedFile[] { }); } - if (!info.hasWorkerEntry) { + if (!info.hasWorkerEntry && !info.isStaticExport) { const workerContent = info.isAppRouter ? generateAppRouterWorkerEntry(info.hasISR) : generatePagesRouterWorkerEntry(); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 93d2ed60..cb48bb28 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1808,7 +1808,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { !hasCloudflarePlugin && !hasNitroPlugin && hasWranglerConfig(root) && - !options.disableAppRouter + !options.disableAppRouter && + nextConfig.output !== "export" ) { throw new Error( formatMissingCloudflarePluginError({ diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index cd19afd2..11957e63 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -19,6 +19,7 @@ import { viteConfigHasCloudflarePlugin, hasWranglerConfig, formatMissingCloudflarePluginError, + detectStaticExport, } from "../packages/vinext/src/deploy.js"; import { detectPackageManager, @@ -288,6 +289,127 @@ describe("detectProject", () => { const info = detectProject(tmpDir); expect(info.hasISR).toBe(false); }); + + it("detects output: 'export' in next.config.mjs", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`); + const info = detectProject(tmpDir); + expect(info.isStaticExport).toBe(true); + }); + + it('detects output:"export" without spaces', () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.mjs", `export default {output:"export"};`); + const info = detectProject(tmpDir); + expect(info.isStaticExport).toBe(true); + }); + + it("isStaticExport is false when no export config", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.mjs", `export default {};`); + const info = detectProject(tmpDir); + expect(info.isStaticExport).toBe(false); + }); + + it("detects output: 'export' in next.config.ts", () => { + mkdir(tmpDir, "app"); + writeFile( + tmpDir, + "next.config.ts", + `const config = { output: 'export' };\nexport default config;`, + ); + const info = detectProject(tmpDir); + expect(info.isStaticExport).toBe(true); + }); + + it("detects output: 'export' in next.config.cjs", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.cjs", `module.exports = { output: "export" };`); + const info = detectProject(tmpDir); + expect(info.isStaticExport).toBe(true); + }); + + it("does not detect commented-out output: 'export'", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.mjs", `// output: "export"\nexport default {};`); + const info = detectProject(tmpDir); + expect(info.isStaticExport).toBe(false); + }); + + it("does not detect output: 'standalone' as static export", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.mjs", `export default { output: "standalone" };`); + const info = detectProject(tmpDir); + expect(info.isStaticExport).toBe(false); + }); + + it("isStaticExport is false when no next.config exists", () => { + mkdir(tmpDir, "app"); + const info = detectProject(tmpDir); + expect(info.isStaticExport).toBe(false); + }); + + it("handles empty next.config.js gracefully", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.js", ""); + const info = detectProject(tmpDir); + expect(info.isStaticExport).toBe(false); + }); + + it("prefers next.config.ts over next.config.mjs for static export detection", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.ts", `export default {};`); + writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`); + const info = detectProject(tmpDir); + // .ts is checked first and has no output: "export", so result is false + expect(info.isStaticExport).toBe(false); + }); + + it("does not detect output: 'export' inside block comments", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.mjs", `/* output: "export" */\nexport default {};`); + const info = detectProject(tmpDir); + expect(info.isStaticExport).toBe(false); + }); + + it("does not detect output: 'export' inside multiline block comments", () => { + mkdir(tmpDir, "app"); + writeFile( + tmpDir, + "next.config.mjs", + "/*\n * Previously: output: 'export'\n */\nexport default {};", + ); + const info = detectProject(tmpDir); + expect(info.isStaticExport).toBe(false); + }); +}); + +// ─── detectStaticExport (direct) ──────────────────────────────────────────── + +describe("detectStaticExport", () => { + it('returns true for output: "export"', () => { + writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`); + expect(detectStaticExport(tmpDir)).toBe(true); + }); + + it("returns false when no next.config exists", () => { + expect(detectStaticExport(tmpDir)).toBe(false); + }); + + it('returns false for output: "standalone"', () => { + writeFile(tmpDir, "next.config.mjs", `export default { output: "standalone" };`); + expect(detectStaticExport(tmpDir)).toBe(false); + }); + + it("strips block comments before matching", () => { + writeFile(tmpDir, "next.config.mjs", `/* output: "export" */ export default {};`); + expect(detectStaticExport(tmpDir)).toBe(false); + }); + + it("strips single-line comments before matching", () => { + writeFile(tmpDir, "next.config.mjs", `// output: "export"\nexport default {};`); + expect(detectStaticExport(tmpDir)).toBe(false); + }); }); // ─── generateWranglerConfig ───────────────────────────────────────────────── @@ -365,6 +487,67 @@ describe("generateWranglerConfig", () => { expect(parsed.images).toBeDefined(); expect(parsed.images.binding).toBe("IMAGES"); }); + + it("generates static-only config when isStaticExport is true", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`); + const info = detectProject(tmpDir); + const config = generateWranglerConfig(info); + const parsed = JSON.parse(config); + + // No worker entry — Cloudflare serves static files directly + expect(parsed.main).toBeUndefined(); + // Uses 404-page handling instead of routing all requests to worker + expect(parsed.assets.not_found_handling).toBe("404-page"); + // No ASSETS binding needed (no worker to bind to) + expect(parsed.assets.binding).toBeUndefined(); + // Still has directory for asset serving + expect(parsed.assets.directory).toBe("dist/client"); + // No image optimization binding + expect(parsed.images).toBeUndefined(); + }); + + it("omits KV namespace for static exports even with ISR-like patterns", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`); + writeFile( + tmpDir, + "app/page.tsx", + "export const revalidate = 30;\nexport default function() { return
}", + ); + const info = detectProject(tmpDir); + const config = generateWranglerConfig(info); + const parsed = JSON.parse(config); + + expect(parsed.kv_namespaces).toBeUndefined(); + }); + + it("static export config still includes $schema, compatibility_date, and compatibility_flags", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`); + const info = detectProject(tmpDir); + const config = generateWranglerConfig(info); + const parsed = JSON.parse(config); + + expect(parsed.$schema).toBe("node_modules/wrangler/config-schema.json"); + const today = new Date().toISOString().split("T")[0]; + expect(parsed.compatibility_date).toBe(today); + expect(parsed.compatibility_flags).toContain("nodejs_compat"); + }); + + it("non-static config has main, ASSETS binding, and images (regression guard)", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.mjs", `export default {};`); + const info = detectProject(tmpDir); + const config = generateWranglerConfig(info); + const parsed = JSON.parse(config); + + expect(parsed.main).toBe("./worker/index.ts"); + expect(parsed.assets.binding).toBe("ASSETS"); + expect(parsed.assets.not_found_handling).toBe("none"); + expect(parsed.images).toBeDefined(); + expect(parsed.images.binding).toBe("IMAGES"); + }); }); // ─── Worker Entry Generation ───────────────────────────────────────────────── @@ -1096,6 +1279,36 @@ describe("getFilesToGenerate", () => { expect(viteFile).toBeDefined(); expect(viteFile!.content).not.toContain("plugin-rsc"); }); + + it("skips worker/index.ts for static exports", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`); + const info = detectProject(tmpDir); + const files = getFilesToGenerate(info); + + const workerFile = files.find((f) => f.description === "worker/index.ts"); + expect(workerFile).toBeUndefined(); + }); + + it("still generates wrangler.jsonc for static exports", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`); + const info = detectProject(tmpDir); + const files = getFilesToGenerate(info); + + const wranglerFile = files.find((f) => f.description === "wrangler.jsonc"); + expect(wranglerFile).toBeDefined(); + }); + + it("still generates vite.config.ts for static exports", () => { + mkdir(tmpDir, "app"); + writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`); + const info = detectProject(tmpDir); + const files = getFilesToGenerate(info); + + const viteFile = files.find((f) => f.description === "vite.config.ts"); + expect(viteFile).toBeDefined(); + }); }); // ─── viteConfigHasCloudflarePlugin ───────────────────────────────────────────