swift-rust 1.1.0 → 1.3.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 CHANGED
@@ -15,7 +15,7 @@ import { join, resolve, dirname, sep } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
 
17
17
  const cwd = process.cwd();
18
- const APP_DIR_CANDIDATES = [resolve(cwd, "app", "src"), resolve(cwd, "app")];
18
+ const APP_DIR_CANDIDATES = [resolve(cwd, "src", "app"), resolve(cwd, "app")];
19
19
  const APP_DIR = APP_DIR_CANDIDATES.find((p) => existsSync(p)) ?? resolve(cwd, "app");
20
20
  const PUBLIC_DIR = resolve(cwd, "public");
21
21
  const OUT_DIR = resolve(cwd, ".vercel", "output");
@@ -200,6 +200,15 @@ function stripHmrScript(html) {
200
200
  return html.replace(/\s*<script src="\/_swift-rust\/hmr-client\.js"[^>]*>\s*<\/script>/g, "");
201
201
  }
202
202
 
203
+ // The dev-time image endpoint (/_swift-rust/image) only exists while the dev
204
+ // server runs. On Vercel the platform serves an optimizing endpoint at
205
+ // /_vercel/image with the exact same query contract (url, w, q), so we swap the
206
+ // path prefix at build time. The `images` config written into config.json
207
+ // enables (and bounds) that optimizer. Handles `src`, `srcset`, and `&amp;`.
208
+ function rewriteImageUrls(html) {
209
+ return html.split("/_swift-rust/image?").join("/_vercel/image?");
210
+ }
211
+
203
212
  function writeStaticFile(outDir, pathname, html) {
204
213
  const rel = pathname === "/" ? "index.html" : `${pathname.replace(/^\//, "")}/index.html`;
205
214
  const outPath = join(outDir, rel);
@@ -263,6 +272,16 @@ function writeConfigJson(outDir, _hasPublic) {
263
272
  overrides: {
264
273
  "404.html": { path: "404", contentType: "text/html; charset=utf-8" },
265
274
  },
275
+ // Enables Vercel's image optimizer (/_vercel/image). `sizes` MUST match the
276
+ // widths <Image> requests (packages/image DEVICE_SIZES) or requests 400.
277
+ images: {
278
+ sizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
279
+ formats: ["image/avif", "image/webp"],
280
+ minimumCacheTTL: 86400,
281
+ // Allow optimizing local SVG assets (e.g. blog covers); sandboxed by CSP.
282
+ dangerouslyAllowSVG: true,
283
+ contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
284
+ },
266
285
  };
267
286
  writeFileSync(join(outDir, "config.json"), `${JSON.stringify(config, null, 2)}\n`);
268
287
  }
