swift-rust 1.2.1 → 1.4.0

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.
@@ -2,9 +2,27 @@
2
2
  import { existsSync, statSync, readFileSync, readdirSync, writeFileSync, unlinkSync, watch as fsWatch } from "node:fs";
3
3
  import { join, resolve, extname, relative, dirname, basename, sep } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
+ import { createRequire } from "node:module";
5
6
  import { performance } from "node:perf_hooks";
6
7
  import { errorOverlayHTML as renderErrorOverlay } from "./error-overlay.mjs";
7
8
 
9
+ // Locate the bundled local fonts. Prefers the installed @swift-rust/font
10
+ // package (so it works when swift-rust is installed from npm), falling back to
11
+ // the monorepo source dir during local development.
12
+ let _localFontDir;
13
+ export function resolveLocalFontDir() {
14
+ if (_localFontDir) return _localFontDir;
15
+ const candidates = [];
16
+ try {
17
+ const req = createRequire(import.meta.url);
18
+ const pkg = req.resolve("@swift-rust/font/package.json");
19
+ candidates.push(join(dirname(pkg), "src", "local"), join(dirname(pkg), "dist", "local"));
20
+ } catch {}
21
+ candidates.push(join(import.meta.dirname, "..", "..", "..", "packages", "font", "src", "local"));
22
+ _localFontDir = candidates.find((d) => existsSync(d)) ?? candidates[candidates.length - 1];
23
+ return _localFontDir;
24
+ }
25
+
8
26
  const cwd = process.cwd();
9
27
  const args = process.argv.slice(2);
10
28
 
@@ -590,8 +608,12 @@ async function scanFontsFromLayouts() {
590
608
  }
591
609
 
