Skip to content

Commit 00a2724

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 00a2724

3 files changed

Lines changed: 273 additions & 15 deletions

File tree

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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1808,7 +1808,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
18081808
!hasCloudflarePlugin &&
18091809
!hasNitroPlugin &&
18101810
hasWranglerConfig(root) &&
1811-
!options.disableAppRouter
1811+
!options.disableAppRouter &&
1812+
nextConfig.output !== "export"
18121813
) {
18131814
throw new Error(
18141815
formatMissingCloudflarePluginError({

tests/deploy.test.ts

Lines changed: 213 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,127 @@ 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(
317+
tmpDir,
318+
"next.config.ts",
319+
`const config = { output: 'export' };\nexport default config;`,
320+
);
321+
const info = detectProject(tmpDir);
322+
expect(info.isStaticExport).toBe(true);
323+
});
324+
325+
it("detects output: 'export' in next.config.cjs", () => {
326+
mkdir(tmpDir, "app");
327+
writeFile(tmpDir, "next.config.cjs", `module.exports = { output: "export" };`);
328+
const info = detectProject(tmpDir);
329+
expect(info.isStaticExport).toBe(true);
330+
});
331+
332+
it("does not detect commented-out output: 'export'", () => {
333+
mkdir(tmpDir, "app");
334+
writeFile(tmpDir, "next.config.mjs", `// output: "export"\nexport default {};`);
335+
const info = detectProject(tmpDir);
336+
expect(info.isStaticExport).toBe(false);
337+
});
338+
339+
it("does not detect output: 'standalone' as static export", () => {
340+
mkdir(tmpDir, "app");
341+
writeFile(tmpDir, "next.config.mjs", `export default { output: "standalone" };`);
342+
const info = detectProject(tmpDir);
343+
expect(info.isStaticExport).toBe(false);
344+
});
345+
346+
it("isStaticExport is false when no next.config exists", () => {
347+
mkdir(tmpDir, "app");
348+
const info = detectProject(tmpDir);
349+
expect(info.isStaticExport).toBe(false);
350+
});
351+
352+
it("handles empty next.config.js gracefully", () => {
353+
mkdir(tmpDir, "app");
354+
writeFile(tmpDir, "next.config.js", "");
355+
const info = detectProject(tmpDir);
356+
expect(info.isStaticExport).toBe(false);
357+
});
358+
359+
it("prefers next.config.ts over next.config.mjs for static export detection", () => {
360+
mkdir(tmpDir, "app");
361+
writeFile(tmpDir, "next.config.ts", `export default {};`);
362+
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
363+
const info = detectProject(tmpDir);
364+
// .ts is checked first and has no output: "export", so result is false
365+
expect(info.isStaticExport).toBe(false);
366+
});
367+
368+
it("does not detect output: 'export' inside block comments", () => {
369+
mkdir(tmpDir, "app");
370+
writeFile(tmpDir, "next.config.mjs", `/* output: "export" */\nexport default {};`);
371+
const info = detectProject(tmpDir);
372+
expect(info.isStaticExport).toBe(false);
373+
});
374+
375+
it("does not detect output: 'export' inside multiline block comments", () => {
376+
mkdir(tmpDir, "app");
377+
writeFile(
378+
tmpDir,
379+
"next.config.mjs",
380+
"/*\n * Previously: output: 'export'\n */\nexport default {};",
381+
);
382+
const info = detectProject(tmpDir);
383+
expect(info.isStaticExport).toBe(false);
384+
});
385+
});
386+
387+
// ─── detectStaticExport (direct) ────────────────────────────────────────────
388+
389+
describe("detectStaticExport", () => {
390+
it('returns true for output: "export"', () => {
391+
writeFile(tmpDir, "next.config.mjs", `export default { output: "export" };`);
392+
expect(detectStaticExport(tmpDir)).toBe(true);
393+
});
394+
395+
it("returns false when no next.config exists", () => {
396+
expect(detectStaticExport(tmpDir)).toBe(false);
397+
});
398+
399+
it('returns false for output: "standalone"', () => {
400+
writeFile(tmpDir, "next.config.mjs", `export default { output: "standalone" };`);
401+
expect(detectStaticExport(tmpDir)).toBe(false);
402+
});
403+
404+
it("strips block comments before matching", () => {
405+
writeFile(tmpDir, "next.config.mjs", `/* output: "export" */ export default {};`);
406+
expect(detectStaticExport(tmpDir)).toBe(false);
407+
});
408+
409+
it("strips single-line comments before matching", () => {
410+
writeFile(tmpDir, "next.config.mjs", `// output: "export"\nexport default {};`);
411+
expect(detectStaticExport(tmpDir)).toBe(false);
412+
});
291413
});
292414

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

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

11011314
// ─── viteConfigHasCloudflarePlugin ───────────────────────────────────────────

0 commit comments

Comments
 (0)