@@ -325,7 +344,7 @@ async function main() {
325
344
  try {
326
345
  const { status, body } = await fetchRoute(route);
327
346
  if (status === 200) {
328
- const cleaned = await localizeIslands(stripHmrScript(body));
347
+ const cleaned = rewriteImageUrls(await localizeIslands(stripHmrScript(body)));
329
348
  writeStaticFile(STATIC_DIR, route, cleaned);
330
349
  okCount++;
331
350
  process.stdout.write(` ${paint("green", "✓")} ${route}\n`);
@@ -393,6 +412,15 @@ async function main() {
393
412
  }
394
413
  }
395
414
 
415
+ // Client navigator runtime (SPA navigation). The rendered HTML references
416
+ // /_swift-rust/navigator.js; emit it as a static asset so deployed sites
417
+ // get client-side navigation too.
418
+ const navSrc = join(RUNTIME_DIR, "navigator.js");
419
+ if (existsSync(navSrc)) {
420
+ writeRawFile(STATIC_DIR, "_swift-rust/navigator.js", readFileSync(navSrc));
421
+ process.stdout.write(` ${paint("green", "✓")} _swift-rust/navigator.js\n`);
422
+ }
423
+
396
424
  writeConfigJson(OUT_DIR, hasPublic);
397
425
 
398
426
  const total = Date.now() - start;
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, statSync, readFileSync, readdirSync, writeFileSync, unlinkSync, watch as fsWatch } from "node:fs";
3
- import { join, resolve, extname, relative, dirname, sep } from "node:path";
3
+ import { join, resolve, extname, relative, dirname, basename, sep } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { performance } from "node:perf_hooks";
6
6
  import { errorOverlayHTML as renderErrorOverlay } from "./error-overlay.mjs";
@@ -17,7 +17,7 @@ function getArg(name, fallback) {
17
17
  return fallback;
18
18
  }
19
19
 
20
- const port = parseInt(getArg("port", process.env.PORT || "3000"), 10);
20
+ const port = parseInt(getArg("port", process.env.PORT || "3210"), 10);
21
21
  const hostname = getArg("hostname", "0.0.0.0");
22
22
 
23
23
  const c = {
@@ -36,7 +36,7 @@ const useColor = process.stdout.isTTY !== false && !process.env.NO_COLOR;
36
36
  const paint = (color, s) => (useColor ? `${c[color]}${s}${c.reset}` : s);
37
37
 
38
38
  const VERSION = "0.1.0";
39
- const APP_DIR_CANDIDATES = [resolve(cwd, "app", "src"), resolve(cwd, "app")];
39
+ const APP_DIR_CANDIDATES = [resolve(cwd, "src", "app"), resolve(cwd, "app")];
40
40
  const APP_DIR = APP_DIR_CANDIDATES.find((p) => existsSync(p)) ?? resolve(cwd, "app");
41
41
  const PUBLIC_DIR = resolve(cwd, "public");
42
42
  const SWIFT_RUST_CONFIG = resolve(cwd, "swift-rust.config.json");
@@ -617,6 +617,7 @@ function mergeMetadata(...metas) {
617
617
  if (m.description) out.description = m.description;
618
618
  if (m.keywords) out.keywords = m.keywords;
619
619
  if (m.openGraph) out.openGraph = { ...(out.openGraph || {}), ...m.openGraph };
620
+ if (m.twitter) out.twitter = { ...(out.twitter || {}), ...m.twitter };
620
621
  }
621
622
  return out;
622
623
  }
@@ -641,6 +642,24 @@ function metadataToHead(meta) {
641
642
  for (const img of meta.openGraph.images) {
642
643
  const url = typeof img === "string" ? img : img.url;
643
644
  if (url) parts.push(`<meta property="og:image" content="${escapeHtml(url)}" />`);
645
+ if (typeof img === "object" && img) {
646
+ if (img.width) parts.push(`<meta property="og:image:width" content="${escapeHtml(img.width)}" />`);
647
+ if (img.height) parts.push(`<meta property="og:image:height" content="${escapeHtml(img.height)}" />`);
648
+ if (img.alt) parts.push(`<meta property="og:image:alt" content="${escapeHtml(img.alt)}" />`);
649
+ }
650
+ }
651
+ }
652
+ }
653
+ if (meta.twitter) {
654
+ if (meta.twitter.card) parts.push(`<meta name="twitter:card" content="${escapeHtml(meta.twitter.card)}" />`);
655
+ if (meta.twitter.site) parts.push(`<meta name="twitter:site" content="${escapeHtml(meta.twitter.site)}" />`);
656
+ if (meta.twitter.creator) parts.push(`<meta name="twitter:creator" content="${escapeHtml(meta.twitter.creator)}" />`);
657
+ if (meta.twitter.title) parts.push(`<meta name="twitter:title" content="${escapeHtml(meta.twitter.title)}" />`);
658
+ if (meta.twitter.description) parts.push(`<meta name="twitter:description" content="${escapeHtml(meta.twitter.description)}" />`);
659
+ if (meta.twitter.images) {
660
+ for (const img of meta.twitter.images) {
661
+ const url = typeof img === "string" ? img : img.url;
662
+ if (url) parts.push(`<meta name="twitter:image" content="${escapeHtml(url)}" />`);
644
663
  }
645
664
  }
646
665
  }
