swift-rust 1.5.1 → 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 +21 -15
- package/bin/dev-server.mjs +352 -7
- package/bin/swift-rust.js +76 -1
- package/package.json +1 -1
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
}
|
package/bin/dev-server.mjs
CHANGED
|
@@ -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(/&/g, "&").replace(/"/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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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(","));
|
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.
|
|
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",
|