swift-rust 1.5.1 → 1.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -364,7 +364,303 @@ const externalizeDepsPlugin = {
364
364
  },
365
365
  };
366
366
 
367
+ // ── Component-level "use client" islands ───────────────────────────────────
368
+ // A `use client` *component* imported into a server-rendered page can't just be
369
+ // SSR'd to static HTML — its useState/effects/handlers would never run. So when
370
+ // the SSR bundle pulls in a client component, we wrap it: on the server it still
371
+ // renders to HTML (good for SEO + no flash), but inside a marker element that
372
+ // carries the component's source path, export name, and serialized props. A tiny
373
+ // client runtime then hydrates each marker from a per-component browser bundle.
374
+ //
375
+ // Limitations (documented): props must be JSON-serializable; `children` and
376
+ // function props don't cross the boundary, so islands should be self-contained
377
+ // leaves (they manage their own children). Nested client components imported by
378
+ // a client component are bundled into that island, not re-wrapped.
379
+
380
+ const ISLAND_EXTS = [".tsx", ".ts", ".jsx", ".js", ".mjs"];
381
+
382
+ // Resolve a relative / absolute / "@/"-aliased specifier to a real file path.
383
+ function resolveIslandSpecifier(spec, importer) {
384
+ let base;
385
+ if (spec.startsWith("@/")) base = resolve(cwd, "src", spec.slice(2));
386
+ else if (spec.startsWith("/")) base = spec;
387
+ else if (spec.startsWith(".")) base = resolve(dirname(importer || cwd), spec);
388
+ else return null;
389
+ const candidates = [base, ...ISLAND_EXTS.map((e) => base + e), ...ISLAND_EXTS.map((e) => join(base, "index" + e))];
390
+ for (const c of candidates) {
391
+ try { if (statSync(c).isFile()) return c; } catch {}
392
+ }
393
+ return null;
394
+ }
395
+
396
+ // Extract export names from a module's source so we can re-export wrapped
397
+ // versions. We only wrap Capitalized exports (React component convention) + the
398
+ // default export; lowercase exports pass through untouched.
399
+ function detectIslandExports(src) {
400
+ const named = new Set();
401
+ let hasDefault = false;
402
+ // strip block/line comments cheaply to avoid false matches
403
+ const code = src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/[^\n]*/g, "$1");
404
+ if (/\bexport\s+default\b/.test(code)) hasDefault = true;
405
+ for (const m of code.matchAll(/\bexport\s+(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g)) {
406
+ named.add(m[1]);
407
+ }
408
+ for (const m of code.matchAll(/\bexport\s*\{([^}]*)\}/g)) {
409
+ for (const part of m[1].split(",")) {
410
+ const name = part.trim().split(/\s+as\s+/).pop()?.trim();
411
+ if (name && name !== "default") named.add(name);
412
+ }
413
+ }
414
+ return { hasDefault, named: [...named] };
415
+ }
416
+
417
+ // Build the source of an island wrapper module for a given client component
418
+ // file. Temp sibling files hold verbatim copies of client components, so the
419
+ // wrapper can import the *real* module under a distinct on-disk path (Bun
420
+ // dedupes modules by resolved path, so a query-string alias would collapse the
421
+ // real module into the wrapper). `temps` is per-build: a shared global list
422
+ // let concurrent builds delete each other's temp files mid-build, which
423
+ // surfaced as transient module-not-found errors on save.
424
+ function islandWrapperSource(abs, temps) {
425
+ const src = readFileSync(abs, "utf8");
426
+ const { hasDefault, named } = detectIslandExports(src);
427
+ // Write the real component to a unique sibling so imports resolve identically.
428
+ const rawName = `.__sr_raw_${process.pid}_${Math.random().toString(36).slice(2)}__${basename(abs)}`;
429
+ const rawPath = join(dirname(abs), rawName);
430
+ writeFileSync(rawPath, src);
431
+ temps.push(rawPath);
432
+ const rawSpec = JSON.stringify(rawPath);
433
+ let out = `import { createElement as __h } from "react";
434
+ import * as __real from ${rawSpec};
435
+ const __src = ${JSON.stringify(abs)};
436
+ function __ser(props){
437
+ var out = {};
438
+ for (var k in props){
439
+ if (k === "children") continue;
440
+ var v = props[k]; var t = typeof v;
441
+ if (v === null || t === "string" || t === "number" || t === "boolean" || (t === "object" && typeof v.$$typeof === "undefined")){
442
+ try { JSON.stringify(v); out[k] = v; } catch (e) {}
443
+ }
444
+ }
445
+ return JSON.stringify(out).replace(/</g, "\\\\u003c");
446
+ }
447
+ function __wrap(Comp, name){
448
+ if (typeof Comp !== "function") return Comp;
449
+ function SrIsland(props){
450
+ return __h("div", {
451
+ "data-sr-island": "",
452
+ "data-sr-island-src": __src,
453
+ "data-sr-island-export": name,
454
+ "data-sr-island-props": __ser(props),
455
+ style: { display: "contents" },
456
+ }, __h(Comp, props));
457
+ }
458
+ SrIsland.displayName = "Island(" + name + ")";
459
+ return SrIsland;
460
+ }
461
+ `;
462
+ if (hasDefault) out += `export default __wrap(__real.default, "default");\n`;
463
+ for (const n of named) {
464
+ if (/^[A-Z]/.test(n)) out += `export const ${n} = __wrap(__real[${JSON.stringify(n)}], ${JSON.stringify(n)});\n`;
465
+ else out += `export const ${n} = __real[${JSON.stringify(n)}];\n`;
466
+ }
467
+ return out;
468
+ }
469
+
470
+ // Which exported names of a 'use cache' module are *async functions* (the only
471
+ // ones safe to wrap with cache(), since cache() always returns a Promise). We
472
+ // pass everything else through untouched.
473
+ function detectAsyncExports(src) {
474
+ const code = src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/[^\n]*/g, "$1");
475
+ const asyncNamed = new Set();
476
+ const allNamed = new Set();
477
+ let defaultAsync = false;
478
+ let hasDefault = false;
479
+ if (/\bexport\s+default\b/.test(code)) {
480
+ hasDefault = true;
481
+ if (/\bexport\s+default\s+async\b/.test(code)) defaultAsync = true;
482
+ }
483
+ for (const m of code.matchAll(/\bexport\s+(async\s+)?function\s+([A-Za-z_$][\w$]*)/g)) {
484
+ allNamed.add(m[2]);
485
+ if (m[1]) asyncNamed.add(m[2]);
486
+ }
487
+ for (const m of code.matchAll(/\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*(async\b)?/g)) {
488
+ allNamed.add(m[1]);
489
+ if (m[2]) asyncNamed.add(m[1]);
490
+ }
491
+ for (const m of code.matchAll(/\bexport\s*\{([^}]*)\}/g)) {
492
+ for (const part of m[1].split(",")) {
493
+ const name = part.trim().split(/\s+as\s+/).pop()?.trim();
494
+ if (name && name !== "default") allNamed.add(name);
495
+ }
496
+ }
497
+ return { asyncNamed: [...asyncNamed], allNamed: [...allNamed], defaultAsync, hasDefault };
498
+ }
499
+
500
+ // Source of a wrapper module that memoizes a 'use cache' module's async exports.
501
+ function cacheWrapperSource(abs, temps) {
502
+ const src = readFileSync(abs, "utf8");
503
+ const { asyncNamed, allNamed, defaultAsync, hasDefault } = detectAsyncExports(src);
504
+ const rawName = `.__sr_raw_${process.pid}_${Math.random().toString(36).slice(2)}__${basename(abs)}`;
505
+ const rawPath = join(dirname(abs), rawName);
506
+ writeFileSync(rawPath, src);
507
+ temps.push(rawPath);
508
+ const tag = relative(cwd, abs);
509
+ let out = `import { cache as __cache } from "swift-rust/cache";
510
+ import * as __real from ${JSON.stringify(rawPath)};
511
+ const __opts = { tags: ${JSON.stringify([tag])} };
512
+ `;
513
+ if (hasDefault) {
514
+ out += defaultAsync
515
+ ? `export default __cache(__real.default, __opts);\n`
516
+ : `export default __real.default;\n`;
517
+ }
518
+ for (const n of allNamed) {
519
+ out += asyncNamed.includes(n)
520
+ ? `export const ${n} = __cache(__real[${JSON.stringify(n)}], __opts);\n`
521
+ : `export const ${n} = __real[${JSON.stringify(n)}];\n`;
522
+ }
523
+ return out;
524
+ }
525
+
526
+ // Bun plugin: auto-memoize modules that declare a leading 'use cache' directive.
527
+ // Built per-build so each build owns its temp-file list (see islandWrapperSource).
528
+ function makeUseCachePlugin(temps) {
529
+ return {
530
+ name: "sr-use-cache",
531
+ setup(build) {
532
+ build.onResolve({ filter: /.*/ }, (args) => {
533
+ const p = args.path;
534
+ if (!(p.startsWith(".") || p.startsWith("/") || p.startsWith("@/"))) return undefined;
535
+ // Our own raw temp copy: resolve it explicitly into the file namespace.
536
+ // (Deferring to Bun's default resolver fails from a virtual namespace.)
537
+ if (/\.__sr_raw_/.test(p)) return { path: p, namespace: "file" };
538
+ if (args.importer && hasUseDirective(args.importer, "cache")) return undefined;
539
+ const abs = resolveIslandSpecifier(p, args.importer);
540
+ if (!abs || !hasUseDirective(abs, "cache")) return undefined;
541
+ return { path: abs, namespace: "sr-cache" };
542
+ });
543
+ build.onLoad({ filter: /.*/, namespace: "sr-cache" }, (args) => ({
544
+ contents: cacheWrapperSource(args.path, temps),
545
+ loader: "js",
546
+ resolveDir: dirname(args.path),
547
+ }));
548
+ },
549
+ };
550
+ }
551
+
552
+ // Bun plugin: wrap client components imported by *server* modules as islands.
553
+ function makeClientIslandPlugin(temps) {
554
+ return {
555
+ name: "sr-client-islands",
556
+ setup(build) {
557
+ build.onResolve({ filter: /.*/ }, (args) => {
558
+ const p = args.path;
559
+ if (!(p.startsWith(".") || p.startsWith("/") || p.startsWith("@/"))) return undefined;
560
+ // Never wrap our own temp raw copies, and don't wrap when the importer is
561
+ // itself a client module — nested client components belong to that
562
+ // island's bundle, not a new boundary.
563
+ if (/\.__sr_raw_/.test(p)) return { path: p, namespace: "file" };
564
+ if (args.importer && hasUseDirective(args.importer, "client")) return undefined;
565
+ const abs = resolveIslandSpecifier(p, args.importer);
566
+ if (!abs || !hasUseDirective(abs, "client")) return undefined;
567
+ return { path: abs, namespace: "sr-island" };
568
+ });
569
+ build.onLoad({ filter: /.*/, namespace: "sr-island" }, (args) => ({
570
+ contents: islandWrapperSource(args.path, temps),
571
+ loader: "js",
572
+ resolveDir: dirname(args.path),
573
+ }));
574
+ },
575
+ };
576
+ }
577
+
578
+ // In-flight build dedup: concurrent requests for the same entry (every open tab
579
+ // reloads at once on an HMR broadcast) await one build instead of racing.
580
+ function withInflight(map, key, gen, factory) {
581
+ const cur = map.get(key);
582
+ if (cur && cur.gen === gen) return cur.promise;
583
+ const entry = { gen, promise: null };
584
+ entry.promise = (async () => {
585
+ try {
586
+ return await factory();
587
+ } finally {
588
+ if (map.get(key) === entry) map.delete(key);
589
+ }
590
+ })();
591
+ map.set(key, entry);
592
+ return entry.promise;
593
+ }
594
+
595
+ // Cache for per-component browser island bundles (src -> { gen, code }).
596
+ // Each bundle is self-mounting: it imports the client component, finds every
597
+ // marker on the page for this source, and hydrates it. React is bundled in, so a
598
+ // single <script type="module"> per source needs no import map or shared runtime.
599
+ const componentIslandCache = new Map();
600
+ const componentIslandInflight = new Map();
601
+ async function buildComponentIslandBundle(srcFile) {
602
+ if (typeof Bun === "undefined" || typeof Bun.build !== "function") {
603
+ throw new Error("client islands require the Bun runtime");
604
+ }
605
+ const cached = componentIslandCache.get(srcFile);
606
+ if (cached && cached.gen === buildGeneration) return cached.code;
607
+ return withInflight(componentIslandInflight, srcFile, buildGeneration, () =>
608
+ buildComponentIslandBundleNow(srcFile),
609
+ );
610
+ }
611
+ async function buildComponentIslandBundleNow(srcFile) {
612
+ const entryPath = join(
613
+ dirname(srcFile),
614
+ `.__sr_cisland_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2)}.js`,
615
+ );
616
+ const entry = `import { hydrateRoot, createRoot } from "react-dom/client";
617
+ import { createElement } from "react";
618
+ import * as __mod from ${JSON.stringify(srcFile)};
619
+ var __src = ${JSON.stringify(srcFile)};
620
+ var nodes = document.querySelectorAll('[data-sr-island]');
621
+ for (var i = 0; i < nodes.length; i++) {
622
+ var el = nodes[i];
623
+ if (el.getAttribute("data-sr-island-src") !== __src || el.__srHydrated) continue;
624
+ el.__srHydrated = true;
625
+ var name = el.getAttribute("data-sr-island-export") || "default";
626
+ var Comp = __mod[name] || __mod.default;
627
+ if (typeof Comp !== "function") { console.warn("[swift-rust] island export missing:", name, __src); continue; }
628
+ var props = {};
629
+ try { props = JSON.parse(el.getAttribute("data-sr-island-props") || "{}"); } catch (e) {}
630
+ var element = createElement(Comp, props);
631
+ try { hydrateRoot(el, element); }
632
+ catch (err) { el.innerHTML = ""; createRoot(el).render(element); }
633
+ }
634
+ `;
635
+ writeFileSync(entryPath, entry);
636
+ try {
637
+ const result = await Bun.build({
638
+ entrypoints: [entryPath],
639
+ target: "browser",
640
+ minify: true,
641
+ define: { "process.env.NODE_ENV": '"production"' },
642
+ });
643
+ if (!result.success) throw new Error(result.logs.map((l) => l.message).join("\n"));
644
+ const code = await result.outputs[0].text();
645
+ componentIslandCache.set(srcFile, { gen: buildGeneration, code });
646
+ return code;
647
+ } finally {
648
+ try { unlinkSync(entryPath); } catch {}
649
+ }
650
+ }
651
+
652
+ // Collect unique client-island source files referenced in rendered HTML.
653
+ function collectIslandSources(html) {
654
+ const srcs = new Set();
655
+ if (!html) return srcs;
656
+ for (const m of html.matchAll(/data-sr-island-src="([^"]+)"/g)) {
657
+ srcs.add(m[1].replace(/&amp;/g, "&").replace(/&quot;/g, '"'));
658
+ }
659
+ return srcs;
660
+ }
661
+
367
662
  const ssrBundleCache = new Map(); // file -> { gen, mod }