@@ -796,10 +815,12 @@ function warnRoutingFile(file, msg) {
796
815
  function validateRoutingConventions() {
797
816
  const names = [...SPECIAL_FILES, "page"];
798
817
  const appRel = relative(cwd, APP_DIR) || "app";
799
- const usesSrc = APP_DIR.endsWith(`${sep}src`);
800
- const scan = (dir, label) => {
818
+ const srcRoot = dirname(APP_DIR);
819
+ const usesSrc = basename(APP_DIR) === "app" && basename(srcRoot) === "src";
820
+ const scan = (dir, label, allow = []) => {
801
821
  if (!existsSync(dir) || resolve(dir) === resolve(APP_DIR)) return;
802
822
  for (const name of names) {
823
+ if (allow.includes(name)) continue;
803
824
  const f = findFile(dir, name);
804
825
  if (f) {
805
826
  warnRoutingFile(
@@ -811,7 +832,8 @@ function validateRoutingConventions() {
811
832
  }
812
833
  };
813
834
  scan(cwd, "the project root");
814
- if (usesSrc) scan(dirname(APP_DIR), `"app/" (outside "src/")`);
835
+ // proxy.ts is allowed to live at the src/ root (sibling of src/app).
836
+ if (usesSrc) scan(srcRoot, `"src/" (outside "app/")`, ["proxy"]);
815
837
  }
816
838
 
817
839
  // ── RFC 0001 route pipeline: config → schema → guard → loader/action ────────
@@ -902,7 +924,16 @@ async function runRoutePipeline(route, ctx) {
902
924
 
903
925
  // proxy.ts — phase 1, outer → inner. Cheap, data-free interception
904
926
  // (Next.js calls this "proxy"; "middleware" is accepted with a warning).
905
- for (const { file } of collectRouteFiles(chain, "proxy")) {
927
+ // When using a src/ directory, a root proxy lives at src/proxy.ts (sibling
928
+ // of src/app), so it runs before any in-app proxy.
929
+ const proxyFiles = [];
930
+ const srcRoot = dirname(APP_DIR);
931
+ if (basename(APP_DIR) === "app" && basename(srcRoot) === "src") {
932
+ const rootProxy = findFile(srcRoot, "proxy");
933
+ if (rootProxy) proxyFiles.push({ file: rootProxy, dir: srcRoot });
934
+ }
935
+ proxyFiles.push(...collectRouteFiles(chain, "proxy"));
936
+ for (const { file } of proxyFiles) {
906
937
  const mod = await loadModuleFresh(file);
907
938
  const fn = mod.default ?? mod.proxy;
908
939
  if (typeof fn === "function") {
@@ -1095,6 +1126,98 @@ function cacheControlFromPlan(plan) {
1095
1126
  return null;
1096
1127
  }
1097
1128
 
1129
+ // ── Parallel routes (@slot dirs → fragment / fallback / default) ────────────
1130
+ function findDynamicChild(dir) {
1131
+ try {
1132
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
1133
+ if (e.isDirectory() && /^\[.+\]$/.test(e.name)) return join(dir, e.name);
1134
+ }
1135
+ } catch {}
1136
+ return null;
1137
+ }
1138
+
1139
+ // Resolve one @slot dir against the URL segments below its layout. Returns a
1140
+ // React element (the slot's matched page/fragment, its default.tsx, wrapped in
1141
+ // any slot layouts + a fallback.tsx Suspense boundary) or null.
1142
+ async function resolveSlotElement(slotDir, segs) {
1143
+ const React = await import("react");
1144
+ let cur = slotDir;
1145
+ const slotLayouts = [];
1146
+ let matched = true;
1147
+ for (let i = 0; i < segs.length; i++) {
1148
+ const layoutFile = findFile(cur, "layout");
1149
+ if (layoutFile) slotLayouts.push(layoutFile);
1150
+ let next = join(cur, segs[i]);
1151
+ if (!existsSync(next) || !statSync(next).isDirectory()) {
1152
+ const dyn = findDynamicChild(cur);
1153
+ if (dyn) next = dyn;
1154
+ else {
1155
+ matched = false;
1156
+ break;
1157
+ }
1158
+ }
1159
+ cur = next;
1160
+ }
1161
+ let leafFile = null;
1162
+ if (matched) {
1163
+ const layoutFile = findFile(cur, "layout");
1164
+ if (layoutFile && !slotLayouts.includes(layoutFile)) slotLayouts.push(layoutFile);
1165
+ leafFile = findFile(cur, "page") || findFile(cur, "fragment");
1166
+ }
1167
+ if (!leafFile) {
1168
+ // Unmatched slot → default.tsx (Next.js semantics), else nothing.
1169
+ const def = findFile(slotDir, "default");
1170
+ if (!def) return null;
1171
+ leafFile = def;
1172
+ slotLayouts.length = 0;
1173
+ const rootLayout = findFile(slotDir, "layout");
1174
+ if (rootLayout) slotLayouts.push(rootLayout);
1175
+ }
1176
+ let mod;
1177
+ try {
1178
+ mod = await loadModuleFresh(leafFile);
1179
+ } catch {
1180
+ return null;
1181
+ }
1182
+ const Comp = mod.default ?? mod.Page ?? mod.Fragment ?? mod.page ?? mod.fragment;
1183
+ if (!Comp) return null;
1184
+ let el = React.createElement(Comp, {});
1185
+ for (let i = slotLayouts.length - 1; i >= 0; i--) {
1186
+ try {
1187
+ const lm = await loadModuleFresh(slotLayouts[i]);
1188
+ const L = lm.default ?? lm.Layout ?? lm.layout;
1189
+ if (L) el = React.createElement(L, null, el);
1190
+ } catch {}
1191
+ }
1192
+ const fallbackFile = findFile(slotDir, "fallback");
1193
+ if (fallbackFile) {
1194
+ try {
1195
+ const fb = await loadModuleFresh(fallbackFile);
1196
+ const Fallback = fb.default ?? fb.Fallback;
1197
+ if (Fallback) el = React.createElement(React.Suspense, { fallback: React.createElement(Fallback, {}) }, el);
1198
+ } catch {}
1199
+ }
1200
+ return el;
1201
+ }
1202
+
1203
+ // Collect all @slot dirs of a layout dir into named props for the layout.
1204
+ async function resolveParallelSlots(dir, segs) {
1205
+ const slots = {};
1206
+ let entries;
1207
+ try {
1208
+ entries = readdirSync(dir, { withFileTypes: true });
1209
+ } catch {
1210
+ return slots;
1211
+ }
1212
+ for (const e of entries) {
1213
+ if (e.isDirectory() && e.name.startsWith("@")) {
1214
+ const el = await resolveSlotElement(join(dir, e.name), segs);
1215
+ if (el) slots[e.name.slice(1)] = el;
1216
+ }
1217
+ }
1218
+ return slots;
1219
+ }
1220
+
1098
1221
  async function renderRoute(urlPath, req) {
1099
1222
  const segments = urlToRouteSegments(urlPath);
1100
1223
  const route = resolvePageRoute(segments);
@@ -1168,10 +1291,28 @@ async function renderRoute(urlPath, req) {
1168
1291
  tree,
1169
1292
  );
1170
1293
  }
1171
- for (let i = layouts.length - 1; i >= 0; i--) {
1172
- const layoutMod = await loadModuleFresh(layouts[i].file);
1173
- const Layout = layoutMod.default ?? layoutMod.Layout ?? layoutMod.layout;
1174
- if (Layout) tree = React.createElement(Layout, null, tree);
1294
+ // Wrap the page with each segment's template then layout, innermost dir
1295
+ // first, so the nesting is layout > template > children (Next.js order).
1296
+ // template.tsx re-mounts on navigation; on the server it just wraps.
1297
+ const dirChain = route.dirChain && route.dirChain.length ? route.dirChain : [APP_DIR];
1298
+ for (let i = dirChain.length - 1; i >= 0; i--) {
1299
+ const dir = dirChain[i];
1300
+ const templateFile = findFile(dir, "template");
1301
+ if (templateFile) {
1302
+ const tmod = await loadModuleFresh(templateFile);
1303
+ const Template = tmod.default ?? tmod.Template ?? tmod.template;
1304
+ if (Template) tree = React.createElement(Template, null, tree);
1305
+ }
1306
+ const layoutFile = findFile(dir, "layout");
1307
+ if (layoutFile) {
1308
+ const layoutMod = await loadModuleFresh(layoutFile);
1309
+ const Layout = layoutMod.default ?? layoutMod.Layout ?? layoutMod.layout;
1310
+ if (Layout) {
1311
+ // Parallel routes: @slot subdirs of this layout become named props.
1312
+ const slotProps = await resolveParallelSlots(dir, segments.slice(i));
1313
+ tree = React.createElement(Layout, slotProps, tree);
1314
+ }
1315
+ }
1175
1316
  }
1176
1317
  const html = await renderToStringCompat(tree);
1177
1318
  if (runtime?.__setRouteContext) runtime.__setRouteContext(null);
@@ -1226,10 +1367,93 @@ async function renderRoute(urlPath, req) {
1226
1367
  }
1227
1368
  } catch {}
1228
1369
  }
1370
+ // global-error.tsx — root-level boundary. It renders its own <html>/<body>,
1371
+ // so it replaces the whole document. Only kicks in for uncaught errors when
1372
+ // no closer error/error-recovery boundary handled them.
1373
+ const globalErrorFile = findFile(APP_DIR, "global-error");
1374
+ if (globalErrorFile) {
1375
+ try {
1376
+ const React = await import("react");
1377
+ const mod = await loadModule(globalErrorFile, { bust: true });
1378
+ const GlobalError = mod.default ?? mod.GlobalError;
1379
+ if (GlobalError) {
1380
+ const inner = await renderToStringCompat(
1381
+ React.createElement(GlobalError, { error: err, reset: () => {} }),
1382
+ );
1383
+ // status:200 here only gates past the dev error overlay; the
1384
+ // rawResponse carries the real 500 to the client.
1385
+ return {
1386
+ status: 200,
1387
+ html: null,
1388
+ error: null,
1389
+ segments,
1390
+ rawResponse: new Response(`<!DOCTYPE html>${inner}`, {
1391
+ status: 500,
1392
+ headers: { "Content-Type": "text/html; charset=utf-8" },
1393
+ }),
1394
+ };
1395
+ }
1396
+ } catch {}
1397
+ }
1229
1398
  return { status: 500, html: null, error: err, segments, pageFile: route.file };
1230
1399
  }
1231
1400
  }
1232
1401
 
1402
+ async function resolveTransitionConfig(segments) {
1403
+ const file = findRouteFileUp(segments || [], "transition");
1404
+ if (!file) return null;
1405
+ try {
1406
+ const mod = await loadModuleFresh(file);
1407
+ const cfg = mod.default && typeof mod.default === "object" ? mod.default : {};
1408
+ const type = mod.type ?? cfg.type ?? "fade";
1409
+ const out = { type };
1410
+ const duration = mod.duration ?? cfg.duration;
1411
+ if (duration) out.duration = Number(duration);
1412
+ return out;
1413
+ } catch {
1414
+ return null;
1415
+ }
1416
+ }
1417
+
1418
+ async function resolvePrefetchConfig(segments) {
1419
+ const file = findRouteFileUp(segments || [], "prefetch");
1420
+ if (!file) return null;
1421
+ try {
1422
+ const mod = await loadModuleFresh(file);
1423
+ const cfg = mod.default && typeof mod.default === "object" ? mod.default : {};
1424
+ const strategy = mod.strategy ?? cfg.strategy ?? "hover";
1425
+ const out = { strategy };
1426
+ const margin = mod.margin ?? cfg.margin;
1427
+ if (margin) out.margin = String(margin);
1428
+ return out;
1429
+ } catch {
1430
+ return null;
1431
+ }
1432
+ }
1433
+
1434
+ const pendingOverlayCache = new Map();
1435
+ async function renderPendingOverlay(segments) {
1436
+ const file = findRouteFileUp(segments || [], "pending");
1437
+ if (!file) return "";
1438
+ try {
1439
+ let inner;
1440
+ const cached = pendingOverlayCache.get(file);
1441
+ if (cached && cached.gen === buildGeneration) {
1442
+ inner = cached.html;
1443
+ } else {
1444
+ const React = await import("react");
1445
+ const mod = await loadModuleFresh(file);
1446
+ const Pending = mod.default ?? mod.Pending ?? mod.pending;
1447
+ if (!Pending) return "";
1448
+ inner = await renderToStringCompat(React.createElement(Pending, {}));
1449
+ pendingOverlayCache.set(file, { gen: buildGeneration, html: inner });
1450
+ }
1451
+ 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>`;
1452
+ } catch {
1453
+ return "";
1454
+ }
1455
+ }
1456
+
1233
1457
  async function renderNotFound(segments) {
1234
1458
  const notFoundFile = findNotFound(segments);
1235
1459
  if (!notFoundFile) {
@@ -1318,6 +1542,7 @@ async function wrapInDocumentAsync({ head, body }) {
1318
1542
  <meta charset="utf-8" />
1319
1543
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1320
1544
  ${fullHead}
1545
+ <script src="/_swift-rust/navigator.js" defer></script>
1321
1546
  <script src="/_swift-rust/hmr-client.js" defer></script>
1322
1547
  </head>
1323
1548
  <body>${body}</body>
@@ -1333,6 +1558,15 @@ async function tryHmrClient() {
1333
1558
  }
1334
1559
  }
1335
1560
 
1561
+ function readNavigatorClient() {
1562
+ try {
1563
+ const file = join(dirname(new URL(import.meta.url).pathname), "runtime", "navigator.js");
1564
+ return readFileSync(file, "utf8");
1565
+ } catch {
1566
+ return null;
1567
+ }
1568
+ }
1569
+
1336
1570
  function shouldIgnoreFile(filename) {
1337
1571
  if (!filename) return true;
1338
1572
  const base = filename.split(sep).pop();
@@ -1716,6 +1950,14 @@ async function handleFetch(req) {
1716
1950
  return new Response("Not found", { status: 404 });
1717
1951
  }
1718
1952
 
1953
+ if (pathname === "/_swift-rust/navigator.js") {
1954
+ const client = readNavigatorClient();
1955
+ if (client) {
1956
+ return new Response(client, { headers: { "Content-Type": "application/javascript; charset=utf-8" } });
1957
+ }
1958
+ return new Response("// navigator unavailable", { status: 404, headers: { "Content-Type": "application/javascript" } });
1959
+ }
1960
+
1719
1961
  if (pathname === "/_swift-rust/image") {
1720
1962
  const target = url.searchParams.get("url");
1721
1963
  const w = parseInt(url.searchParams.get("w") || "0", 10);
@@ -1958,6 +2200,22 @@ async function handleFetch(req) {
1958
2200
  const src = `/_swift-rust/island.js?p=${encodeURIComponent(renderResult.pageFile)}`;
1959
2201
  doc = doc.replace("</body>", `<script type="module" src="${src}"></script>\n</body>`);
1960
2202
  }
2203
+ // pending.tsx → hidden overlay the client navigator reveals while a
2204
+ // navigation is in flight (see runtime/navigator.js).
2205
+ const pendingOverlay = await renderPendingOverlay(renderResult.segments);
2206
+ if (pendingOverlay) doc = doc.replace("</body>", `${pendingOverlay}\n</body>`);
2207
+ // prefetch.ts → client prefetch strategy for the navigator.
2208
+ const prefetchCfg = await resolvePrefetchConfig(renderResult.segments);
2209
+ if (prefetchCfg) {
2210
+ const json = JSON.stringify(prefetchCfg).replace(/</g, "\\u003c");
2211
+ doc = doc.replace("</body>", `<script>window.__SR_PREFETCH__=${json}</script>\n</body>`);
2212
+ }
2213
+ // transition.tsx → View Transitions config for the navigator's swap.
2214
+ const transitionCfg = await resolveTransitionConfig(renderResult.segments);
2215
+ if (transitionCfg) {
2216
+ const json = JSON.stringify(transitionCfg).replace(/</g, "\\u003c");
2217
+ doc = doc.replace("</body>", `<script>window.__SR_TRANSITION__=${json}</script>\n</body>`);
2218
+ }
1961
2219
  const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
1962
2220
  for (const c of renderResult.setCookies || []) headers.append("Set-Cookie", c);
1963
2221
  // config.ts headers
@@ -113,8 +113,8 @@ function classifyError(message) {
113
113
  "The dev server could not bind to the requested port, " +
114
114
  "or it ran out of system resources.",
115
115
  suggestions: [
116
- "Close any other process using this port (usually 3000)",
117
- "Run with --port 3001 to use a different port",
116
+ "Close any other process using this port (usually 3210)",
117
+ "Run with --port <number> to use a different port",
118
118
  "Check disk space with `df -h`",
119
119
  ],
120
120
  };
@@ -0,0 +1,238 @@
1
+ // Swift-Rust client navigator — turns full-page loads into SPA navigation for
2
+ // <a>/<Link>. Intercepts same-origin clicks, fetches the destination HTML,
3
+ // swaps <body>, re-runs body scripts (island bootstrap + serialized state),
4
+ // syncs <title>/meta, and manages history. Ships in dev and in the build
5
+ // output (NOT dev-only like the HMR client).
6
+ (() => {
7
+ if (window.__SR_NAV__) return;
8
+ const nav = (window.__SR_NAV__ = { cache: new Map(), inflight: new Map() });
9
+ const ORIGIN = location.origin;
10
+ const MAX_CACHE = 32;
11
+
12
+ function internalAnchor(a) {
13
+ if (!a || a.target === "_blank" || a.hasAttribute("download")) return null;
14
+ if (a.dataset.srNoNav !== undefined) return null;
15
+ const raw = a.getAttribute("href");
16
+ if (!raw || raw.startsWith("#") || raw.startsWith("mailto:") || raw.startsWith("tel:")) return null;
17
+ let url;
18
+ try {
19
+ url = new URL(a.href, location.href);
20
+ } catch {
21
+ return null;
22
+ }
23
+ if (url.origin !== ORIGIN) return null;
24
+ return url;
25
+ }
26
+
27
+ function remember(key, html) {
28
+ if (nav.cache.size >= MAX_CACHE) nav.cache.delete(nav.cache.keys().next().value);
29
+ nav.cache.set(key, html);
30
+ }
31
+
32
+ // Fetch (and cache) a route's HTML. Deduped per URL so prefetch + click share.
33
+ function fetchDoc(url) {
34
+ const key = url;
35
+ if (nav.cache.has(key)) return Promise.resolve(nav.cache.get(key));
36
+ if (nav.inflight.has(key)) return nav.inflight.get(key);
37
+ const p = fetch(url, { headers: { "x-swift-rust-nav": "1" }, credentials: "same-origin" })
38
+ .then((res) => {
39
+ if (!res.ok && res.status !== 404) throw new Error("nav fetch " + res.status);
40
+ return res.text();
41
+ })
42
+ .then((html) => {
43
+ remember(key, html);
44
+ nav.inflight.delete(key);
45
+ return html;
46
+ })
47
+ .catch((err) => {
48
+ nav.inflight.delete(key);
49
+ throw err;
50
+ });
51
+ nav.inflight.set(key, p);
52
+ return p;
53
+ }
54
+ nav.prefetch = (url) => fetchDoc(new URL(url, location.href).href).catch(() => {});
55
+
56
+ // Scripts inserted via DOM cloning don't execute; clone them into fresh nodes.
57
+ function runScripts(root) {
58
+ for (const old of root.querySelectorAll("script")) {
59
+ const s = document.createElement("script");
60
+ for (const att of old.attributes) s.setAttribute(att.name, att.value);
61
+ s.textContent = old.textContent;
62
+ old.replaceWith(s);
63
+ }
64
+ }
65
+
66
+ // transition.tsx → wrap the DOM swap in the View Transitions API. Config is
67
+ // injected as window.__SR_TRANSITION__ = { type, duration }. Falls back to a
68
+ // plain swap when unsupported, type "none", or reduced-motion is requested.
69
+ function injectTransitionStyle() {
70
+ if (document.getElementById("__sr-transition-style")) return;
71
+ const s = document.createElement("style");
72
+ s.id = "__sr-transition-style";
73
+ s.textContent =
74
+ "::view-transition-old(root),::view-transition-new(root){animation-duration:var(--sr-transition-duration,250ms)}" +
75
+ 'html[data-sr-transition="slide"]::view-transition-old(root){animation-name:sr-vt-slide-out}' +
76
+ 'html[data-sr-transition="slide"]::view-transition-new(root){animation-name:sr-vt-slide-in}' +
77
+ "@keyframes sr-vt-slide-out{to{opacity:0;transform:translateX(-24px)}}" +
78
+ "@keyframes sr-vt-slide-in{from{opacity:0;transform:translateX(24px)}}";
79
+ (document.head || document.documentElement).appendChild(s);
80
+ }
81
+
82
+ async function withTransition(apply) {
83
+ const t = window.__SR_TRANSITION__;
84
+ const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
85
+ if (!t || !t.type || t.type === "none" || reduce || typeof document.startViewTransition !== "function") {
86
+ apply();
87
+ return;
88
+ }
89
+ const root = document.documentElement;
90
+ root.dataset.srTransition = t.type;
91
+ if (t.duration) root.style.setProperty("--sr-transition-duration", t.duration + "ms");
92
+ try {
93
+ const vt = document.startViewTransition(() => apply());
94
+ await vt.finished;
95
+ } catch {
96
+ // DOM was already updated inside the callback; nothing to recover.
97
+ } finally {
98
+ delete root.dataset.srTransition;
99
+ }
100
+ }
101
+
102
+ function syncHead(doc) {
103
+ const title = doc.querySelector("title");
104
+ if (title) document.title = title.textContent || document.title;
105
+ const selectors = ['meta[name="description"]', 'meta[property^="og:"]', 'meta[name^="twitter:"]', 'link[rel="canonical"]'];
106
+ for (const sel of selectors) {
107
+ document.head.querySelectorAll(sel).forEach((m) => m.remove());
108
+ doc.head.querySelectorAll(sel).forEach((m) => document.head.appendChild(m.cloneNode(true)));
109
+ }
110
+ }
111
+
112
+ // pending.tsx overlay: revealed only if a navigation outlasts the threshold,
113
+ // so fast (cached) navigations don't flash it.
114
+ let pendingTimer = null;
115
+ function showPending() {
116
+ const el = document.getElementById("__sr-pending");
117
+ if (el) el.hidden = false;
118
+ }
119
+ function clearPending() {
120
+ if (pendingTimer) {
121
+ clearTimeout(pendingTimer);
122
+ pendingTimer = null;
123
+ }
124
+ const el = document.getElementById("__sr-pending");
125
+ if (el) el.hidden = true;
126
+ }
127
+
128
+ async function navigate(href, { push = true, scroll = true, replace = false } = {}) {
129
+ let html;
130
+ nav.active = href;
131
+ if (pendingTimer) clearTimeout(pendingTimer);
132
+ const delay = typeof window.__SR_NAV_PENDING_DELAY === "number" ? window.__SR_NAV_PENDING_DELAY : 120;
133
+ pendingTimer = setTimeout(showPending, delay);
134
+ window.dispatchEvent(new CustomEvent("sr:navigate-start", { detail: { url: href } }));
135
+ try {
136
+ html = await fetchDoc(href);
137
+ } catch {
138
+ location.href = href; // hard fallback
139
+ return;
140
+ }
141
+ if (nav.active !== href) return; // superseded by a newer navigation
142
+ const doc = new DOMParser().parseFromString(html, "text/html");
143
+ if (!doc.body) {
144
+ location.href = href;
145
+ return;
146
+ }
147
+ const apply = () => {
148
+ document.body.replaceWith(doc.body);
149
+ runScripts(document.body);
150
+ syncHead(doc);
151
+ };
152
+ await withTransition(apply);
153
+ if (push) {
154
+ if (replace) history.replaceState({ srNav: true }, "", href);
155
+ else history.pushState({ srNav: true }, "", href);
156
+ }
157
+ if (scroll) window.scrollTo(0, 0);
158
+ clearPending();
159
+ window.dispatchEvent(new CustomEvent("sr:navigate-end", { detail: { url: href } }));
160
+ }
161
+ nav.navigate = navigate;
162
+
163
+ document.addEventListener(
164
+ "click",
165
+ (e) => {
166
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
167
+ const a = e.target.closest && e.target.closest("a");
168
+ const url = internalAnchor(a);
169
+ if (!url) return;
170
+ e.preventDefault();
171
+ if (url.href === location.href) return;
172
+ navigate(url.href, {
173
+ push: true,
174
+ scroll: a.dataset.srScroll !== "false",
175
+ replace: a.dataset.srReplace === "true",
176
+ });
177
+ },
178
+ false,
179
+ );
180
+
181
+ window.addEventListener("popstate", () => {
182
+ navigate(location.href, { push: false, scroll: false });
183
+ });
184
+
185
+ // ── Prefetch (prefetch.ts) ────────────────────────────────────────────────
186
+ // Strategy comes from the per-route prefetch.ts, injected as
187
+ // window.__SR_PREFETCH__ = { strategy, margin? }. Default: "hover".
188
+ // Per-link opt-out via data-sr-prefetch="false".
189
+ function strategy() {
190
+ const c = window.__SR_PREFETCH__;
191
+ return (c && c.strategy) || "hover";
192
+ }
193
+ function prefetchableURL(a) {
194
+ if (!a || a.dataset.srPrefetch === "false") return null;
195
+ return internalAnchor(a);
196
+ }
197
+ function intent(e) {
198
+ if (strategy() !== "hover") return;
199
+ const a = e.target.closest && e.target.closest("a");
200
+ const url = prefetchableURL(a);
201
+ if (url && url.href !== location.href) nav.prefetch(url.href);
202
+ }
203
+ document.addEventListener("mouseover", intent, { passive: true });
204
+ document.addEventListener("focusin", intent);
205
+ document.addEventListener("touchstart", intent, { passive: true });
206
+
207
+ let io = null;
208
+ function scanViewport() {
209
+ if (io) {
210
+ io.disconnect();
211
+ io = null;
212
+ }
213
+ if (strategy() !== "viewport" || !("IntersectionObserver" in window)) return;
214
+ const margin = (window.__SR_PREFETCH__ && window.__SR_PREFETCH__.margin) || "200px";
215
+ io = new IntersectionObserver(
216
+ (entries) => {
217
+ for (const en of entries) {
218
+ if (!en.isIntersecting) continue;
219
+ const url = prefetchableURL(en.target);
220
+ if (url && url.href !== location.href) nav.prefetch(url.href);
221
+ io.unobserve(en.target);
222
+ }
223
+ },
224
+ { rootMargin: margin },
225
+ );
226
+ document.querySelectorAll("a[href]").forEach((a) => {
227
+ if (prefetchableURL(a)) io.observe(a);
228
+ });
229
+ }
230
+ window.addEventListener("sr:navigate-end", scanViewport);
231
+ if (document.readyState === "loading") {
232
+ document.addEventListener("DOMContentLoaded", scanViewport, { once: true });
233
+ } else {
234
+ scanViewport();
235
+ }
236
+
237
+ injectTransitionStyle();
238
+ })();
package/dist/link.d.ts CHANGED
@@ -6,5 +6,5 @@ export interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>,
6
6
  replace?: boolean;
7
7
  scroll?: boolean;
8
8
  }
9
- export declare function Link({ href, prefetch: _prefetch, replace: _replace, scroll: _scroll, children, ...rest }: LinkProps): import("react").JSX.Element;
9
+ export declare function Link({ href, prefetch, replace, scroll, children, ...rest }: LinkProps): import("react").JSX.Element;
10
10
  //# sourceMappingURL=link.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"link.d.ts","sourceRoot":"","sources":["../src/link.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAE7D,MAAM,WAAW,SAAU,SAAQ,IAAI,CAAC,oBAAoB,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACtF,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,SAAS,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,wBAAgB,IAAI,CAAC,EACnB,IAAI,EACJ,QAAQ,EAAE,SAAS,EACnB,OAAO,EAAE,QAAQ,EACjB,MAAM,EAAE,OAAO,EACf,QAAQ,EACR,GAAG,IAAI,EACR,EAAE,SAAS,+BAMX"}
1
+ {"version":3,"file":"link.d.ts","sourceRoot":"","sources":["../src/link.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAE7D,MAAM,WAAW,SAAU,SAAQ,IAAI,CAAC,oBAAoB,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACtF,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,SAAS,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,wBAAgB,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,+BAYrF"}
package/dist/link.jsx CHANGED
@@ -1,5 +1,14 @@
1
- export function Link({ href, prefetch: _prefetch, replace: _replace, scroll: _scroll, children, ...rest }) {
2
- return (<a href={href} {...rest}>
1
+ export function Link({ href, prefetch, replace, scroll, children, ...rest }) {
2
+ // The client navigator (runtime/navigator.js) reads these data-* hints to
3
+ // drive SPA navigation. Plain <a> semantics are preserved when JS is off.
4
+ const dataAttrs = {};
5
+ if (replace)
6
+ dataAttrs["data-sr-replace"] = "true";
7
+ if (scroll === false)
8
+ dataAttrs["data-sr-scroll"] = "false";
9
+ if (prefetch === false)
10
+ dataAttrs["data-sr-prefetch"] = "false";
11
+ return (<a href={href} {...dataAttrs} {...rest}>
3
12
  {children}
4
13
  </a>);
5
14
  }
package/dist/link.jsx.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"link.jsx","sourceRoot":"","sources":["../src/link.tsx"],"names":[],"mappings":"AAUA,MAAM,UAAU,IAAI,CAAC,EACnB,IAAI,EACJ,QAAQ,EAAE,SAAS,EACnB,OAAO,EAAE,QAAQ,EACjB,MAAM,EAAE,OAAO,EACf,QAAQ,EACR,GAAG,IAAI,EACG;IACV,OAAO,CACL,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,CACtB;MAAA,CAAC,QAAQ,CACX;IAAA,EAAE,CAAC,CAAC,CACL,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"link.jsx","sourceRoot":"","sources":["../src/link.tsx"],"names":[],"mappings":"AAUA,MAAM,UAAU,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAa;IACpF,0EAA0E;IAC1E,0EAA0E;IAC1E,MAAM,SAAS,GAA2B,EAAE,CAAC;IAC7C,IAAI,OAAO;QAAE,SAAS,CAAC,iBAAiB,CAAC,GAAG,MAAM,CAAC;IACnD,IAAI,MAAM,KAAK,KAAK;QAAE,SAAS,CAAC,gBAAgB,CAAC,GAAG,OAAO,CAAC;IAC5D,IAAI,QAAQ,KAAK,KAAK;QAAE,SAAS,CAAC,kBAAkB,CAAC,GAAG,OAAO,CAAC;IAChE,OAAO,CACL,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,SAAS,CAAC,CAAC,IAAI,IAAI,CAAC,CACrC;MAAA,CAAC,QAAQ,CACX;IAAA,EAAE,CAAC,CAAC,CACL,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swift-rust",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "The full-stack React framework powered with Rust + Bun. TSX-first, Rust rendering, 10x faster than Next.js, single binary deploy.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://swift-rust.dev",
@@ -22,6 +22,7 @@
22
22
  "types": "./dist/index.d.ts",
23
23
  "exports": {
24
24
  "./package.json": "./package.json",
25
+ "./tsconfig.base.json": "./tsconfig.base.json",
25
26
  ".": {
26
27
  "types": "./dist/index.d.ts",
27
28
  "import": "./dist/index.js"
@@ -74,7 +75,8 @@
74
75
  "dist",
75
76
  "bin",
76
77
  "scripts",
77
- "native"
78
+ "native",
79
+ "tsconfig.base.json"
78
80
  ],
79
81
  "scripts": {
80
82
  "build": "tsc -p tsconfig.json",
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "display": "swift-rust base",
4
+ "compilerOptions": {
5
+ "target": "ES2022",
6
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "moduleResolution": "bundler",
9
+ "moduleDetection": "force",
10
+ "jsx": "react-jsx",
11
+ "allowJs": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "esModuleInterop": true,
15
+ "skipLibCheck": true,
16
+ "strict": true,
17
+ "noUncheckedIndexedAccess": true,
18
+ "forceConsistentCasingInFileNames": true,
19
+ "noEmit": true
20
+ }
21
+ }