592
610
  function buildGoogleFontsLinkTag() {
593
- if (GOOGLE_FONT_FAMILIES.size === 0) return "";
594
- const families = Array.from(GOOGLE_FONT_FAMILIES)
611
+ // Layout-scanned families + families any factory registered during this
612
+ // render (globalThis.__SR_GOOGLE_FONTS__), so page-only fonts get a <link>.
613
+ const registered = globalThis.__SR_GOOGLE_FONTS__ instanceof Set ? globalThis.__SR_GOOGLE_FONTS__ : null;
614
+ const all = registered ? new Set([...GOOGLE_FONT_FAMILIES, ...registered]) : GOOGLE_FONT_FAMILIES;
615
+ if (all.size === 0) return "";
616
+ const families = Array.from(all)
595
617
  .map((f) => `family=${encodeURIComponent(f).replace(/%20/g, "+")}:wght@300..900`)
596
618
  .join("&");
597
619
  return `<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -740,7 +762,44 @@ export function isClientPage(file) {
740
762
  for (const raw of readFileSync(file, "utf8").split("\n")) {
741
763
  const t = raw.trim();
742
764
  if (!t || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*")) continue;
743
- return /^["']use client["'];?$/.test(t);
765
+ if (/^["']use client["'];?$/.test(t)) return true;
766
+ // allow other leading directives (e.g. 'use bun') before 'use client'
767
+ if (/^["']use [a-z]+["'];?$/.test(t)) continue;
768
+ return false;
769
+ }
770
+ } catch {}
771
+ return false;
772
+ }
773
+
774
+ const VALID_RUNTIMES = new Set(["bun", "edge", "node", "worker"]);
775
+
776
+ // Detect a `'use bun' | 'use edge' | 'use node'` directive among a file's
777
+ // leading string-literal directives (alongside an optional 'use client').
778
+ export function detectRuntimeDirective(file) {
779
+ try {
780
+ for (const raw of readFileSync(file, "utf8").split("\n")) {
781
+ const t = raw.trim();
782
+ if (!t || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*")) continue;
783
+ const m = t.match(/^["']use (bun|edge|node|worker)["'];?$/);
784
+ if (m) return m[1];
785
+ if (/^["']use [a-z]+["'];?$/.test(t)) continue; // other directive, keep scanning
786
+ return null; // first non-directive line ends the directive prologue
787
+ }
788
+ } catch {}
789
+ return null;
790
+ }
791
+
792
+ // True if a file declares a `'use <name>'` directive among its leading
793
+ // string-literal directives (coexists with 'use client' / 'use bun' / etc.).
794
+ export function hasUseDirective(file, name) {
795
+ try {
796
+ const re = new RegExp(`^["']use ${name}["'];?$`);
797
+ for (const raw of readFileSync(file, "utf8").split("\n")) {
798
+ const t = raw.trim();
799
+ if (!t || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*")) continue;
800
+ if (re.test(t)) return true;
801
+ if (/^["']use [a-z]+["'];?$/.test(t)) continue;
802
+ return false;
744
803
  }
745
804
  } catch {}
746
805
  return false;
@@ -883,7 +942,7 @@ function buildRouteCtx(req, url, params, searchParams) {
883
942
  cookies,
884
943
  params,
885
944
  searchParams,
886
- runtime: "node",
945
+ runtime: "bun",
887
946
  locals: { get: (k) => localsMap.get(k), set: (k, v) => localsMap.set(k, v) },
888
947
  request: req,
889
948
  __setCookies: setCookies,
@@ -903,19 +962,85 @@ function applyControl(c) {
903
962
  throw e;
904
963
  }
905
964
 
906
- /** Merge config.ts along the chain (inner overrides outer). */
907
- async function readMergedConfig(chain) {
965
+ // swift-rust.config.json (read once) — provides the project default runtime.
966
+ let _globalConfig;
967
+ function loadGlobalConfig() {
968
+ if (_globalConfig !== undefined) return _globalConfig;
969
+ try {
970
+ _globalConfig = existsSync(SWIFT_RUST_CONFIG) ? JSON.parse(readFileSync(SWIFT_RUST_CONFIG, "utf8")) : {};
971
+ } catch {
972
+ _globalConfig = {};
973
+ }
974
+ return _globalConfig;
975
+ }
976
+
977
+ // Resolve a `'use bun'|'use edge'|'use node'` directive for a route's tree:
978
+ // the page wins, then layouts innermost → outermost.
979
+ function resolveTreeRuntimeDirective(route, chain) {
980
+ if (route?.file) {
981
+ const d = detectRuntimeDirective(route.file);
982
+ if (d) return d;
983
+ }
984
+ const layouts = collectRouteFiles(chain, "layout"); // outer → inner
985
+ for (let i = layouts.length - 1; i >= 0; i--) {
986
+ const d = detectRuntimeDirective(layouts[i].file);
987
+ if (d) return d;
988
+ }
989
+ return null;
990
+ }
991
+
992
+ // guard.ts runs only when explicitly opted in: a `'use guard'` directive in the
993
+ // route tree (page/layout/config), config.ts `{ guard: true }`, or the global
994
+ // `autoApplyGuard: true`. Default is off (autoApplyGuard defaults to false).
995
+ function shouldRunGuard(route, chain, config) {
996
+ if (route?.file && hasUseDirective(route.file, "guard")) return true;
997
+ for (const { file } of collectRouteFiles(chain, "layout")) {
998
+ if (hasUseDirective(file, "guard")) return true;
999
+ }
1000
+ for (const { file } of collectRouteFiles(chain, "config")) {
1001
+ if (hasUseDirective(file, "guard")) return true;
1002
+ }
1003
+ if (config?.guard === true) return true;
1004
+ if (loadGlobalConfig().autoApplyGuard === true) return true;
1005
+ return false;
1006
+ }
1007
+
1008
+ /** Merge config.ts along the chain (inner overrides outer) + resolve runtime. */
1009
+ async function readMergedConfig(chain, route) {
908
1010
  let config = {};
909
1011
  for (const { file } of collectRouteFiles(chain, "config")) {
910
1012
  const mod = await loadModuleFresh(file);
911
1013
  const c = mod.config ?? mod.default;
912
1014
  if (c && typeof c === "object") config = { ...config, ...c, headers: { ...config.headers, ...c.headers } };
913
1015
  }
914
- // edge.ts / worker.ts force a runtime.
1016
+ // edge.ts / worker.ts force a runtime (file-based, like a directive).
915
1017
  const edge = await collectFirst(chain, "edge");
916
1018
  if (edge && (edge.edge || edge.default)) config.runtime = "edge";
917
1019
  const worker = await collectFirst(chain, "worker");
918
1020
  if (worker && (worker.default || worker.bindings)) config.runtime = "worker";
1021
+
1022
+ // Runtime resolution (highest priority first):
1023
+ // 'use bun|edge|node' directive → config.ts / edge.ts / worker.ts
1024
+ // → swift-rust.config.json "runtime" → default "bun".
1025
+ const explicitRuntime = config.runtime != null; // set by config.ts / edge / worker
1026
+ const directive = resolveTreeRuntimeDirective(route, chain);
1027
+ const fromJson = loadGlobalConfig().runtime;
1028
+ let runtime = directive ?? config.runtime ?? fromJson ?? "bun";
1029
+ if (!VALID_RUNTIMES.has(runtime)) {
1030
+ process.stderr.write(
1031
+ ` ${paint("yellow", "⚠")} invalid runtime ${JSON.stringify(runtime)} — falling back to "bun"\n`,
1032
+ );
1033
+ runtime = "bun";
1034
+ }
1035
+ config.runtime = runtime;
1036
+ // A route is "dynamic" (emitted as a request-time function) when it explicitly
1037
+ // opts into a runtime via a directive, config.ts/edge/worker, or config.dynamic.
1038
+ config.dynamic = Boolean(directive) || explicitRuntime || config.dynamic === true;
1039
+ config.headers = {
1040
+ "x-swift-rust-runtime": runtime,
1041
+ ...(config.dynamic ? { "x-swift-rust-dynamic": "1" } : {}),
1042
+ ...config.headers,
1043
+ };
919
1044
  return config;
920
1045
  }
921
1046
 
@@ -961,7 +1086,8 @@ async function runRoutePipeline(route, ctx) {
961
1086
  }
962
1087
 
963
1088
  // config.ts — merged, applied to the response by the caller.
964
- const config = await readMergedConfig(chain);
1089
+ const config = await readMergedConfig(chain, route);
1090
+ ctx.runtime = config.runtime;
965
1091
 
966
1092
  // i18n.ts — resolve the active locale into locals (cookie/header/default).
967
1093
  const i18nMod = await collectFirst(chain, "i18n");
@@ -994,19 +1120,23 @@ async function runRoutePipeline(route, ctx) {
994
1120
  }
995
1121
  }
996
1122
 
997
- // guard.ts — outer → inner
998
- for (const { file } of collectRouteFiles(chain, "guard")) {
999
- const mod = await loadModuleFresh(file);
1000
- const fn = mod.default ?? mod.guard;
1001
- if (typeof fn === "function") {
1002
- try {
1003
- applyControl(await fn(ctx));
1004
- } catch (e) {
1005
- if (e?.digest || e?.__response) throw e;
1006
- throw new RoutingFileError("guard", file, e);
1123
+ // guard.ts — outer → inner. Opt-in via 'use guard' / config.guard /
1124
+ // global autoApplyGuard (see shouldRunGuard).
1125
+ if (shouldRunGuard(route, chain, config)) {
1126
+ config.headers = { ...config.headers, "x-swift-rust-guard": "1" };
1127
+ for (const { file } of collectRouteFiles(chain, "guard")) {
1128
+ const mod = await loadModuleFresh(file);
1129
+ const fn = mod.default ?? mod.guard;
1130
+ if (typeof fn === "function") {
1131
+ try {
1132
+ applyControl(await fn(ctx));
1133
+ } catch (e) {
1134
+ if (e?.digest || e?.__response) throw e;
1135
+ throw new RoutingFileError("guard", file, e);
1136
+ }
1137
+ } else if (mod.default !== undefined || mod.guard !== undefined) {
1138
+ warnRoutingFile(file, `guard.ts must export a function (default export). ${relative(cwd, file)}`);
1007
1139
  }
1008
- } else if (mod.default !== undefined || mod.guard !== undefined) {
1009
- warnRoutingFile(file, `guard.ts must export a function (default export). ${relative(cwd, file)}`);
1010
1140
  }
1011
1141
  }
1012
1142
 
@@ -1126,6 +1256,98 @@ function cacheControlFromPlan(plan) {
1126
1256
  return null;
1127
1257
  }
1128
1258
 
1259
+ // ── Parallel routes (@slot dirs → fragment / fallback / default) ────────────
1260
+ function findDynamicChild(dir) {
1261
+ try {
1262
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
1263
+ if (e.isDirectory() && /^\[.+\]$/.test(e.name)) return join(dir, e.name);
1264
+ }
1265
+ } catch {}
1266
+ return null;
1267
+ }
1268
+
1269
+ // Resolve one @slot dir against the URL segments below its layout. Returns a
1270
+ // React element (the slot's matched page/fragment, its default.tsx, wrapped in
1271
+ // any slot layouts + a fallback.tsx Suspense boundary) or null.
1272
+ async function resolveSlotElement(slotDir, segs) {
1273
+ const React = await import("react");
1274
+ let cur = slotDir;
1275
+ const slotLayouts = [];
1276
+ let matched = true;
1277
+ for (let i = 0; i < segs.length; i++) {
1278
+ const layoutFile = findFile(cur, "layout");
1279
+ if (layoutFile) slotLayouts.push(layoutFile);
1280
+ let next = join(cur, segs[i]);
1281
+ if (!existsSync(next) || !statSync(next).isDirectory()) {
1282
+ const dyn = findDynamicChild(cur);
1283
+ if (dyn) next = dyn;
1284
+ else {
1285
+ matched = false;
1286
+ break;
1287
+ }
1288
+ }
1289
+ cur = next;
1290
+ }
1291
+ let leafFile = null;
1292
+ if (matched) {
1293
+ const layoutFile = findFile(cur, "layout");
1294
+ if (layoutFile && !slotLayouts.includes(layoutFile)) slotLayouts.push(layoutFile);
1295
+ leafFile = findFile(cur, "page") || findFile(cur, "fragment");
1296
+ }
1297
+ if (!leafFile) {
1298
+ // Unmatched slot → default.tsx (Next.js semantics), else nothing.
1299
+ const def = findFile(slotDir, "default");
1300
+ if (!def) return null;
1301
+ leafFile = def;
1302
+ slotLayouts.length = 0;
1303
+ const rootLayout = findFile(slotDir, "layout");
1304
+ if (rootLayout) slotLayouts.push(rootLayout);
1305
+ }
1306
+ let mod;
1307
+ try {
1308
+ mod = await loadModuleFresh(leafFile);
1309
+ } catch {
1310
+ return null;
1311
+ }
1312
+ const Comp = mod.default ?? mod.Page ?? mod.Fragment ?? mod.page ?? mod.fragment;
1313
+ if (!Comp) return null;
1314
+ let el = React.createElement(Comp, {});
1315
+ for (let i = slotLayouts.length - 1; i >= 0; i--) {
1316
+ try {
1317
+ const lm = await loadModuleFresh(slotLayouts[i]);
1318
+ const L = lm.default ?? lm.Layout ?? lm.layout;
1319
+ if (L) el = React.createElement(L, null, el);
1320
+ } catch {}
1321
+ }
1322
+ const fallbackFile = findFile(slotDir, "fallback");
1323
+ if (fallbackFile) {
1324
+ try {
1325
+ const fb = await loadModuleFresh(fallbackFile);
1326
+ const Fallback = fb.default ?? fb.Fallback;
1327
+ if (Fallback) el = React.createElement(React.Suspense, { fallback: React.createElement(Fallback, {}) }, el);
1328
+ } catch {}
1329
+ }
1330
+ return el;
1331
+ }
1332
+
1333
+ // Collect all @slot dirs of a layout dir into named props for the layout.
1334
+ async function resolveParallelSlots(dir, segs) {
1335
+ const slots = {};
1336
+ let entries;
1337
+ try {
1338
+ entries = readdirSync(dir, { withFileTypes: true });
1339
+ } catch {
1340
+ return slots;
1341
+ }
1342
+ for (const e of entries) {
1343
+ if (e.isDirectory() && e.name.startsWith("@")) {
1344
+ const el = await resolveSlotElement(join(dir, e.name), segs);
1345
+ if (el) slots[e.name.slice(1)] = el;
1346
+ }
1347
+ }
1348
+ return slots;
1349
+ }
1350
+
1129
1351
  async function renderRoute(urlPath, req) {
1130
1352
  const segments = urlToRouteSegments(urlPath);
1131
1353
  const route = resolvePageRoute(segments);
@@ -1137,6 +1359,10 @@ async function renderRoute(urlPath, req) {
1137
1359
  const errorFile = findErrorBoundary(segments);
1138
1360
  const loadingFile = findLoading(segments);
1139
1361
 
1362
+ // Reset per-render Google font registry so each page only requests the
1363
+ // families it actually uses (factories re-register during render).
1364
+ globalThis.__SR_GOOGLE_FONTS__ = new Set();
1365
+
1140
1366
  try {
1141
1367
  const React = await import("react");
1142
1368
  const url = req ? new URL(req.url) : new URL(urlPath, "http://localhost");
@@ -1199,15 +1425,48 @@ async function renderRoute(urlPath, req) {
1199
1425
  tree,
1200
1426
  );
1201
1427
  }
1202
- for (let i = layouts.length - 1; i >= 0; i--) {
1203
- const layoutMod = await loadModuleFresh(layouts[i].file);
1204
- const Layout = layoutMod.default ?? layoutMod.Layout ?? layoutMod.layout;
1205
- if (Layout) tree = React.createElement(Layout, null, tree);
1428
+ // Wrap the page with each segment's template then layout, innermost dir
1429
+ // first, so the nesting is layout > template > children (Next.js order).
1430
+ // template.tsx re-mounts on navigation; on the server it just wraps.
1431
+ const dirChain = route.dirChain && route.dirChain.length ? route.dirChain : [APP_DIR];
1432
+ for (let i = dirChain.length - 1; i >= 0; i--) {
1433
+ const dir = dirChain[i];
1434
+ const templateFile = findFile(dir, "template");
1435
+ if (templateFile) {
1436
+ const tmod = await loadModuleFresh(templateFile);
1437
+ const Template = tmod.default ?? tmod.Template ?? tmod.template;
1438
+ if (Template) tree = React.createElement(Template, null, tree);
1439
+ }
1440
+ const layoutFile = findFile(dir, "layout");
1441
+ if (layoutFile) {
1442
+ const layoutMod = await loadModuleFresh(layoutFile);
1443
+ const Layout = layoutMod.default ?? layoutMod.Layout ?? layoutMod.layout;
1444
+ if (Layout) {
1445
+ // Parallel routes: @slot subdirs of this layout become named props.
1446
+ const slotProps = await resolveParallelSlots(dir, segments.slice(i));
1447
+ tree = React.createElement(Layout, slotProps, tree);
1448
+ }
1449
+ }
1450
+ }
1451
+ // shell.tsx — root-only. Lets the app own the outer document
1452
+ // (<html>/<body>/providers); the framework injects head assets + body
1453
+ // scripts into what it renders (see the fullDocument branch in handleFetch).
1454
+ let fullDocument = false;
1455
+ const shellFile = findFile(APP_DIR, "shell");
1456
+ if (shellFile) {
1457
+ try {
1458
+ const sm = await loadModuleFresh(shellFile);
1459
+ const Shell = sm.default ?? sm.Shell ?? sm.shell;
1460
+ if (Shell) {
1461
+ tree = React.createElement(Shell, null, tree);
1462
+ fullDocument = true;
1463
+ }
1464
+ } catch {}
1206
1465
  }
1207
1466
  const html = await renderToStringCompat(tree);
1208
1467
  if (runtime?.__setRouteContext) runtime.__setRouteContext(null);
1209
1468
  const metadata = await resolveMetadata(layouts.map((l) => l.file), route.file, route.params, segments);
1210
- return { status: 200, html, metadata, error: null, clientPage, pageFile: route.file, layoutFiles: layouts.map((l) => l.file), notFoundFile, errorFile, loadingFile, segments, setCookies: pipeline.setCookies, actionData: pipeline.actionData, config: pipeline.config, revalidatePlan: pipeline.revalidatePlan, seoHead: pipeline.seoHead, serverState: pipeline.serverState };
1469
+ return { status: 200, html, metadata, error: null, clientPage, pageFile: route.file, layoutFiles: layouts.map((l) => l.file), notFoundFile, errorFile, loadingFile, segments, setCookies: pipeline.setCookies, actionData: pipeline.actionData, config: pipeline.config, revalidatePlan: pipeline.revalidatePlan, seoHead: pipeline.seoHead, serverState: pipeline.serverState, fullDocument };
1211
1470
  } catch (err) {
1212
1471
  const rt = await routerRuntime();
1213
1472
  if (rt?.__setRouteContext) rt.__setRouteContext(null);
@@ -1257,10 +1516,93 @@ async function renderRoute(urlPath, req) {
1257
1516
  }
1258
1517
  } catch {}
1259
1518
  }
1519
+ // global-error.tsx — root-level boundary. It renders its own <html>/<body>,
1520
+ // so it replaces the whole document. Only kicks in for uncaught errors when
1521
+ // no closer error/error-recovery boundary handled them.
1522
+ const globalErrorFile = findFile(APP_DIR, "global-error");
1523
+ if (globalErrorFile) {
1524
+ try {
1525
+ const React = await import("react");
1526
+ const mod = await loadModule(globalErrorFile, { bust: true });
1527
+ const GlobalError = mod.default ?? mod.GlobalError;
1528
+ if (GlobalError) {
1529
+ const inner = await renderToStringCompat(
1530
+ React.createElement(GlobalError, { error: err, reset: () => {} }),
1531
+ );
1532
+ // status:200 here only gates past the dev error overlay; the
1533
+ // rawResponse carries the real 500 to the client.
1534
+ return {
1535
+ status: 200,
1536
+ html: null,
1537
+ error: null,
1538
+ segments,
1539
+ rawResponse: new Response(`<!DOCTYPE html>${inner}`, {
1540
+ status: 500,
1541
+ headers: { "Content-Type": "text/html; charset=utf-8" },
1542
+ }),
1543
+ };
1544
+ }
1545
+ } catch {}
1546
+ }
1260
1547
  return { status: 500, html: null, error: err, segments, pageFile: route.file };
1261
1548
  }
1262
1549
  }
1263
1550
 
1551
+ async function resolveTransitionConfig(segments) {
1552
+ const file = findRouteFileUp(segments || [], "transition");
1553
+ if (!file) return null;
1554
+ try {
1555
+ const mod = await loadModuleFresh(file);
1556
+ const cfg = mod.default && typeof mod.default === "object" ? mod.default : {};
1557
+ const type = mod.type ?? cfg.type ?? "fade";
1558
+ const out = { type };
1559
+ const duration = mod.duration ?? cfg.duration;
1560
+ if (duration) out.duration = Number(duration);
1561
+ return out;
1562
+ } catch {
1563
+ return null;
1564
+ }
1565
+ }
1566
+
1567
+ async function resolvePrefetchConfig(segments) {
1568
+ const file = findRouteFileUp(segments || [], "prefetch");
1569
+ if (!file) return null;
1570
+ try {
1571
+ const mod = await loadModuleFresh(file);
1572
+ const cfg = mod.default && typeof mod.default === "object" ? mod.default : {};
1573
+ const strategy = mod.strategy ?? cfg.strategy ?? "hover";
1574
+ const out = { strategy };
1575
+ const margin = mod.margin ?? cfg.margin;
1576
+ if (margin) out.margin = String(margin);
1577
+ return out;
1578
+ } catch {
1579
+ return null;
1580
+ }
1581
+ }
1582
+
1583
+ const pendingOverlayCache = new Map();
1584
+ async function renderPendingOverlay(segments) {
1585
+ const file = findRouteFileUp(segments || [], "pending");
1586
+ if (!file) return "";
1587
+ try {
1588
+ let inner;
1589
+ const cached = pendingOverlayCache.get(file);
1590
+ if (cached && cached.gen === buildGeneration) {
1591
+ inner = cached.html;
1592
+ } else {
1593
+ const React = await import("react");
1594
+ const mod = await loadModuleFresh(file);
1595
+ const Pending = mod.default ?? mod.Pending ?? mod.pending;
1596
+ if (!Pending) return "";
1597
+ inner = await renderToStringCompat(React.createElement(Pending, {}));
1598
+ pendingOverlayCache.set(file, { gen: buildGeneration, html: inner });
1599
+ }
1600
+ return `<div id="__sr-pending" data-sr-pending hidden style="position:fixed;top:0;left:0;right:0;z-index:2147483646;pointer-events:none">${inner}</div>`;
1601
+ } catch {
1602
+ return "";
1603
+ }
1604
+ }
1605
+
1264
1606
  async function renderNotFound(segments) {
1265
1607
  const notFoundFile = findNotFound(segments);
1266
1608
  if (!notFoundFile) {
@@ -1349,6 +1691,7 @@ async function wrapInDocumentAsync({ head, body }) {
1349
1691
  <meta charset="utf-8" />
1350
1692
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1351
1693
  ${fullHead}
1694
+ <script src="/_swift-rust/navigator.js" defer></script>
1352
1695
  <script src="/_swift-rust/hmr-client.js" defer></script>
1353
1696
  </head>
1354
1697
  <body>${body}</body>
@@ -1364,6 +1707,15 @@ async function tryHmrClient() {
1364
1707
  }
1365
1708
  }
1366
1709
 
1710
+ function readNavigatorClient() {
1711
+ try {
1712
+ const file = join(dirname(new URL(import.meta.url).pathname), "runtime", "navigator.js");
1713
+ return readFileSync(file, "utf8");
1714
+ } catch {
1715
+ return null;
1716
+ }
1717
+ }
1718
+
1367
1719
  function shouldIgnoreFile(filename) {
1368
1720
  if (!filename) return true;
1369
1721
  const base = filename.split(sep).pop();
@@ -1530,15 +1882,8 @@ async function handleRequest(req, res) {
1530
1882
 
1531
1883
  if (pathname.startsWith("/_swift-rust/fonts/")) {
1532
1884
  const fontPath = join(
1533
- import.meta.dirname,
1534
- "..",
1535
- "..",
1536
- "..",
1537
- "packages",
1538
- "font",
1539
- "src",
1540
- "local",
1541
- decodeURIComponent(pathname.replace("/_swift-rust/fonts/", ""))
1885
+ resolveLocalFontDir(),
1886
+ decodeURIComponent(pathname.replace("/_swift-rust/fonts/", "")),
1542
1887
  );
1543
1888
  if (existsSync(fontPath) && statSync(fontPath).isFile()) {
1544
1889
  const ext = extname(fontPath).toLowerCase();
@@ -1747,6 +2092,14 @@ async function handleFetch(req) {
1747
2092
  return new Response("Not found", { status: 404 });
1748
2093
  }
1749
2094
 
2095
+ if (pathname === "/_swift-rust/navigator.js") {
2096
+ const client = readNavigatorClient();
2097
+ if (client) {
2098
+ return new Response(client, { headers: { "Content-Type": "application/javascript; charset=utf-8" } });
2099
+ }
2100
+ return new Response("// navigator unavailable", { status: 404, headers: { "Content-Type": "application/javascript" } });
2101
+ }
2102
+
1750
2103
  if (pathname === "/_swift-rust/image") {
1751
2104
  const target = url.searchParams.get("url");
1752
2105
  const w = parseInt(url.searchParams.get("w") || "0", 10);
@@ -1979,7 +2332,19 @@ async function handleFetch(req) {
1979
2332
  await scanFontsFromLayouts();
1980
2333
  // seo.tsx head + metadata head
1981
2334
  const headExtra = [metadataToHead(renderResult.metadata), renderResult.seoHead || ""].filter(Boolean).join("\n");
1982
- let doc = await wrapInDocumentAsync({ head: headExtra, body: renderResult.html || "" });
2335
+ let doc;
2336
+ if (renderResult.fullDocument) {
2337
+ // shell.tsx already rendered <html>/<head>/<body>. Inject framework head
2338
+ // assets (metadata, fonts, globals CSS, navigator/HMR scripts) into its
2339
+ // <head>; body scripts below still append before </body>.
2340
+ const inject = `${await buildHead(headExtra)}\n<script src="/_swift-rust/navigator.js" defer></script>\n<script src="/_swift-rust/hmr-client.js" defer></script>`;
2341
+ doc = renderResult.html || "";
2342
+ if (!/^\s*<!doctype/i.test(doc)) doc = `<!DOCTYPE html>${doc}`;
2343
+ if (doc.includes("</head>")) doc = doc.replace("</head>", `${inject}\n</head>`);
2344
+ else doc = doc.replace(/<body([\s>])/i, `<head>${inject}</head><body$1`);
2345
+ } else {
2346
+ doc = await wrapInDocumentAsync({ head: headExtra, body: renderResult.html || "" });
2347
+ }
1983
2348
  // state.ts → window.__SR_STATE__ for client stores
1984
2349
  if (renderResult.serverState !== undefined) {
1985
2350
  const json = JSON.stringify(renderResult.serverState).replace(/</g, "\\u003c");
@@ -1989,6 +2354,22 @@ async function handleFetch(req) {
1989
2354
  const src = `/_swift-rust/island.js?p=${encodeURIComponent(renderResult.pageFile)}`;
1990
2355
  doc = doc.replace("</body>", `<script type="module" src="${src}"></script>\n</body>`);
1991
2356
  }
2357
+ // pending.tsx → hidden overlay the client navigator reveals while a
2358
+ // navigation is in flight (see runtime/navigator.js).
2359
+ const pendingOverlay = await renderPendingOverlay(renderResult.segments);
2360
+ if (pendingOverlay) doc = doc.replace("</body>", `${pendingOverlay}\n</body>`);
2361
+ // prefetch.ts → client prefetch strategy for the navigator.
2362
+ const prefetchCfg = await resolvePrefetchConfig(renderResult.segments);
2363
+ if (prefetchCfg) {
2364
+ const json = JSON.stringify(prefetchCfg).replace(/</g, "\\u003c");
2365
+ doc = doc.replace("</body>", `<script>window.__SR_PREFETCH__=${json}</script>\n</body>`);
2366
+ }
2367
+ // transition.tsx → View Transitions config for the navigator's swap.
2368
+ const transitionCfg = await resolveTransitionConfig(renderResult.segments);
2369
+ if (transitionCfg) {
2370
+ const json = JSON.stringify(transitionCfg).replace(/</g, "\\u003c");
2371
+ doc = doc.replace("</body>", `<script>window.__SR_TRANSITION__=${json}</script>\n</body>`);
2372
+ }
1992
2373
  const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
1993
2374
  for (const c of renderResult.setCookies || []) headers.append("Set-Cookie", c);
1994
2375
  // config.ts headers