663
+ const ssrInflight = new Map(); // file -> { gen, promise }
368
664
 
369
665
  async function loadModuleFresh(filePath) {
370
666
  if (typeof Bun === "undefined" || typeof Bun.build !== "function") {
@@ -372,21 +668,32 @@ async function loadModuleFresh(filePath) {
372
668
  }
373
669
  const cached = ssrBundleCache.get(filePath);
374
670
  if (cached && cached.gen === buildGeneration) return cached.mod;
671
+ return withInflight(ssrInflight, filePath, buildGeneration, () =>
672
+ buildSsrModule(filePath, buildGeneration),
673
+ );
674
+ }
375
675
 
376
- const result = await Bun.build({
377
- entrypoints: [filePath],
378
- target: "bun",
379
- plugins: [externalizeDepsPlugin],
380
- });
676
+ async function buildSsrModule(filePath, gen) {
677
+ const temps = [];
678
+ let result;
679
+ try {
680
+ result = await Bun.build({
681
+ entrypoints: [filePath],
682
+ target: "bun",
683
+ plugins: [makeUseCachePlugin(temps), makeClientIslandPlugin(temps), externalizeDepsPlugin],
684
+ });
685
+ } finally {
686
+ for (const t of temps) { try { unlinkSync(t); } catch {} }
687
+ }
381
688
  if (!result.success) {
382
689
  throw new Error(result.logs.map((l) => l.message).join("\n"));
383
690
  }
384
691
  const code = await result.outputs[0].text();
385
- const tmp = join(dirname(filePath), `.__sr_ssr_${buildGeneration}_${Math.random().toString(36).slice(2)}.mjs`);
692
+ const tmp = join(dirname(filePath), `.__sr_ssr_${gen}_${Math.random().toString(36).slice(2)}.mjs`);
386
693
  writeFileSync(tmp, code);
387
694
  try {
388
695
  const mod = await import(pathToFileURL(tmp).href);
389
- ssrBundleCache.set(filePath, { gen: buildGeneration, mod });
696
+ ssrBundleCache.set(filePath, { gen, mod });
390
697
  return mod;
391
698
  } finally {
392
699
  try { unlinkSync(tmp); } catch {}
@@ -424,11 +731,15 @@ async function compileRoute(urlPath) {
424
731
  }
425
732
 
426
733
  try {
427
- await loadModule(route.file, { bust: true });
428
- for (const layout of layouts) await loadModule(layout.file, { bust: true });
429
- if (notFoundFile) await loadModule(notFoundFile, { bust: true });
430
- if (errorFile) await loadModule(errorFile, { bust: true });
431
- if (loadingFile) await loadModule(loadingFile, { bust: true });
734
+ // Generation-keyed bundles: a save bumps the generation (fresh build), an
735
+ // unchanged file is a cache hit. The old `?t=` bust-imports created new
736
+ // module instances on every request they leaked (ESM registry entries
737
+ // are permanent) and, under Bun, didn't even pick up child-module edits.
738
+ await loadModuleFresh(route.file);
739
+ for (const layout of layouts) await loadModuleFresh(layout.file);
740
+ if (notFoundFile) await loadModuleFresh(notFoundFile);
741
+ if (errorFile) await loadModuleFresh(errorFile);
742
+ if (loadingFile) await loadModuleFresh(loadingFile);
432
743
  } catch (err) {
433
744
  compileTimings.set(urlPath, performance.now() - start);
434
745
  return { ok: false, reason: "compile_error", error: err, pageFile: route.file, layoutFile: layouts[0]?.file, params: route.params, segments };
@@ -452,14 +763,33 @@ function findAppGlobalsCss() {
452
763
  return null;
453
764
  }
454
765
 
766
+ // A require() rooted at the user's project, so Node module resolution walks the
767
+ // real node_modules tree (project → monorepo root → …) and finds packages
768
+ // wherever the package manager hoisted them. Critical for production builds
769
+ // (e.g. Vercel), where hard-coded ../../node_modules guesses miss the plugin
770
+ // and Tailwind silently doesn't compile — shipping an unstyled site.
771
+ let _projectRequire;
772
+ function projectRequire() {
773
+ if (!_projectRequire) _projectRequire = createRequire(join(cwd, "package.json"));
774
+ return _projectRequire;
775
+ }
776
+
455
777
  let postcssInstance = null;
456
778
  async function getPostcss() {
457
779
  if (postcssInstance !== null) return postcssInstance;
780
+ // 1) Resolve through the project's module graph.
781
+ try {
782
+ const entry = projectRequire().resolve("postcss");
783
+ const mod = await import(pathToFileURL(entry).href);
784
+ postcssInstance = { available: true, default: mod.default ?? mod, mod };
785
+ return postcssInstance;
786
+ } catch {}
787
+ // 2) Hard-coded fallbacks, then a bare import.
458
788
  try {
459
789
  const candidates = [
460
790
  join(cwd, "node_modules", "postcss"),
461
- join(cwd, "node_modules", "postcss", "lib", "postcss.mjs"),
462
- join(cwd, "node_modules", "postcss", "lib", "postcss.js"),
791
+ join(cwd, "..", "..", "node_modules", "postcss"),
792
+ join(cwd, "..", "..", "..", "node_modules", "postcss"),
463
793
  ];
464
794
  for (const candidate of candidates) {
465
795
  if (existsSync(candidate)) {
@@ -478,9 +808,19 @@ async function getPostcss() {
478
808
  }
479
809
 
480
810
  async function loadPostcssPluginByName(name) {
811
+ // 1) Node resolution from the project root — finds the plugin at any hoist level.
812
+ try {
813
+ const entryPath = projectRequire().resolve(name);
814
+ const mod = await import(pathToFileURL(entryPath).href);
815
+ const result = mod.default ?? mod;
816
+ logLine([` ${paint("dim", "css plugin loaded:")} ${paint("cyan", name)} ${paint("dim", "(resolved)")}`], 1);
817
+ return result;
818
+ } catch {}
819
+ // 2) Fallback: scan a few likely node_modules locations.
481
820
  const candidates = [
482
821
  join(cwd, "node_modules", name),
483
822
  join(cwd, "..", "..", "node_modules", name),
823
+ join(cwd, "..", "..", "..", "node_modules", name),
484
824
  ];
485
825
  for (const candidate of candidates) {
486
826
  const pkgJsonPath = join(candidate, "package.json");
@@ -503,7 +843,7 @@ async function loadPostcssPluginByName(name) {
503
843
  }
504
844
  }
505
845
  }
506
- logLine([` ${paint("dim", "css plugin not found:")} ${paint("yellow", name)}`], 1);
846
+ logLine([` ${paint("dim", "css plugin not found:")} ${paint("yellow", name)} ${paint("dim", "— is it in dependencies?")}`], 1);
507
847
  return null;
508
848
  }
509
849
 
@@ -697,13 +1037,13 @@ async function resolveMetadata(layoutFiles, pageFile, params, segments) {
697
1037
  const metas = [];
698
1038
  for (const layoutFile of layoutFiles || []) {
699
1039
  try {
700
- const mod = await loadModule(layoutFile, { bust: true });
1040
+ const mod = await loadModuleFresh(layoutFile);
701
1041
  if (mod.metadata) metas.push(mod.metadata);
702
1042
  } catch {}
703
1043
  }
704
1044
  if (pageFile) {
705
1045
  try {
706
- const mod = await loadModule(pageFile, { bust: true });
1046
+ const mod = await loadModuleFresh(pageFile);
707
1047
  if (mod.generateMetadata) {
708
1048
  const m = await mod.generateMetadata({ params: params || {}, searchParams: {} });
709
1049
  if (m) metas.push(m);
@@ -771,6 +1111,80 @@ export function isClientPage(file) {
771
1111
  return false;
772
1112
  }
773
1113
 
1114
+ // True when a file's leading directive prologue contains `'use server'`.
1115
+ // (Component-level "use server" is NOT supported — swift-rust does classic SSR +
1116
+ // client islands, not React Server Components / Flight. The only legitimate home
1117
+ // for 'use server' is a route action.ts file, which is loaded by the router, not
1118
+ // imported into a page's render tree.)
1119
+ function hasServerDirective(file) {
1120
+ try {
1121
+ for (const raw of readFileSync(file, "utf8").split("\n")) {
1122
+ const t = raw.trim();
1123
+ if (!t || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*")) continue;
1124
+ if (/^["']use server["'];?$/.test(t)) return true;
1125
+ if (/^["']use [a-z]+["'];?$/.test(t)) continue;
1126
+ return false;
1127
+ }
1128
+ } catch {}
1129
+ return false;
1130
+ }
1131
+
1132
+ // Walk a page's transitive *relative* import graph looking for a module that
1133
+ // declares `'use server'`. Returns the offending file (or null). We only follow
1134
+ // ./ and ../ specifiers — that's where a developer's own components live, and it
1135
+ // keeps the scan cheap and free of node_modules false positives.
1136
+ const RELATIVE_EXTS = [".tsx", ".ts", ".jsx", ".js", ".mjs"];
1137
+ function findServerComponentInClientGraph(entryFile, seen = new Set()) {
1138
+ const resolved = resolve(entryFile);
1139
+ if (seen.has(resolved)) return null;
1140
+ seen.add(resolved);
1141
+ let src;
1142
+ try {
1143
+ src = readFileSync(resolved, "utf8");
1144
+ } catch {
1145
+ return null;
1146
+ }
1147
+ // The entry (the page) is allowed to be 'use client'; we only flag *imports*.
1148
+ for (const m of src.matchAll(/\b(?:import|export)\b[^'"]*['"](\.[^'"]+)['"]/g)) {
1149
+ const spec = m[1];
1150
+ const base = resolve(dirname(resolved), spec);
1151
+ let target = null;
1152
+ const candidates = [base, ...RELATIVE_EXTS.map((e) => base + e), ...RELATIVE_EXTS.map((e) => join(base, "index" + e))];
1153
+ for (const c of candidates) {
1154
+ try {
1155
+ if (statSync(c).isFile()) { target = c; break; }
1156
+ } catch {}
1157
+ }
1158
+ if (!target) continue;
1159
+ if (hasServerDirective(target)) return target;
1160
+ const nested = findServerComponentInClientGraph(target, seen);
1161
+ if (nested) return nested;
1162
+ }
1163
+ return null;
1164
+ }
1165
+
1166
+ // Throw a clear, actionable error when a 'use client' page pulls a component
1167
+ // that declares 'use server'. Replaces the old silent no-op (the component used
1168
+ // to be imported but its directive ignored, so nothing ran and nothing warned).
1169
+ export function assertNoServerComponentInClientPage(pageFile) {
1170
+ if (!isClientPage(pageFile)) return;
1171
+ const offender = findServerComponentInClientGraph(pageFile);
1172
+ if (offender) {
1173
+ const rel = (f) => relative(cwd, f);
1174
+ throw new Error(
1175
+ `Unsupported: '${rel(offender)}' declares 'use server' but is imported into the ` +
1176
+ `'use client' page '${rel(pageFile)}'.\n\n` +
1177
+ `swift-rust uses classic SSR + client islands, not React Server Components, so a ` +
1178
+ `'use server' component inside a client tree cannot run on the server — React forbids ` +
1179
+ `importing a Server Component into a Client Component.\n\n` +
1180
+ `Fix one of these:\n` +
1181
+ ` • Remove the 'use server' directive — the component will render normally inside the client page.\n` +
1182
+ ` • Move server-only work into a route loader (loader.ts) or action (action.ts) and pass the data down as props.\n` +
1183
+ ` • Keep the page server-rendered (drop 'use client' on the page) and mark only the interactive leaf with 'use client'.`,
1184
+ );
1185
+ }
1186
+ }
1187
+
774
1188
  const VALID_RUNTIMES = new Set(["bun", "edge", "node", "worker"]);
775
1189
 
776
1190
  // Detect a `'use bun' | 'use edge' | 'use node'` directive among a file's
@@ -805,15 +1219,24 @@ export function hasUseDirective(file, name) {
805
1219
  return false;
806
1220
  }
807
1221
 
1222
+ const islandBundleInflight = new Map();
808
1223
  async function buildIslandBundle(pageFile) {
809
1224
  if (typeof Bun === "undefined" || typeof Bun.build !== "function") {
810
1225
  throw new Error("client islands require the Bun runtime");
811
1226
  }
1227
+ assertNoServerComponentInClientPage(pageFile);
812
1228
  const cached = islandBundleCache.get(pageFile);
813
1229
  if (cached && cached.gen === buildGeneration) return cached.code;
814
-
1230
+ return withInflight(islandBundleInflight, pageFile, buildGeneration, () =>
1231
+ buildIslandBundleNow(pageFile),
1232
+ );
1233
+ }
1234
+ async function buildIslandBundleNow(pageFile) {
815
1235
  // Temp hydration entry, written next to the page so module resolution works.
816
- const entryPath = join(dirname(pageFile), `.__sr_island_${process.pid}_${Date.now()}.js`);
1236
+ const entryPath = join(
1237
+ dirname(pageFile),
1238
+ `.__sr_island_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2)}.js`,
1239
+ );
817
1240
  const entry = `import { createRoot } from "react-dom/client";
818
1241
  import { createElement } from "react";
819
1242
  import Page from ${JSON.stringify(pageFile)};
@@ -1036,9 +1459,39 @@ async function readMergedConfig(chain, route) {
1036
1459
  // A route is "dynamic" (emitted as a request-time function) when it explicitly
1037
1460
  // opts into a runtime via a directive, config.ts/edge/worker, or config.dynamic.
1038
1461
  config.dynamic = Boolean(directive) || explicitRuntime || config.dynamic === true;
1462
+
1463
+ // `use static` (page directive) or config.static: a hard guarantee that the
1464
+ // route is purely static — no request-time/dynamic behavior. We fail loudly on
1465
+ // any conflict so the guarantee can't silently rot, then force the route static
1466
+ // and tag it for aggressive caching. (The actual prerender happens in build;
1467
+ // here we enforce + emit the cache contract.)
1468
+ const wantsStatic =
1469
+ (route?.file && hasUseDirective(route.file, "static")) ||
1470
+ collectRouteFiles(chain, "config").some(({ file }) => hasUseDirective(file, "static")) ||
1471
+ config.static === true;
1472
+ if (wantsStatic) {
1473
+ const reasons = [];
1474
+ if (directive) reasons.push(`a 'use ${directive}' runtime directive (declares a request-time runtime)`);
1475
+ else if (explicitRuntime) reasons.push("a runtime set by config.ts / edge.ts / worker.ts");
1476
+ if (config.dynamic === true && !directive && !explicitRuntime) reasons.push("config.dynamic = true");
1477
+ if (collectRouteFiles(chain, "action").length) reasons.push("an action.ts (mutations are dynamic)");
1478
+ if (reasons.length) {
1479
+ const rel = route?.file ? relative(cwd, route.file) : "(route)";
1480
+ throw new Error(
1481
+ `Route '${rel}' is marked 'use static' but is dynamic: it has ${reasons.join(", ")}.\n\n` +
1482
+ `A 'use static' route is prerendered and CDN-cached, so it can't use request-time features. Fix one:\n` +
1483
+ ` • Remove 'use static' if the route really needs to be dynamic.\n` +
1484
+ ` • Drop the runtime directive / dynamic config / action.ts so the route can be fully static.\n` +
1485
+ ` • For periodic refresh, keep 'use static' and add a revalidate.ts (ISR), which stays static + cached.`,
1486
+ );
1487
+ }
1488
+ config.dynamic = false;
1489
+ config.static = true;
1490
+ }
1039
1491
  config.headers = {
1040
1492
  "x-swift-rust-runtime": runtime,
1041
1493
  ...(config.dynamic ? { "x-swift-rust-dynamic": "1" } : {}),
1494
+ ...(config.static ? { "x-swift-rust-render": "static" } : {}),
1042
1495
  ...config.headers,
1043
1496
  };
1044
1497
  return config;
@@ -1416,6 +1869,11 @@ async function renderRoute(urlPath, req) {
1416
1869
  });
1417
1870
 
1418
1871
  const clientPage = isClientPage(route.file);
1872
+ if (clientPage) {
1873
+ // Loud failure (was a silent no-op): a 'use server' component imported into
1874
+ // a 'use client' page can't run on the server in this SSR+islands model.
1875
+ assertNoServerComponentInClientPage(route.file);
1876
+ }
1419
1877
  let tree = React.createElement(Page, { params: paramsProxy });
1420
1878
  if (clientPage) {
1421
1879
  // Wrap in a hydration root so the client bundle can mount into it.
@@ -1493,7 +1951,7 @@ async function renderRoute(urlPath, req) {
1493
1951
  if (errorFile) {
1494
1952
  try {
1495
1953
  const React = await import("react");
1496
- const errorMod = await loadModule(errorFile, { bust: true });
1954
+ const errorMod = await loadModuleFresh(errorFile);
1497
1955
  const ErrorBoundary = errorMod.default ?? errorMod.ErrorBoundary ?? errorMod.error;
1498
1956
  if (ErrorBoundary) {
1499
1957
  const html = await renderToStringCompat(React.createElement(ErrorBoundary, { error: err }));
@@ -1506,7 +1964,7 @@ async function renderRoute(urlPath, req) {
1506
1964
  if (recoveryFile) {
1507
1965
  try {
1508
1966
  const React = await import("react");
1509
- const mod = await loadModule(recoveryFile, { bust: true });
1967
+ const mod = await loadModuleFresh(recoveryFile);
1510
1968
  const Recovery = mod.default ?? mod.ErrorRecovery;
1511
1969
  if (Recovery) {
1512
1970
  const html = await renderToStringCompat(
@@ -1523,7 +1981,7 @@ async function renderRoute(urlPath, req) {
1523
1981
  if (globalErrorFile) {
1524
1982
  try {
1525
1983
  const React = await import("react");
1526
- const mod = await loadModule(globalErrorFile, { bust: true });
1984
+ const mod = await loadModuleFresh(globalErrorFile);
1527
1985
  const GlobalError = mod.default ?? mod.GlobalError;
1528
1986
  if (GlobalError) {
1529
1987
  const inner = await renderToStringCompat(
@@ -1611,14 +2069,14 @@ async function renderNotFound(segments) {
1611
2069
  try {
1612
2070
  const React = await import("react");
1613
2071
  const layouts = findLayoutsFor(segments || []);
1614
- const mod = await loadModule(notFoundFile, { bust: true });
2072
+ const mod = await loadModuleFresh(notFoundFile);
1615
2073
  const NotFound = mod.default ?? mod.NotFound ?? mod.notFound;
1616
2074
  if (!NotFound) {
1617
2075
  return { status: 404, html: null, error: null, segments };
1618
2076
  }
1619
2077
  let tree = React.createElement(NotFound);
1620
2078
  for (let i = layouts.length - 1; i >= 0; i--) {
1621
- const layoutMod = await loadModule(layouts[i].file, { bust: true });
2079
+ const layoutMod = await loadModuleFresh(layouts[i].file);
1622
2080
  const Layout = layoutMod.default ?? layoutMod.Layout ?? layoutMod.layout;
1623
2081
  if (Layout) tree = React.createElement(Layout, null, tree);
1624
2082
  }
@@ -1716,83 +2174,219 @@ function readNavigatorClient() {
1716
2174
  }
1717
2175
  }
1718
2176
 
1719
- function shouldIgnoreFile(filename) {
1720
- if (!filename) return true;
1721
- const base = filename.split(sep).pop();
1722
- if (!base) return true;
1723
- if (base.startsWith(".")) return true;
2177
+ const WATCH_IGNORE_DIRS = new Set([
2178
+ "node_modules",
2179
+ "dist",
2180
+ "build",
2181
+ "out",
2182
+ "coverage",
2183
+ "target",
2184
+ ]);
2185
+
2186
+ // Ignore anything inside a dep/build dir, any dot-prefixed path segment (which
2187
+ // also covers our own .__sr_* build temps and dirs like .git/.turbo/.next),
2188
+ // and editor scratch suffixes.
2189
+ function isIgnoredWatchPath(full) {
2190
+ const rel = relative(cwd, full);
2191
+ if (!rel || rel.startsWith("..")) return true;
2192
+ const parts = rel.split(sep);
2193
+ for (const p of parts) {
2194
+ if (!p || p.startsWith(".")) return true;
2195
+ if (WATCH_IGNORE_DIRS.has(p)) return true;
2196
+ }
2197
+ const base = parts[parts.length - 1];
1724
2198
  if (base.endsWith(".bak") || base.endsWith(".tmp") || base.endsWith("~")) return true;
1725
- if (base === "node_modules") return true;
1726
2199
  return false;
1727
2200
  }
1728
2201
 
2202
+ const SYNTAX_CHECK_LOADERS = { ".ts": "ts", ".tsx": "tsx", ".js": "js", ".jsx": "jsx", ".mjs": "js" };
2203
+
2204
+ // Quick transpile of the changed sources before telling the browser to reload.
2205
+ // Editors save in stages (truncate+write, format-on-save), so the first FS
2206
+ // event often fires mid-write; compiling that state used to navigate every tab
2207
+ // into a dead 500 page. A failed check retries once after a beat (to skate
2208
+ // over partial writes); a persistent failure is a real syntax error.
2209
+ async function findSyntaxError(files) {
2210
+ if (typeof Bun === "undefined" || typeof Bun.Transpiler !== "function") return null;
2211
+ for (const file of files) {
2212
+ const loader = SYNTAX_CHECK_LOADERS[extname(file)];
2213
+ if (!loader) continue;
2214
+ for (let attempt = 0; ; attempt++) {
2215
+ let src;
2216
+ try {
2217
+ src = readFileSync(file, "utf8");
2218
+ } catch {
2219
+ break; // deleted — nothing to check
2220
+ }
2221
+ try {
2222
+ new Bun.Transpiler({ loader }).transformSync(src);
2223
+ break;
2224
+ } catch (err) {
2225
+ if (attempt >= 1) {
2226
+ const msg = err?.message || String(err);
2227
+ return `${relative(cwd, file)}: ${msg}`;
2228
+ }
2229
+ await new Promise((r) => setTimeout(r, 150));
2230
+ }
2231
+ }
2232
+ }
2233
+ return null;
2234
+ }
2235
+
2236
+ function broadcastHmr(data) {
2237
+ const payload = JSON.stringify({ event: "change", data });
2238
+ for (const send of hmrClients) {
2239
+ try {
2240
+ send(payload);
2241
+ } catch {}
2242
+ }
2243
+ }
2244
+
1729
2245
  function setupWatcher() {
1730
2246
  if (!existsSync(APP_DIR)) return;
1731
2247
  const watchers = new Map();
2248
+ let recursiveMode = false;
1732
2249
 
1733
- const IGNORE_DIRS = new Set([
1734
- "node_modules",
1735
- "dist",
1736
- "build",
1737
- "out",
1738
- "coverage",
1739
- "target",
1740
- ".git",
1741
- ".vercel",
1742
- ".turbo",
1743
- ".swift-rust",
1744
- ".next",
1745
- ]);
2250
+ // ── Debounced batch flush ──────────────────────────────────────────────────
2251
+ // All events funnel into one pending set; a flush invalidates caches once,
2252
+ // syntax-checks the batch, and broadcasts a single reload (or error).
2253
+ const pendingFiles = new Set();
2254
+ let pendingWildcard = false; // event with no usable filename → invalidate broadly
2255
+ let flushTimer = null;
2256
+ let flushing = false;
2257
+ let lastFlushErrored = false;
2258
+
2259
+ function scheduleFlush() {
2260
+ if (flushTimer) clearTimeout(flushTimer);
2261
+ flushTimer = setTimeout(() => {
2262
+ flushTimer = null;
2263
+ void flush();
2264
+ }, 80);
2265
+ }
2266
+
2267
+ function noteChange(full) {
2268
+ pendingFiles.add(full);
2269
+ scheduleFlush();
2270
+ }
2271
+
2272
+ function noteWildcard() {
2273
+ pendingWildcard = true;
2274
+ scheduleFlush();
2275
+ }
2276
+
2277
+ async function flush() {
2278
+ if (flushing) {
2279
+ scheduleFlush();
2280
+ return;
2281
+ }
2282
+ flushing = true;
2283
+ try {
2284
+ const files = [...pendingFiles];
2285
+ pendingFiles.clear();
2286
+ const wildcard = pendingWildcard;
2287
+ pendingWildcard = false;
2288
+ if (!files.length && !wildcard) return;
2289
+
2290
+ buildGeneration++;
2291
+ for (const full of files) {
2292
+ logEvent("change", full);
2293
+ bustCache(full);
2294
+ if (full.endsWith(`${sep}layout.tsx`) || full.endsWith(`${sep}layout.ts`)) {
2295
+ fontsScannedFromLayout = false;
2296
+ GOOGLE_FONT_FAMILIES.clear();
2297
+ }
2298
+ }
2299
+ // Any source edit can introduce new utility classes (Tailwind scans the
2300
+ // source tree), so re-run the CSS pipeline on every flush — not only when
2301
+ // globals.css itself changes. mtime caching keeps subsequent requests hot.
2302
+ globalsCssCache = { file: null, mtime: 0, css: "" };
2303
+ if (wildcard) {
2304
+ fontsScannedFromLayout = false;
2305
+ GOOGLE_FONT_FAMILIES.clear();
2306
+ }
2307
+
2308
+ const syntaxError = await findSyntaxError(files);
2309
+ if (syntaxError) {
2310
+ // Overlay on the live page instead of reloading into a dead 500 —
2311
+ // page state survives, and the next clean save reloads normally.
2312
+ lastFlushErrored = true;
2313
+ logLine([` ${paint("red", "✗")} ${paint("red", syntaxError.split("\n")[0])}`]);
2314
+ broadcastHmr({ type: "error", message: syntaxError });
2315
+ return;
2316
+ }
2317
+ if (lastFlushErrored) {
2318
+ lastFlushErrored = false;
2319
+ broadcastHmr({ type: "ok" });
2320
+ }
2321
+ broadcastHmr({ type: "reload", file: relative(cwd, files[0] ?? cwd), at: Date.now() });
2322
+ logHmr(files[0] ?? cwd);
2323
+ } finally {
2324
+ flushing = false;
2325
+ if (pendingFiles.size || pendingWildcard) scheduleFlush();
2326
+ }
2327
+ }
2328
+
2329
+ function handleEvent(full) {
2330
+ try {
2331
+ if (statSync(full).isDirectory()) {
2332
+ // New directory: in fallback mode start watching it. Files may have
2333
+ // been created inside it before the watcher attached, so invalidate
2334
+ // broadly instead of silently dropping the event (the old behavior —
2335
+ // one dropped event meant stale renders until the server restarted).
2336
+ if (!recursiveMode) walk(full);
2337
+ noteWildcard();
2338
+ return;
2339
+ }
2340
+ } catch {}
2341
+ noteChange(full);
2342
+ }
2343
+
2344
+ function onWatchEvent(baseDir) {
2345
+ return (event, filename) => {
2346
+ if (!filename) {
2347
+ // macOS/FSEvents can coalesce events and drop the filename. Treat it
2348
+ // as "something changed" rather than ignoring it.
2349
+ noteWildcard();
2350
+ return;
2351
+ }
2352
+ const full = join(baseDir, filename.toString());
2353
+ if (isIgnoredWatchPath(full)) return;
2354
+ handleEvent(full);
2355
+ };
2356
+ }
2357
+
2358
+ // Fallback: one watcher per directory (non-recursive platforms).
1746
2359
  function walk(dir) {
1747
2360
  if (watchers.has(dir)) return;
2361
+ // A probe child path tells us whether the directory itself is ignored
2362
+ // (cwd resolves to rel "x", which passes).
2363
+ if (isIgnoredWatchPath(join(dir, "x"))) return;
1748
2364
  try {
1749
2365
  const entries = readdirSync(dir, { withFileTypes: true });
1750
2366
  for (const e of entries) {
1751
- if (e.isDirectory() && !e.name.startsWith(".") && !IGNORE_DIRS.has(e.name)) {
2367
+ if (e.isDirectory() && !e.name.startsWith(".") && !WATCH_IGNORE_DIRS.has(e.name)) {
1752
2368
  walk(join(dir, e.name));
1753
2369
  }
1754
2370
  }
1755
- let debounce;
1756
- const w = fsWatch(dir, (event, filename) => {
1757
- if (shouldIgnoreFile(filename)) return;
1758
- const full = join(dir, filename.toString());
1759
- if (debounce) clearTimeout(debounce);
1760
- debounce = setTimeout(() => {
1761
- try {
1762
- const s = statSync(full);
1763
- if (s.isDirectory()) {
1764
- walk(full);
1765
- return;
1766
- }
1767
- } catch {}
1768
- logEvent("change", full);
1769
- buildGeneration++;
1770
- bustCache(full);
1771
- if (full.includes(`${sep}globals.${"css"}`)) {
1772
- globalsCssCache = { file: null, mtime: 0, css: "" };
1773
- }
1774
- if (full.endsWith(`${sep}layout.tsx`) || full.endsWith(`${sep}layout.ts`)) {
1775
- fontsScannedFromLayout = false;
1776
- GOOGLE_FONT_FAMILIES.clear();
1777
- }
1778
- const payload = { type: "reload", file: relative(cwd, full), at: Date.now() };
1779
- for (const send of hmrClients) {
1780
- try {
1781
- send(JSON.stringify({ event: "change", data: payload }));
1782
- } catch {}
1783
- }
1784
- logHmr(full);
1785
- }, 30);
1786
- });
2371
+ const w = fsWatch(dir, onWatchEvent(dir));
1787
2372
  watchers.set(dir, w);
1788
2373
  } catch (err) {
1789
2374
  logLine([` ${paint("dim", "watch error:")} ${paint("red", err.message)}`], 1);
1790
2375
  }
1791
2376
  }
2377
+
1792
2378
  // Watch the WHOLE project (minus dep/build dirs) so a change to *any* source
1793
2379
  // file — pages, components, lib, hooks, content, config, wherever — triggers
1794
- // a recompile + reload. No more "edit this folder and nothing happens".
1795
- walk(cwd);
2380
+ // a recompile + reload. Prefer a single recursive watcher (macOS, Windows,
2381
+ // Linux on Node 20+/Bun): hundreds of per-directory FSEvents watchers could
2382
+ // hit OS limits and die silently, leaving saves unnoticed until a restart.
2383
+ try {
2384
+ const w = fsWatch(cwd, { recursive: true }, onWatchEvent(cwd));
2385
+ watchers.set(cwd, w);
2386
+ recursiveMode = true;
2387
+ } catch {
2388
+ walk(cwd);
2389
+ }
1796
2390
  }
1797
2391
 
1798
2392
  const networkUrls = [];
@@ -2156,6 +2750,55 @@ async function handleFetch(req) {
2156
2750
  return new Response("// island not found", { status: 404, headers: { "Content-Type": "application/javascript" } });
2157
2751
  }
2158
2752
 
2753
+ // Per-component client-island bundle (self-mounting; hydrates its markers).
2754
+ if (pathname === "/_swift-rust/island-comp.js") {
2755
+ const p = url.searchParams.get("p");
2756
+ if (p && existsSync(p)) {
2757
+ try {
2758
+ const code = await buildComponentIslandBundle(p);
2759
+ return new Response(code, {
2760
+ headers: { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-cache" },
2761
+ });
2762
+ } catch (e) {
2763
+ logError(e, `component island bundle failed for ${p}`);
2764
+ return new Response(`/* island build error: ${String(e?.message || e).replace(/\*\//g, "* /")} */`, {
2765
+ status: 500,
2766
+ headers: { "Content-Type": "application/javascript; charset=utf-8" },
2767
+ });
2768
+ }
2769
+ }
2770
+ return new Response("// island not found", { status: 404, headers: { "Content-Type": "application/javascript" } });
2771
+ }
2772
+
2773
+ // On-demand revalidation: POST /_swift-rust/revalidate { tag?, path? }
2774
+ // Purges the shared data cache (globalThis.__SR_CACHE__) by tag or path so
2775
+ // webhooks / mutations can invalidate without a redeploy.
2776
+ if (pathname === "/_swift-rust/revalidate") {
2777
+ if (req.method !== "POST") {
2778
+ return new Response(JSON.stringify({ error: "use POST" }), {
2779
+ status: 405,
2780
+ headers: { "Content-Type": "application/json" },
2781
+ });
2782
+ }
2783
+ let body = {};
2784
+ try { body = await req.json(); } catch {}
2785
+ const tag = body.tag ?? url.searchParams.get("tag");
2786
+ const path = body.path ?? url.searchParams.get("path");
2787
+ const store = globalThis.__SR_CACHE__;
2788
+ let purged = 0;
2789
+ if (store?.store) {
2790
+ for (const [k, v] of store.store) {
2791
+ if ((tag && v.tags?.includes(tag)) || (path && k.includes(path))) {
2792
+ store.store.delete(k);
2793
+ purged++;
2794
+ }
2795
+ }
2796
+ if (tag) store.purged?.add(`tag:${tag}`);
2797
+ if (path) store.purged?.add(`path:${path}`);
2798
+ }
2799
+ return Response.json({ revalidated: true, tag: tag ?? null, path: path ?? null, purged });
2800
+ }
2801
+
2159
2802
  if (pathname.startsWith("/_swift-rust/")) {
2160
2803
  return new Response("Not found", { status: 404 });
2161
2804
  }
@@ -2364,6 +3007,12 @@ async function handleFetch(req) {
2364
3007
  const src = `/_swift-rust/island.js?p=${encodeURIComponent(renderResult.pageFile)}`;
2365
3008
  doc = doc.replace("</body>", `<script type="module" src="${src}"></script>\n</body>`);
2366
3009
  }
3010
+ // Component-level "use client" islands: one self-mounting module per unique
3011
+ // source referenced by a marker in the rendered HTML.
3012
+ for (const islandSrc of collectIslandSources(doc)) {
3013
+ const s = `/_swift-rust/island-comp.js?p=${encodeURIComponent(islandSrc)}`;
3014
+ doc = doc.replace("</body>", `<script type="module" src="${s}"></script>\n</body>`);
3015
+ }
2367
3016
  // pending.tsx → hidden overlay the client navigator reveals while a
2368
3017
  // navigation is in flight (see runtime/navigator.js).
2369
3018
  const pendingOverlay = await renderPendingOverlay(renderResult.segments);
@@ -2380,12 +3029,28 @@ async function handleFetch(req) {
2380
3029
  const json = JSON.stringify(transitionCfg).replace(/</g, "\\u003c");
2381
3030
  doc = doc.replace("</body>", `<script>window.__SR_TRANSITION__=${json}</script>\n</body>`);
2382
3031
  }
3032
+ // `use static` guarantee: a static route must not perform request-time work.
3033
+ // Setting cookies during render is the one dynamic signal we can catch at
3034
+ // runtime — fail loudly rather than silently shipping a wrong cache header.
3035
+ if (renderResult.config?.static && (renderResult.setCookies || []).length) {
3036
+ const e = new Error(
3037
+ "A 'use static' route set a cookie during render, which is a dynamic action. " +
3038
+ "Remove 'use static' or stop setting cookies on this route.",
3039
+ );
3040
+ logError(e, "use static violation");
3041
+ return new Response(errorOverlayHTML(e.message, e.stack || ""), {
3042
+ status: 500,
3043
+ headers: { "Content-Type": "text/html; charset=utf-8" },
3044
+ });
3045
+ }
2383
3046
  const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
2384
3047
  for (const c of renderResult.setCookies || []) headers.append("Set-Cookie", c);
2385
3048
  // config.ts headers
2386
3049
  for (const [k, v] of Object.entries(renderResult.config?.headers || {})) headers.set(k, String(v));
2387
- // revalidate.ts → Cache-Control + cache tags
2388
- const cc = cacheControlFromPlan(renderResult.revalidatePlan);
3050
+ // revalidate.ts → Cache-Control + cache tags. A 'use static' route with no
3051
+ // explicit revalidate plan gets a long-lived, revalidatable CDN cache.
3052
+ let cc = cacheControlFromPlan(renderResult.revalidatePlan);
3053
+ if (!cc && renderResult.config?.static) cc = "public, max-age=0, s-maxage=31536000, stale-while-revalidate";
2389
3054
  if (cc) headers.set("Cache-Control", cc);
2390
3055
  const tags = renderResult.revalidatePlan?.tags || renderResult.revalidatePlan?.invalidate;
2391
3056
  if (Array.isArray(tags) && tags.length) headers.set("x-vercel-cache-tags", tags.join(","));
@@ -2404,7 +3069,7 @@ async function handleApiRoute(req, segments, method, reqStart) {
2404
3069
  }
2405
3070
  const handlerName = method.toUpperCase();
2406
3071
  try {
2407
- const mod = await loadModule(route.file, { bust: true });
3072
+ const mod = await loadModuleFresh(route.file);
2408
3073
  const handler = mod[handlerName] || mod[method.toLowerCase()];
2409
3074
  if (typeof handler !== "function") {
2410
3075
  const total = performance.now() - reqStart;