Skip to content

Commit bcff738

Browse files
committed
feat: lightweight worker for static exports (#566)
Strip the full worker from static export builds. When next.config sets output: "export", vinext deploy now generates a wrangler config that serves pre-rendered files directly via Cloudflare's built-in asset serving — no worker entry, no ASSETS/IMAGES bindings, no KV namespace. - Add `isStaticExport` to ProjectInfo with heuristic detection (reads next.config, strips single-line and block comments, matches output: "export" / 'export') - generateWranglerConfig: static branch uses not_found_handling: "404-page" - getFilesToGenerate: skip worker/index.ts for static exports - Relax Cloudflare plugin guard: use already-loaded nextConfig.output instead of re-reading disk, so inline config overrides also work - 24 new tests covering detection, config generation, and file generation Closes #566
1 parent bd2e41f commit bcff738

File tree

3 files changed

+270
-15
lines changed

3 files changed

+270
-15
lines changed

packages/vinext/src/deploy.ts

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ type ProjectInfo = {
119119
projectName: string;
120120
/** Pages that use `revalidate` (ISR) */
121121
hasISR: boolean;
122+
/** next.config.js sets output: 'export' (full static export) */
123+
isStaticExport: boolean;
122124
/** package.json has "type": "module" */
123125
hasTypeModule: boolean;
124126
/** .mdx files detected in app/ or pages/ */
@@ -222,6 +224,9 @@ export function detectProject(root: string): ProjectInfo {
222224
// Detect ISR usage (rough heuristic: search for `revalidate` exports)
223225
const hasISR = detectISR(root, isAppRouter);
224226

227+
// Detect output: 'export' in next.config
228+
const isStaticExport = detectStaticExport(root);
229+
225230
// Detect "type": "module" in package.json
226231
const hasTypeModule = pkg?.type === "module";
227232

@@ -250,13 +255,42 @@ export function detectProject(root: string): ProjectInfo {
250255
hasWrangler,
251256
projectName,
252257
hasISR,
258+
isStaticExport,
253259
hasTypeModule,
254260
hasMDX,
255261
hasCodeHike,
256262
nativeModulesToStub,
257263
};
258264
}
259265

266+
/**
267+
* Heuristic: does the next.config file set `output: 'export'`?
268+
*
269+
* Limitations (consistent with detectISR): variable indirection like
270+
* `const o = "export"; { output: o }` is not detected. Template-literal
271+
* syntax (`output: \`export\``) is also not detected (uncommon in configs).
272+
*/
273+
export function detectStaticExport(root: string): boolean {
274+
const configNames = ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.cjs"];
275+
for (const name of configNames) {
276+
const configPath = path.join(root, name);
277+
if (fs.existsSync(configPath)) {
278+
try {
279+
const content = fs.readFileSync(configPath, "utf-8");
280+
// Strip comments to avoid matching commented-out config.
281+
const stripped = content
282+
.replace(/\/\/.*$/gm, "") // single-line comments
283+
.replace(/\/\*[\s\S]*?\*\//g, ""); // block comments
284+
// Match output: "export" or output: 'export' (enforcing matching quotes).
285+
return /output\s*:\s*(?:"export"|'export')/.test(stripped);
286+
} catch {
287+
return false;
288+
}
289+
}
290+
}
291+
return false;
292+
}
293+
260294
function detectISR(root: string, isAppRouter: boolean): boolean {
261295
// ISR detection is only implemented for App Router (scans for `export const revalidate`).
262296
// Pages Router ISR (getStaticProps + revalidate) is not detected here — wrangler.jsonc
@@ -396,31 +430,41 @@ export function generateWranglerConfig(info: ProjectInfo): string {
396430
name: info.projectName,
397431
compatibility_date: today,
398432
compatibility_flags: ["nodejs_compat"],
399-
main: "./worker/index.ts",
400-
assets: {
433+
};
434+
435+
if (info.isStaticExport) {
436+
// Static export: serve pre-rendered files directly — no worker needed.
437+
// Cloudflare's built-in asset serving handles everything.
438+
config.assets = {
439+
directory: "dist/client",
440+
not_found_handling: "404-page",
441+
};
442+
} else {
443+
config.main = "./worker/index.ts";
444+
config.assets = {
401445
// Wrangler 4.69+ requires `directory` when `assets` is an object.
402446
// The @cloudflare/vite-plugin always writes static assets to dist/client/.
403447
directory: "dist/client",
404448
not_found_handling: "none",
405449
// Expose static assets to the Worker via env.ASSETS so the image
406450
// optimization handler can fetch source images programmatically.
407451
binding: "ASSETS",
408-
},
452+
};
409453
// Cloudflare Images binding for next/image optimization.
410454
// Enables resize, format negotiation (AVIF/WebP), and quality transforms
411455
// at the edge. No user setup needed — wrangler creates the binding automatically.
412-
images: {
456+
config.images = {
413457
binding: "IMAGES",
414-
},
415-
};
458+
};
416459

417-
if (info.hasISR) {
418-
config.kv_namespaces = [
419-
{
420-
binding: "VINEXT_CACHE",
421-
id: "<your-kv-namespace-id>",
422-
},
423-
];
460+
if (info.hasISR) {
461+
config.kv_namespaces = [
462+
{
463+
binding: "VINEXT_CACHE",
464+
id: "<your-kv-namespace-id>",
465+
},
466+
];
467+
}
424468
}
425469

426470
return JSON.stringify(config, null, 2) + "\n";
@@ -1112,7 +1156,7 @@ export function getFilesToGenerate(info: ProjectInfo): GeneratedFile[] {
11121156
});
11131157
}
11141158

1115-
if (!info.hasWorkerEntry) {
1159+
if (!info.hasWorkerEntry && !info.isStaticExport) {
11161160
const workerContent = info.isAppRouter
11171161
? generateAppRouterWorkerEntry(info.hasISR)
11181162
: generatePagesRouterWorkerEntry();

packages/vinext/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ type ASTNode = ReturnType<typeof parseAst>["body"][number]["parent"];
9494
const __dirname = import.meta.dirname;
9595
type VitePluginReactModule = typeof import("@vitejs/plugin-react");
9696

97+
9798
function resolveOptionalDependency(projectRoot: string, specifier: string): string | null {
9899
try {
99100
const projectRequire = createRequire(path.join(projectRoot, "package.json"));
@@ -1808,7 +1809,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
18081809
!hasCloudflarePlugin &&
18091810
!hasNitroPlugin &&
18101811
hasWranglerConfig(root) &&
1811-
!options.disableAppRouter
1812+
!options.disableAppRouter &&
1813+
nextConfig.output !== "export"
18121814
) {
18131815
throw new Error(
18141816
formatMissingCloudflarePluginError({

tests/deploy.test.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
viteConfigHasCloudflarePlugin,
2020
hasWranglerConfig,
2121
formatMissingCloudflarePluginError,
22+
detectStaticExport,
2223
} from "../packages/vinext/src/deploy.js";
2324
import {
2425
detectPackageManager,
@@ -288,6 +289,123 @@ describe("detectProject", () => {
288289
const info = detectProject(tmpDir);
289290
expect(info.hasISR).toBe(false);
290291
});
292+
293+
it("detects output: 'export' in next.config.mjs", () => {
294+
mkdir(tmpDir, "app");
295+
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
296+
const info = detectProject(tmpDir);
297+
expect(info.isStaticExport).toBe(true);
298+
});
299+
300+
it("detects output:\"export\" without spaces", () => {
301+
mkdir(tmpDir, "app");
302+
writeFile(tmpDir, "next.config.mjs", `export default {output:"export"};`);
303+
const info = detectProject(tmpDir);
304+
expect(info.isStaticExport).toBe(true);
305+
});
306+
307+
it("isStaticExport is false when no export config", () => {
308+
mkdir(tmpDir, "app");
309+
writeFile(tmpDir, "next.config.mjs", `export default {};`);
310+
const info = detectProject(tmpDir);
311+
expect(info.isStaticExport).toBe(false);
312+
});
313+
314+
it("detects output: 'export' in next.config.ts", () => {
315+
mkdir(tmpDir, "app");
316+
writeFile(tmpDir, "next.config.ts", `const config = { output: 'export' };\nexport default config;`);
317+
const info = detectProject(tmpDir);
318+
expect(info.isStaticExport).toBe(true);
319+
});
320+
321+
it("detects output: 'export' in next.config.cjs", () => {
322+
mkdir(tmpDir, "app");
323+
writeFile(tmpDir, "next.config.cjs", `module.exports = { output: "export" };`);
324+
const info = detectProject(tmpDir);
325+
expect(info.isStaticExport).toBe(true);
326+
});
327+
328+
it("does not detect commented-out output: 'export'", () => {
329+
mkdir(tmpDir, "app");
330+
writeFile(tmpDir, "next.config.mjs", `// output: "export"\nexport default {};`);
331+
const info = detectProject(tmpDir);
332+
expect(info.isStaticExport).toBe(false);
333+
});
334+
335+
it("does not detect output: 'standalone' as static export", () => {
336+
mkdir(tmpDir, "app");
337+
writeFile(tmpDir, "next.config.mjs", `export default { output: "standalone" };`);
338+
const info = detectProject(tmpDir);
339+
expect(info.isStaticExport).toBe(false);
340+
});
341+
342+
it("isStaticExport is false when no next.config exists", () => {
343+
mkdir(tmpDir, "app");
344+
const info = detectProject(tmpDir);
345+
expect(info.isStaticExport).toBe(false);
346+
});
347+
348+
it("handles empty next.config.js gracefully", () => {
349+
mkdir(tmpDir, "app");
350+
writeFile(tmpDir, "next.config.js", "");
351+
const info = detectProject(tmpDir);
352+
expect(info.isStaticExport).toBe(false);
353+
});
354+
355+
it("prefers next.config.ts over next.config.mjs for static export detection", () => {
356+
mkdir(tmpDir, "app");
357+
writeFile(tmpDir, "next.config.ts", `export default {};`);
358+
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
359+
const info = detectProject(tmpDir);
360+
// .ts is checked first and has no output: "export", so result is false
361+
expect(info.isStaticExport).toBe(false);
362+
});
363+
364+
it("does not detect output: 'export' inside block comments", () => {
365+
mkdir(tmpDir, "app");
366+
writeFile(tmpDir, "next.config.mjs", `/* output: "export" */\nexport default {};`);
367+
const info = detectProject(tmpDir);
368+
expect(info.isStaticExport).toBe(false);
369+
});
370+
371+
it("does not detect output: 'export' inside multiline block comments", () => {
372+
mkdir(tmpDir, "app");
373+
writeFile(
374+
tmpDir,
375+
"next.config.mjs",
376+
"/*\n * Previously: output: 'export'\n */\nexport default {};",
377+
);
378+
const info = detectProject(tmpDir);
379+
expect(info.isStaticExport).toBe(false);
380+
});
381+
});
382+
383+
// ─── detectStaticExport (direct) ────────────────────────────────────────────
384+
385+
describe("detectStaticExport", () => {
386+
it("returns true for output: \"export\"", () => {
387+
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
388+
expect(detectStaticExport(tmpDir)).toBe(true);
389+
});
390+
391+
it("returns false when no next.config exists", () => {
392+
expect(detectStaticExport(tmpDir)).toBe(false);
393+
});
394+
395+
it("returns false for output: \"standalone\"", () => {
396+
writeFile(tmpDir, "next.config.mjs", `export default { output: "standalone" };`);
397+
expect(detectStaticExport(tmpDir)).toBe(false);
398+
});
399+
400+
it("strips block comments before matching", () => {
401+
writeFile(tmpDir, "next.config.mjs", `/* output: "export" */ export default {};`);
402+
expect(detectStaticExport(tmpDir)).toBe(false);
403+
});
404+
405+
it("strips single-line comments before matching", () => {
406+
writeFile(tmpDir, "next.config.mjs", `// output: "export"\nexport default {};`);
407+
expect(detectStaticExport(tmpDir)).toBe(false);
408+
});
291409
});
292410

293411
// ─── generateWranglerConfig ─────────────────────────────────────────────────
@@ -365,6 +483,67 @@ describe("generateWranglerConfig", () => {
365483
expect(parsed.images).toBeDefined();
366484
expect(parsed.images.binding).toBe("IMAGES");
367485
});
486+
487+
it("generates static-only config when isStaticExport is true", () => {
488+
mkdir(tmpDir, "app");
489+
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
490+
const info = detectProject(tmpDir);
491+
const config = generateWranglerConfig(info);
492+
const parsed = JSON.parse(config);
493+
494+
// No worker entry — Cloudflare serves static files directly
495+
expect(parsed.main).toBeUndefined();
496+
// Uses 404-page handling instead of routing all requests to worker
497+
expect(parsed.assets.not_found_handling).toBe("404-page");
498+
// No ASSETS binding needed (no worker to bind to)
499+
expect(parsed.assets.binding).toBeUndefined();
500+
// Still has directory for asset serving
501+
expect(parsed.assets.directory).toBe("dist/client");
502+
// No image optimization binding
503+
expect(parsed.images).toBeUndefined();
504+
});
505+
506+
it("omits KV namespace for static exports even with ISR-like patterns", () => {
507+
mkdir(tmpDir, "app");
508+
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
509+
writeFile(
510+
tmpDir,
511+
"app/page.tsx",
512+
"export const revalidate = 30;\nexport default function() { return <div/> }",
513+
);
514+
const info = detectProject(tmpDir);
515+
const config = generateWranglerConfig(info);
516+
const parsed = JSON.parse(config);
517+
518+
expect(parsed.kv_namespaces).toBeUndefined();
519+
});
520+
521+
it("static export config still includes $schema, compatibility_date, and compatibility_flags", () => {
522+
mkdir(tmpDir, "app");
523+
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
524+
const info = detectProject(tmpDir);
525+
const config = generateWranglerConfig(info);
526+
const parsed = JSON.parse(config);
527+
528+
expect(parsed.$schema).toBe("node_modules/wrangler/config-schema.json");
529+
const today = new Date().toISOString().split("T")[0];
530+
expect(parsed.compatibility_date).toBe(today);
531+
expect(parsed.compatibility_flags).toContain("nodejs_compat");
532+
});
533+
534+
it("non-static config has main, ASSETS binding, and images (regression guard)", () => {
535+
mkdir(tmpDir, "app");
536+
writeFile(tmpDir, "next.config.mjs", `export default {};`);
537+
const info = detectProject(tmpDir);
538+
const config = generateWranglerConfig(info);
539+
const parsed = JSON.parse(config);
540+
541+
expect(parsed.main).toBe("./worker/index.ts");
542+
expect(parsed.assets.binding).toBe("ASSETS");
543+
expect(parsed.assets.not_found_handling).toBe("none");
544+
expect(parsed.images).toBeDefined();
545+
expect(parsed.images.binding).toBe("IMAGES");
546+
});
368547
});
369548

370549
// ─── Worker Entry Generation ─────────────────────────────────────────────────
@@ -1096,6 +1275,36 @@ describe("getFilesToGenerate", () => {
10961275
expect(viteFile).toBeDefined();
10971276
expect(viteFile!.content).not.toContain("plugin-rsc");
10981277
});
1278+
1279+
it("skips worker/index.ts for static exports", () => {
1280+
mkdir(tmpDir, "app");
1281+
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
1282+
const info = detectProject(tmpDir);
1283+
const files = getFilesToGenerate(info);
1284+
1285+
const workerFile = files.find((f) => f.description === "worker/index.ts");
1286+
expect(workerFile).toBeUndefined();
1287+
});
1288+
1289+
it("still generates wrangler.jsonc for static exports", () => {
1290+
mkdir(tmpDir, "app");
1291+
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
1292+
const info = detectProject(tmpDir);
1293+
const files = getFilesToGenerate(info);
1294+
1295+
const wranglerFile = files.find((f) => f.description === "wrangler.jsonc");
1296+
expect(wranglerFile).toBeDefined();
1297+
});
1298+
1299+
it("still generates vite.config.ts for static exports", () => {
1300+
mkdir(tmpDir, "app");
1301+
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
1302+
const info = detectProject(tmpDir);
1303+
const files = getFilesToGenerate(info);
1304+
1305+
const viteFile = files.find((f) => f.description === "vite.config.ts");
1306+
expect(viteFile).toBeDefined();
1307+
});
10991308
});
11001309

11011310
// ─── viteConfigHasCloudflarePlugin ───────────────────────────────────────────

0 commit comments

Comments
 (0)