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.
- package/bin/build.mjs +374 -9
- package/bin/dev-server.mjs +416 -35
- package/bin/runtime/fn-core.mjs +318 -0
- package/bin/runtime/navigator.js +238 -0
- package/dist/head.d.ts +3 -3
- package/dist/head.d.ts.map +1 -1
- package/dist/head.js +14 -0
- package/dist/head.js.map +1 -0
- package/dist/link.d.ts +1 -1
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +14 -0
- package/dist/link.js.map +1 -0
- package/package.json +1 -1
- package/dist/head.jsx +0 -15
- package/dist/head.jsx.map +0 -1
- package/dist/link.jsx +0 -6
- package/dist/link.jsx.map +0 -1
package/bin/dev-server.mjs
CHANGED
|
@@ -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
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
907
|
-
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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
|
-
|
|
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
|
|
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
|