swift-rust 1.5.0 → 1.8.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
@@ -500,23 +500,29 @@ function simpleHash(s) {
500
500
  return (h >>> 0).toString(36);
501
501
  }
502
502
  async function localizeIslands(html) {
503
- const re = /\/_swift-rust\/island\.js\?p=([^"]+)/g;
504
- const found = new Set();
505
- let m;
506
- while ((m = re.exec(html)) !== null) found.add(m[1]);
507
- if (found.size === 0) return html;
508
503
  let out = html;
509
- for (const enc of found) {
510
- let staticUrl = islandWritten.get(enc);
511
- if (!staticUrl) {
512
- const { status, body } = await fetchRoute(`/_swift-rust/island.js?p=${enc}`);
513
- if (status !== 200) continue;
514
- const rel = `_swift-rust/island/${simpleHash(enc)}.js`;
515
- writeRawFile(STATIC_DIR, rel, body);
516
- staticUrl = `/${rel}`;
517
- islandWritten.set(enc, staticUrl);
504
+ // Page-level islands (/_swift-rust/island.js) and component-level islands
505
+ // (/_swift-rust/island-comp.js) both reference a dev-only bundle keyed by an
506
+ // encoded source path. For the static export we fetch each bundle once, write
507
+ // it as a real file, and rewrite the script src to point at it.
508
+ for (const endpoint of ["island.js", "island-comp.js"]) {
509
+ const re = new RegExp(`/_swift-rust/${endpoint.replace(".", "\\.")}\\?p=([^"]+)`, "g");
510
+ const found = new Set();
511
+ let m;
512
+ while ((m = re.exec(out)) !== null) found.add(m[1]);
513
+ for (const enc of found) {
514
+ const key = `${endpoint}:${enc}`;
515
+ let staticUrl = islandWritten.get(key);
516
+ if (!staticUrl) {
517
+ const { status, body } = await fetchRoute(`/_swift-rust/${endpoint}?p=${enc}`);
518
+ if (status !== 200) continue;
519
+ const rel = `_swift-rust/island/${simpleHash(key)}.js`;
520
+ writeRawFile(STATIC_DIR, rel, body);
521
+ staticUrl = `/${rel}`;
522
+ islandWritten.set(key, staticUrl);
523
+ }
524
+ out = out.split(`/_swift-rust/${endpoint}?p=${enc}`).join(staticUrl);
518
525
  }
519
- out = out.split(`/_swift-rust/island.js?p=${enc}`).join(staticUrl);
520
526
  }
521
527
  return out;
522
528
  }
@@ -364,6 +364,192 @@ const externalizeDepsPlugin = {
364
364
  },
365
365
  };
366
366
 
367
+ // ── Component-level "use client" islands ───────────────────────────────────
368
+ // A `use client` *component* imported into a server-rendered page can't just be
369
+ // SSR'd to static HTML — its useState/effects/handlers would never run. So when
370
+ // the SSR bundle pulls in a client component, we wrap it: on the server it still
371
+ // renders to HTML (good for SEO + no flash), but inside a marker element that
372
+ // carries the component's source path, export name, and serialized props. A tiny
373
+ // client runtime then hydrates each marker from a per-component browser bundle.
374
+ //
375
+ // Limitations (documented): props must be JSON-serializable; `children` and
376
+ // function props don't cross the boundary, so islands should be self-contained
377
+ // leaves (they manage their own children). Nested client components imported by
378
+ // a client component are bundled into that island, not re-wrapped.
379
+
380
+ const ISLAND_EXTS = [".tsx", ".ts", ".jsx", ".js", ".mjs"];
381
+
382
+ // Resolve a relative / absolute / "@/"-aliased specifier to a real file path.
383
+ function resolveIslandSpecifier(spec, importer) {
384
+ let base;
385
+ if (spec.startsWith("@/")) base = resolve(cwd, "src", spec.slice(2));
386
+ else if (spec.startsWith("/")) base = spec;
387
+ else if (spec.startsWith(".")) base = resolve(dirname(importer || cwd), spec);
388
+ else return null;
389
+ const candidates = [base, ...ISLAND_EXTS.map((e) => base + e), ...ISLAND_EXTS.map((e) => join(base, "index" + e))];
390
+ for (const c of candidates) {
391
+ try { if (statSync(c).isFile()) return c; } catch {}
392
+ }
393
+ return null;
394
+ }
395
+
396
+ // Extract export names from a module's source so we can re-export wrapped
397
+ // versions. We only wrap Capitalized exports (React component convention) + the
398
+ // default export; lowercase exports pass through untouched.
399
+ function detectIslandExports(src) {
400
+ const named = new Set();
401
+ let hasDefault = false;
402
+ // strip block/line comments cheaply to avoid false matches
403
+ const code = src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/[^\n]*/g, "$1");
404
+ if (/\bexport\s+default\b/.test(code)) hasDefault = true;
405
+ for (const m of code.matchAll(/\bexport\s+(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g)) {
406
+ named.add(m[1]);
407
+ }
408
+ for (const m of code.matchAll(/\bexport\s*\{([^}]*)\}/g)) {
409
+ for (const part of m[1].split(",")) {
410
+ const name = part.trim().split(/\s+as\s+/).pop()?.trim();
411
+ if (name && name !== "default") named.add(name);
412
+ }
413
+ }
414
+ return { hasDefault, named: [...named] };
415
+ }
416
+
417
+ // Temp sibling files holding verbatim copies of client components, so the
418
+ // wrapper can import the *real* module under a distinct on-disk path (Bun
419
+ // dedupes modules by resolved path, so a query-string alias would collapse the
420
+ // real module into the wrapper). Cleaned up after each SSR build.
421
+ const islandRawTemps = [];
422
+
423
+ // Build the source of an island wrapper module for a given client component file.
424
+ function islandWrapperSource(abs) {
425
+ const src = readFileSync(abs, "utf8");
426
+ const { hasDefault, named } = detectIslandExports(src);
427
+ // Write the real component to a unique sibling so imports resolve identically.
428
+ const rawName = `.__sr_raw_${process.pid}_${Math.random().toString(36).slice(2)}__${basename(abs)}`;
429
+ const rawPath = join(dirname(abs), rawName);
430
+ writeFileSync(rawPath, src);
431
+ islandRawTemps.push(rawPath);
432
+ const rawSpec = JSON.stringify("./" + rawName);
433
+ let out = `import { createElement as __h } from "react";
434
+ import * as __real from ${rawSpec};
435
+ const __src = ${JSON.stringify(abs)};
436
+ function __ser(props){
437
+ var out = {};
438
+ for (var k in props){
439
+ if (k === "children") continue;
440
+ var v = props[k]; var t = typeof v;
441
+ if (v === null || t === "string" || t === "number" || t === "boolean" || (t === "object" && typeof v.$$typeof === "undefined")){
442
+ try { JSON.stringify(v); out[k] = v; } catch (e) {}
443
+ }
444
+ }
445
+ return JSON.stringify(out).replace(/</g, "\\\\u003c");
446
+ }
447
+ function __wrap(Comp, name){
448
+ if (typeof Comp !== "function") return Comp;
449
+ function SrIsland(props){
450
+ return __h("div", {
451
+ "data-sr-island": "",
452
+ "data-sr-island-src": __src,
453
+ "data-sr-island-export": name,
454
+ "data-sr-island-props": __ser(props),
455
+ style: { display: "contents" },
456
+ }, __h(Comp, props));
457
+ }
458
+ SrIsland.displayName = "Island(" + name + ")";
459
+ return SrIsland;
460
+ }
461
+ `;
462
+ if (hasDefault) out += `export default __wrap(__real.default, "default");\n`;
463
+ for (const n of named) {
464
+ if (/^[A-Z]/.test(n)) out += `export const ${n} = __wrap(__real[${JSON.stringify(n)}], ${JSON.stringify(n)});\n`;
465
+ else out += `export const ${n} = __real[${JSON.stringify(n)}];\n`;
466
+ }
467
+ return out;
468
+ }
469
+
470
+ // Bun plugin: wrap client components imported by *server* modules as islands.
471
+ const clientIslandPlugin = {
472
+ name: "sr-client-islands",
473
+ setup(build) {
474
+ build.onResolve({ filter: /.*/ }, (args) => {
475
+ const p = args.path;
476
+ if (!(p.startsWith(".") || p.startsWith("/") || p.startsWith("@/"))) return undefined;
477
+ // Never wrap our own temp raw copies, and don't wrap when the importer is
478
+ // itself a client module — nested client components belong to that
479
+ // island's bundle, not a new boundary.
480
+ if (/\.__sr_raw_/.test(p)) return undefined;
481
+ if (args.importer && hasUseDirective(args.importer, "client")) return undefined;
482
+ const abs = resolveIslandSpecifier(p, args.importer);
483
+ if (!abs || !hasUseDirective(abs, "client")) return undefined;
484
+ return { path: abs, namespace: "sr-island" };
485
+ });
486
+ build.onLoad({ filter: /.*/, namespace: "sr-island" }, (args) => ({
487
+ contents: islandWrapperSource(args.path),
488
+ loader: "js",
489
+ resolveDir: dirname(args.path),
490
+ }));
491
+ },
492
+ };
493
+
494
+ // Cache for per-component browser island bundles (src -> { gen, code }).
495
+ // Each bundle is self-mounting: it imports the client component, finds every
496
+ // marker on the page for this source, and hydrates it. React is bundled in, so a
497
+ // single <script type="module"> per source needs no import map or shared runtime.
498
+ const componentIslandCache = new Map();
499
+ async function buildComponentIslandBundle(srcFile) {
500
+ if (typeof Bun === "undefined" || typeof Bun.build !== "function") {
501
+ throw new Error("client islands require the Bun runtime");
502
+ }
503
+ const cached = componentIslandCache.get(srcFile);
504
+ if (cached && cached.gen === buildGeneration) return cached.code;
505
+
506
+ const entryPath = join(dirname(srcFile), `.__sr_cisland_${process.pid}_${Date.now()}.js`);
507
+ const entry = `import { hydrateRoot, createRoot } from "react-dom/client";
508
+ import { createElement } from "react";
509
+ import * as __mod from ${JSON.stringify(srcFile)};
510
+ var __src = ${JSON.stringify(srcFile)};
511
+ var nodes = document.querySelectorAll('[data-sr-island]');
512
+ for (var i = 0; i < nodes.length; i++) {
513
+ var el = nodes[i];
514
+ if (el.getAttribute("data-sr-island-src") !== __src || el.__srHydrated) continue;
515
+ el.__srHydrated = true;
516
+ var name = el.getAttribute("data-sr-island-export") || "default";
517
+ var Comp = __mod[name] || __mod.default;
518
+ if (typeof Comp !== "function") { console.warn("[swift-rust] island export missing:", name, __src); continue; }
519
+ var props = {};
520
+ try { props = JSON.parse(el.getAttribute("data-sr-island-props") || "{}"); } catch (e) {}
521
+ var element = createElement(Comp, props);
522
+ try { hydrateRoot(el, element); }
523
+ catch (err) { el.innerHTML = ""; createRoot(el).render(element); }
524
+ }
525
+ `;
526
+ writeFileSync(entryPath, entry);
527
+ try {
528
+ const result = await Bun.build({
529
+ entrypoints: [entryPath],
530
+ target: "browser",
531
+ minify: true,
532
+ define: { "process.env.NODE_ENV": '"production"' },
533
+ });
534
+ if (!result.success) throw new Error(result.logs.map((l) => l.message).join("\n"));
535
+ const code = await result.outputs[0].text();
536
+ componentIslandCache.set(srcFile, { gen: buildGeneration, code });
537
+ return code;
538
+ } finally {
539
+ try { unlinkSync(entryPath); } catch {}
540
+ }
541
+ }
542
+
543
+ // Collect unique client-island source files referenced in rendered HTML.
544
+ function collectIslandSources(html) {
545
+ const srcs = new Set();
546
+ if (!html) return srcs;
547
+ for (const m of html.matchAll(/data-sr-island-src="([^"]+)"/g)) {
548
+ srcs.add(m[1].replace(/&amp;/g, "&").replace(/&quot;/g, '"'));
549
+ }
550
+ return srcs;
551
+ }
552
+
367
553
  const ssrBundleCache = new Map(); // file -> { gen, mod }
368
554
 
369
555
  async function loadModuleFresh(filePath) {
@@ -373,11 +559,18 @@ async function loadModuleFresh(filePath) {
373
559
  const cached = ssrBundleCache.get(filePath);
374
560
  if (cached && cached.gen === buildGeneration) return cached.mod;
375
561
 
376
- const result = await Bun.build({
377
- entrypoints: [filePath],
378
- target: "bun",
379
- plugins: [externalizeDepsPlugin],
380
- });
562
+ islandRawTemps.length = 0;
563
+ let result;
564
+ try {
565
+ result = await Bun.build({
566
+ entrypoints: [filePath],
567
+ target: "bun",
568
+ plugins: [clientIslandPlugin, externalizeDepsPlugin],
569
+ });
570
+ } finally {
571
+ for (const t of islandRawTemps) { try { unlinkSync(t); } catch {} }
572
+ islandRawTemps.length = 0;
573
+ }
381
574
  if (!result.success) {
382
575
  throw new Error(result.logs.map((l) => l.message).join("\n"));
383
576
  }
@@ -771,6 +964,80 @@ export function isClientPage(file) {
771
964
  return false;
772
965
  }
773
966
 
967
+ // True when a file's leading directive prologue contains `'use server'`.
968
+ // (Component-level "use server" is NOT supported — swift-rust does classic SSR +
969
+ // client islands, not React Server Components / Flight. The only legitimate home
970
+ // for 'use server' is a route action.ts file, which is loaded by the router, not
971
+ // imported into a page's render tree.)
972
+ function hasServerDirective(file) {
973
+ try {
974
+ for (const raw of readFileSync(file, "utf8").split("\n")) {
975
+ const t = raw.trim();
976
+ if (!t || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*")) continue;
977
+ if (/^["']use server["'];?$/.test(t)) return true;
978
+ if (/^["']use [a-z]+["'];?$/.test(t)) continue;
979
+ return false;
980
+ }
981
+ } catch {}
982
+ return false;
983
+ }
984
+
985
+ // Walk a page's transitive *relative* import graph looking for a module that
986
+ // declares `'use server'`. Returns the offending file (or null). We only follow
987
+ // ./ and ../ specifiers — that's where a developer's own components live, and it
988
+ // keeps the scan cheap and free of node_modules false positives.
989
+ const RELATIVE_EXTS = [".tsx", ".ts", ".jsx", ".js", ".mjs"];
990
+ function findServerComponentInClientGraph(entryFile, seen = new Set()) {
991
+ const resolved = resolve(entryFile);
992
+ if (seen.has(resolved)) return null;
993
+ seen.add(resolved);
994
+ let src;
995
+ try {
996
+ src = readFileSync(resolved, "utf8");
997
+ } catch {
998
+ return null;
999
+ }
1000
+ // The entry (the page) is allowed to be 'use client'; we only flag *imports*.
1001
+ for (const m of src.matchAll(/\b(?:import|export)\b[^'"]*['"](\.[^'"]+)['"]/g)) {
1002
+ const spec = m[1];
1003
+ const base = resolve(dirname(resolved), spec);
1004
+ let target = null;
1005
+ const candidates = [base, ...RELATIVE_EXTS.map((e) => base + e), ...RELATIVE_EXTS.map((e) => join(base, "index" + e))];
1006
+ for (const c of candidates) {
1007
+ try {
1008
+ if (statSync(c).isFile()) { target = c; break; }
1009
+ } catch {}
1010
+ }
1011
+ if (!target) continue;
1012
+ if (hasServerDirective(target)) return target;
1013
+ const nested = findServerComponentInClientGraph(target, seen);
1014
+ if (nested) return nested;
1015
+ }
1016
+ return null;
1017
+ }
1018
+
1019
+ // Throw a clear, actionable error when a 'use client' page pulls a component
1020
+ // that declares 'use server'. Replaces the old silent no-op (the component used
1021
+ // to be imported but its directive ignored, so nothing ran and nothing warned).
1022
+ export function assertNoServerComponentInClientPage(pageFile) {
1023
+ if (!isClientPage(pageFile)) return;
1024
+ const offender = findServerComponentInClientGraph(pageFile);
1025
+ if (offender) {
1026
+ const rel = (f) => relative(cwd, f);
1027
+ throw new Error(
1028
+ `Unsupported: '${rel(offender)}' declares 'use server' but is imported into the ` +
1029
+ `'use client' page '${rel(pageFile)}'.\n\n` +
1030
+ `swift-rust uses classic SSR + client islands, not React Server Components, so a ` +
1031
+ `'use server' component inside a client tree cannot run on the server — React forbids ` +
1032
+ `importing a Server Component into a Client Component.\n\n` +
1033
+ `Fix one of these:\n` +
1034
+ ` • Remove the 'use server' directive — the component will render normally inside the client page.\n` +
1035
+ ` • Move server-only work into a route loader (loader.ts) or action (action.ts) and pass the data down as props.\n` +
1036
+ ` • Keep the page server-rendered (drop 'use client' on the page) and mark only the interactive leaf with 'use client'.`,
1037
+ );
1038
+ }
1039
+ }
1040
+
774
1041
  const VALID_RUNTIMES = new Set(["bun", "edge", "node", "worker"]);
775
1042
 
776
1043
  // Detect a `'use bun' | 'use edge' | 'use node'` directive among a file's
@@ -809,6 +1076,7 @@ async function buildIslandBundle(pageFile) {
809
1076
  if (typeof Bun === "undefined" || typeof Bun.build !== "function") {
810
1077
  throw new Error("client islands require the Bun runtime");
811
1078
  }
1079
+ assertNoServerComponentInClientPage(pageFile);
812
1080
  const cached = islandBundleCache.get(pageFile);
813
1081
  if (cached && cached.gen === buildGeneration) return cached.code;
814
1082
 
@@ -1036,9 +1304,39 @@ async function readMergedConfig(chain, route) {
1036
1304
  // A route is "dynamic" (emitted as a request-time function) when it explicitly
1037
1305
  // opts into a runtime via a directive, config.ts/edge/worker, or config.dynamic.
1038
1306
  config.dynamic = Boolean(directive) || explicitRuntime || config.dynamic === true;
1307
+
1308
+ // `use static` (page directive) or config.static: a hard guarantee that the
1309
+ // route is purely static — no request-time/dynamic behavior. We fail loudly on
1310
+ // any conflict so the guarantee can't silently rot, then force the route static
1311
+ // and tag it for aggressive caching. (The actual prerender happens in build;
1312
+ // here we enforce + emit the cache contract.)
1313
+ const wantsStatic =
1314
+ (route?.file && hasUseDirective(route.file, "static")) ||
1315
+ collectRouteFiles(chain, "config").some(({ file }) => hasUseDirective(file, "static")) ||
1316
+ config.static === true;
1317
+ if (wantsStatic) {
1318
+ const reasons = [];
1319
+ if (directive) reasons.push(`a 'use ${directive}' runtime directive (declares a request-time runtime)`);
1320
+ else if (explicitRuntime) reasons.push("a runtime set by config.ts / edge.ts / worker.ts");
1321
+ if (config.dynamic === true && !directive && !explicitRuntime) reasons.push("config.dynamic = true");
1322
+ if (collectRouteFiles(chain, "action").length) reasons.push("an action.ts (mutations are dynamic)");
1323
+ if (reasons.length) {
1324
+ const rel = route?.file ? relative(cwd, route.file) : "(route)";
1325
+ throw new Error(
1326
+ `Route '${rel}' is marked 'use static' but is dynamic: it has ${reasons.join(", ")}.\n\n` +
1327
+ `A 'use static' route is prerendered and CDN-cached, so it can't use request-time features. Fix one:\n` +
1328
+ ` • Remove 'use static' if the route really needs to be dynamic.\n` +
1329
+ ` • Drop the runtime directive / dynamic config / action.ts so the route can be fully static.\n` +
1330
+ ` • For periodic refresh, keep 'use static' and add a revalidate.ts (ISR), which stays static + cached.`,
1331
+ );
1332
+ }
1333
+ config.dynamic = false;
1334
+ config.static = true;
1335
+ }
1039
1336
  config.headers = {
1040
1337
  "x-swift-rust-runtime": runtime,
1041
1338
  ...(config.dynamic ? { "x-swift-rust-dynamic": "1" } : {}),
1339
+ ...(config.static ? { "x-swift-rust-render": "static" } : {}),
1042
1340
  ...config.headers,
1043
1341
  };
1044
1342
  return config;
@@ -1416,6 +1714,11 @@ async function renderRoute(urlPath, req) {
1416
1714
  });
1417
1715
 
1418
1716
  const clientPage = isClientPage(route.file);
1717
+ if (clientPage) {
1718
+ // Loud failure (was a silent no-op): a 'use server' component imported into
1719
+ // a 'use client' page can't run on the server in this SSR+islands model.
1720
+ assertNoServerComponentInClientPage(route.file);
1721
+ }
1419
1722
  let tree = React.createElement(Page, { params: paramsProxy });
1420
1723
  if (clientPage) {
1421
1724
  // Wrap in a hydration root so the client bundle can mount into it.
@@ -2156,6 +2459,26 @@ async function handleFetch(req) {
2156
2459
  return new Response("// island not found", { status: 404, headers: { "Content-Type": "application/javascript" } });
2157
2460
  }
2158
2461
 
2462
+ // Per-component client-island bundle (self-mounting; hydrates its markers).
2463
+ if (pathname === "/_swift-rust/island-comp.js") {
2464
+ const p = url.searchParams.get("p");
2465
+ if (p && existsSync(p)) {
2466
+ try {
2467
+ const code = await buildComponentIslandBundle(p);
2468
+ return new Response(code, {
2469
+ headers: { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-cache" },
2470
+ });
2471
+ } catch (e) {
2472
+ logError(e, `component island bundle failed for ${p}`);
2473
+ return new Response(`/* island build error: ${String(e?.message || e).replace(/\*\//g, "* /")} */`, {
2474
+ status: 500,
2475
+ headers: { "Content-Type": "application/javascript; charset=utf-8" },
2476
+ });
2477
+ }
2478
+ }
2479
+ return new Response("// island not found", { status: 404, headers: { "Content-Type": "application/javascript" } });
2480
+ }
2481
+
2159
2482
  if (pathname.startsWith("/_swift-rust/")) {
2160
2483
  return new Response("Not found", { status: 404 });
2161
2484
  }
@@ -2364,6 +2687,12 @@ async function handleFetch(req) {
2364
2687
  const src = `/_swift-rust/island.js?p=${encodeURIComponent(renderResult.pageFile)}`;
2365
2688
  doc = doc.replace("</body>", `<script type="module" src="${src}"></script>\n</body>`);
2366
2689
  }
2690
+ // Component-level "use client" islands: one self-mounting module per unique
2691
+ // source referenced by a marker in the rendered HTML.
2692
+ for (const islandSrc of collectIslandSources(doc)) {
2693
+ const s = `/_swift-rust/island-comp.js?p=${encodeURIComponent(islandSrc)}`;
2694
+ doc = doc.replace("</body>", `<script type="module" src="${s}"></script>\n</body>`);
2695
+ }
2367
2696
  // pending.tsx → hidden overlay the client navigator reveals while a
2368
2697
  // navigation is in flight (see runtime/navigator.js).
2369
2698
  const pendingOverlay = await renderPendingOverlay(renderResult.segments);
@@ -2380,12 +2709,28 @@ async function handleFetch(req) {
2380
2709
  const json = JSON.stringify(transitionCfg).replace(/</g, "\\u003c");
2381
2710
  doc = doc.replace("</body>", `<script>window.__SR_TRANSITION__=${json}</script>\n</body>`);
2382
2711
  }
2712
+ // `use static` guarantee: a static route must not perform request-time work.
2713
+ // Setting cookies during render is the one dynamic signal we can catch at
2714
+ // runtime — fail loudly rather than silently shipping a wrong cache header.
2715
+ if (renderResult.config?.static && (renderResult.setCookies || []).length) {
2716
+ const e = new Error(
2717
+ "A 'use static' route set a cookie during render, which is a dynamic action. " +
2718
+ "Remove 'use static' or stop setting cookies on this route.",
2719
+ );
2720
+ logError(e, "use static violation");
2721
+ return new Response(errorOverlayHTML(e.message, e.stack || ""), {
2722
+ status: 500,
2723
+ headers: { "Content-Type": "text/html; charset=utf-8" },
2724
+ });
2725
+ }
2383
2726
  const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
2384
2727
  for (const c of renderResult.setCookies || []) headers.append("Set-Cookie", c);
2385
2728
  // config.ts headers
2386
2729
  for (const [k, v] of Object.entries(renderResult.config?.headers || {})) headers.set(k, String(v));
2387
- // revalidate.ts → Cache-Control + cache tags
2388
- const cc = cacheControlFromPlan(renderResult.revalidatePlan);
2730
+ // revalidate.ts → Cache-Control + cache tags. A 'use static' route with no
2731
+ // explicit revalidate plan gets a long-lived, revalidatable CDN cache.
2732
+ let cc = cacheControlFromPlan(renderResult.revalidatePlan);
2733
+ if (!cc && renderResult.config?.static) cc = "public, max-age=0, s-maxage=31536000, stale-while-revalidate";
2389
2734
  if (cc) headers.set("Cache-Control", cc);
2390
2735
  const tags = renderResult.revalidatePlan?.tags || renderResult.revalidatePlan?.invalidate;
2391
2736
  if (Array.isArray(tags) && tags.length) headers.set("x-vercel-cache-tags", tags.join(","));
@@ -45,9 +45,17 @@
45
45
  }, 1800);
46
46
  }
47
47
 
48
- es.addEventListener("change", function (e) {
48
+ // The server sends *unnamed* SSE messages whose payload is an envelope:
49
+ // data: {"event":"change","data":{"type":"reload",...}}
50
+ // An unnamed SSE message fires the browser's default "message" event — NOT a
51
+ // named "change" event. Listening only on addEventListener("change") meant the
52
+ // handler never fired, so the browser never reloaded and you had to refresh by
53
+ // hand. We listen on "message" (and "change", for forward-compat) and unwrap
54
+ // the envelope, tolerating both the enveloped and flat payload shapes.
55
+ function handlePayload(raw) {
49
56
  try {
50
- var data = JSON.parse(e.data);
57
+ var msg = JSON.parse(raw);
58
+ var data = msg && msg.event && msg.data ? msg.data : msg;
51
59
  if (data.type === "reload") {
52
60
  showToast("↻ Reloading…");
53
61
  setTimeout(function () { location.reload(); }, 100);
@@ -64,7 +72,9 @@
64
72
  } catch (err) {
65
73
  console.error("[swift-rust] bad HMR payload", err);
66
74
  }
67
- });
75
+ }
76
+ es.addEventListener("message", function (e) { handlePayload(e.data); });
77
+ es.addEventListener("change", function (e) { handlePayload(e.data); });
68
78
 
69
79
  es.addEventListener("open", function () {
70
80
  if (window.__sr_setStatus) window.__sr_setStatus(true);
package/bin/swift-rust.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, realpathSync } from "node:fs";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { spawn } from "node:child_process";
@@ -55,6 +55,81 @@ if (cmd === "build") {
55
55
  process.exit(code);
56
56
  }
57
57
 
58
+ if (cmd === "upgrade" || cmd === "update") {
59
+ const projectDir = process.cwd();
60
+ const args = process.argv.slice(3);
61
+
62
+ if (args.includes("--help") || args.includes("-h")) {
63
+ process.stdout.write(
64
+ `swift-rust upgrade — update swift-rust to the latest release.\n\n` +
65
+ `Usage:\n` +
66
+ ` swift-rust upgrade update to the latest stable\n` +
67
+ ` swift-rust upgrade <tag> update to a dist-tag (e.g. canary) or version (e.g. 1.6.0)\n` +
68
+ ` swift-rust upgrade --tag <t> same, explicit flag\n\n` +
69
+ `Detects your package manager from the lockfile (bun/pnpm/npm/yarn).\n` +
70
+ `The @swift-rust/* packages (font, image, pdf, video, env) update transitively.\n`,
71
+ );
72
+ process.exit(0);
73
+ }
74
+
75
+ // Resolve the target tag/version: a bare arg, or --tag <t>, else "latest".
76
+ let target = "latest";
77
+ const tagIdx = args.indexOf("--tag");
78
+ if (tagIdx !== -1 && args[tagIdx + 1]) target = args[tagIdx + 1];
79
+ else {
80
+ const bare = args.find((a) => !a.startsWith("-"));
81
+ if (bare) target = bare;
82
+ }
83
+
84
+ // Detect the package manager from the lockfile in the project.
85
+ const pm =
86
+ existsSync(join(projectDir, "bun.lock")) || existsSync(join(projectDir, "bun.lockb"))
87
+ ? "bun"
88
+ : existsSync(join(projectDir, "pnpm-lock.yaml"))
89
+ ? "pnpm"
90
+ : existsSync(join(projectDir, "yarn.lock"))
91
+ ? "yarn"
92
+ : existsSync(join(projectDir, "package-lock.json"))
93
+ ? "npm"
94
+ : "bun";
95
+
96
+ const readInstalledVersion = () => {
97
+ try {
98
+ const pkgPath = join(projectDir, "node_modules", "swift-rust", "package.json");
99
+ if (existsSync(pkgPath)) {
100
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version;
101
+ }
102
+ } catch {}
103
+ return null;
104
+ };
105
+
106
+ if (!existsSync(join(projectDir, "package.json"))) {
107
+ process.stderr.write("swift-rust upgrade: no package.json here — run this inside your project.\n");
108
+ process.exit(1);
109
+ }
110
+
111
+ const before = readInstalledVersion();
112
+ const spec = `swift-rust@${target}`;
113
+ const addArgs = pm === "npm" ? ["install", spec] : ["add", spec];
114
+ process.stdout.write(`↻ Upgrading ${spec} with ${pm}${before ? ` (current: ${before})` : ""}…\n`);
115
+
116
+ const child = spawn(pm, addArgs, { stdio: "inherit", cwd: projectDir, env: process.env });
117
+ const code = await new Promise((r) => {
118
+ child.on("exit", (c) => r(c ?? 1));
119
+ child.on("error", (e) => {
120
+ process.stderr.write(`swift-rust upgrade: failed to run ${pm} (${e.message}).\n`);
121
+ r(1);
122
+ });
123
+ });
124
+ if (code === 0) {
125
+ const after = readInstalledVersion();
126
+ if (after && before && after !== before) process.stdout.write(`✓ swift-rust ${before} → ${after}\n`);
127
+ else if (after) process.stdout.write(`✓ swift-rust on ${after}\n`);
128
+ else process.stdout.write(`✓ done — restart your dev server to pick up the new runtime.\n`);
129
+ }
130
+ process.exit(code);
131
+ }
132
+
58
133
  function getBinaryName() {
59
134
  if (process.platform === "win32") return "swift-rust.exe";
60
135
  return "swift-rust";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swift-rust",
3
- "version": "1.5.0",
3
+ "version": "1.8.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",