@@ -19,6 +19,7 @@ import {
1919 viteConfigHasCloudflarePlugin ,
2020 hasWranglerConfig ,
2121 formatMissingCloudflarePluginError ,
22+ detectStaticExport ,
2223} from "../packages/vinext/src/deploy.js" ;
2324import {
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