siltrun 0.1.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/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # `siltrun` — the Silt CLI
2
+
3
+ Run an authoritative multiplayer room on your machine with one command. A room is a
4
+ reducer (`tick(state, inputs)`); the server owns the truth. See `archive/rung4-design/hello-room.md`
5
+ for the full quickstart.
6
+
7
+ ```bash
8
+ npx siltrun dev room.ts
9
+ ```
10
+
11
+ Your room is live at **http://localhost:4000** — point a client at it and go:
12
+
13
+ ```tsx
14
+ import { useRoom } from "@siltrun/react";
15
+ const { state, send } = useRoom("http://localhost:4000", { id: crypto.randomUUID() });
16
+ ```
17
+
18
+ ## What `siltrun dev` does
19
+
20
+ `siltrun dev <contract.ts>` stands up the whole local room and keeps it live as you edit:
21
+
22
+ 1. **Builds** the Go room-server binary if it is missing or stale (`go build`).
23
+ 2. **Bundles** your contract and its imports into a single module (Bun's bundler).
24
+ 3. **Runs the determinism doctor** (CONTRACT §4) if `packages/room-host/doctor.ts` is
25
+ present — it replays the tick sequence and reports drift at the exact tick/field. If
26
+ the doctor is not present yet, dev prints a notice and keeps running.
27
+ 4. **Boots the room-server in compute mode** (`--contract <bundle> --host <host.ts>`) and
28
+ scrapes the per-boot dev certificate hash it prints.
29
+ 5. **Serves room-info** on `:4000` — `GET /.well-known/silt` returns
30
+ `{ wtEndpoint, certHash, mode }` (SEAM §4) with permissive CORS, so a Vite app on
31
+ another port can discover the room and dial WebTransport with the cert pinned. **You
32
+ never touch `serverCertificateHashes`.**
33
+ 6. **Watches** the contract. On save it rebundles, re-runs the doctor, and restarts the
34
+ room (the simplest correct reload — live state resets in dev; the new cert flows
35
+ straight into room-info). It prints exactly what happened.
36
+ 7. **Ctrl-C** tears everything down — the room-server child and the room-info server.
37
+
38
+ The room-server is supervised: an unexpected crash triggers an exponential-backoff
39
+ restart (250ms → 5s cap), clearly logged, so a transient failure never ends your session.
40
+
41
+ ### Options
42
+
43
+ ```
44
+ siltrun dev <contract.ts> [options]
45
+ --wt-port <n> WebTransport port (default 4433, env SILT_WT_PORT)
46
+ --info-port <n> room-info port (default 4000, env SILT_INFO_PORT)
47
+ --wt-host <host> WebTransport bind host (default 127.0.0.1)
48
+ --no-doctor skip the determinism check on (re)load
49
+ ```
50
+
51
+ Path overrides (mainly for the monorepo / tests): `SILT_ROOM_SERVER_DIR`,
52
+ `SILT_ROOM_HOST_ENTRY`, `SILT_DOCTOR_ENTRY`.
53
+
54
+ ## `siltrun deploy`
55
+
56
+ ```bash
57
+ npx siltrun deploy room.ts
58
+ ```
59
+
60
+ Ships a contract to the hosted beta: bundle → local determinism doctor → upload
61
+ (`SILT_DEPLOY_TOKEN` required; see `src/deploy.ts`). Prints the room endpoint and the
62
+ check page on success.
63
+
64
+ Deployed room names are capped at **12 chars** (charset `[A-Za-z0-9._-]`): the box
65
+ maps each room to a Linux tap device `tap<room>`, and IFNAMSIZ caps interface names
66
+ at 15. The CLI enforces this client-side, before upload, with the same reason the
67
+ intake worker gives (DIG-676). Local `siltrun dev` has no length cap — no tap device.
68
+
69
+ ## Distribution — how the npm package is shaped
70
+
71
+ `npm install siltrun` must work from an empty directory with **Node as the only
72
+ prerequisite** (the wrangler/workerd bar). Three runtimes ship as packages:
73
+
74
+ - **The CLI itself** runs on Bun, but its `bin` is a plain-Node shim
75
+ (`bin/silt.mjs`) that locates Bun and re-execs `src/cli.ts` under it. Bun arrives
76
+ through the official `bun` npm package (a dependency); the shim resolves it in
77
+ order: `SILT_BUN` env → the `bun` package's populated `bin/bun.exe` → the
78
+ `@oven/bun-<platform>` package directly (covers `--ignore-scripts` installs) →
79
+ `bun` on PATH. The resolved path is exported as `SILT_BUN` so every downstream
80
+ spawn (doctor, and the Go server spawning the room-host) uses the same runtime.
81
+ - **The Go room-server** ships prebuilt in per-platform packages
82
+ (`@siltrun/room-server-<os>-<arch>`, `optionalDependencies` with `os`/`cpu` fields —
83
+ npm installs exactly the matching one). `paths.ts` resolves the installed binary;
84
+ in the monorepo the sibling Go source wins and is built/rebuilt from source.
85
+ - **The Bun room-host** (host.ts, doctor, deterministic realm) ships as
86
+ `@siltrun/room-host` — plain TS source, zero runtime deps; the CLI resolves it from
87
+ node_modules, or from the sibling package in-repo.
88
+
89
+ `create-siltrun` (`npm create siltrun my-game`) scaffolds the consumer project: `room.ts`
90
+ at the root, a `src/Game.tsx` React client, and a `dev` script that runs
91
+ `siltrun dev room.ts` + vite together.
92
+
93
+ ### Releasing
94
+
95
+ ```bash
96
+ bun tools/pack-release.ts # build dist + platform binaries, pack ALL tarballs
97
+ bun tools/pack-release.ts --current # this machine's platform binary only
98
+ ```
99
+
100
+ Tarballs land in `dist-tarballs/`, with `workspace:*` deps mechanically rewritten to
101
+ real versions by `bun pm pack` and verified by the script (it fails on any leaked
102
+ `workspace:` and on a platform tarball missing its binary). Publishing is
103
+ `npm publish <tarball>` per file. QA without touching the npm registry: publish the
104
+ tarballs to a local registry (verdaccio) and walk `npm create siltrun` against it.
105
+
106
+ ## North star: the Vite-plugin blend (future)
107
+
108
+ `npx siltrun dev` is the standalone path. The tracked north star (CONTRACT §10, open item 3)
109
+ is a **Vite plugin** so the room runs inside your existing `npm run dev` — one project,
110
+ one terminal, one reload story, exactly how Next.js made API routes feel. `room.ts` should
111
+ feel like adding a route, not running a game server. The plugin would host the room-info
112
+ endpoint on the Vite dev server itself and own the same supervise/bundle/doctor pipeline
113
+ this CLI implements today, so the standalone path stays the reference implementation.
114
+
115
+ ## Notes for the reference build
116
+
117
+ - **Runtime:** Bun. The room-host runs on Bun and the contract is bundled with Bun, so
118
+ Bun is the toolchain regardless; the CLI's own code runs under it (via the Node
119
+ shim above — never assume `bun` is on the user's PATH).
120
+ - **Room route:** `/room/<name>` — named by `--room`, `SILT_ROOM`, or derived from the
121
+ contract path (generic basenames like `room.ts` defer to the parent directory). The
122
+ dev never types this path — only the room-info URL.
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ bun install
128
+ bun test # room-info shape, bundling, arg parsing, cert-scrape
129
+ bun run typecheck
130
+ ```
131
+
132
+ End-to-end proof (a browser actually joining and moving a ship) belongs to the integration
133
+ phase. These tests cover the pieces the CLI owns standalone.
package/bin/silt.mjs ADDED
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ // The `siltrun` bin — a plain-Node shim that locates the Bun runtime and re-execs the
3
+ // real CLI (src/cli.ts) under it.
4
+ //
5
+ // Why a Node shim: npm consumers always have Node (npm runs on it), but Bun is an
6
+ // implementation detail of Silt (the room-host runs on Bun; contracts are bundled
7
+ // with Bun.build). The Wrangler/workerd bar: the runtime ships with the tool and the
8
+ // user never installs it by hand. The `siltrun` package depends on the official `bun`
9
+ // npm package, so `npm install` delivers the binary; this shim finds it.
10
+ //
11
+ // Resolution order (first hit wins):
12
+ // 1. SILT_BUN env — explicit escape hatch, always honored
13
+ // 2. the `bun` package's own bin (bin/bun.exe on EVERY platform — its postinstall
14
+ // moves the platform binary there and names it .exe unconditionally)
15
+ // 3. the @oven/bun-<platform> package directly — covers installs where lifecycle
16
+ // scripts were blocked (--ignore-scripts / allow-scripts policies), which leave
17
+ // the binary in the platform package and the bin/ placeholder unpopulated
18
+ // 4. `bun` on PATH — a dev's own install
19
+ //
20
+ // The resolved path is exported as SILT_BUN so every downstream spawn (the Go
21
+ // room-server spawning the host, the CLI spawning the doctor) uses the same runtime.
22
+
23
+ import { createRequire } from "node:module";
24
+ import { existsSync, statSync } from "node:fs";
25
+ import { dirname, join } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { spawn } from "node:child_process";
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const here = dirname(fileURLToPath(import.meta.url));
31
+ const isWindows = process.platform === "win32";
32
+
33
+ // node's platform-arch → the @oven platform package name.
34
+ const OVEN_PACKAGES = {
35
+ "darwin-arm64": "bun-darwin-aarch64",
36
+ "darwin-x64": "bun-darwin-x64",
37
+ "linux-arm64": "bun-linux-aarch64",
38
+ "linux-x64": "bun-linux-x64",
39
+ "win32-arm64": "bun-windows-aarch64",
40
+ "win32-x64": "bun-windows-x64",
41
+ };
42
+
43
+ /** A real bun binary is tens of MB; the pre-postinstall bin/bun.exe is a tiny placeholder. */
44
+ function realBinary(path) {
45
+ try {
46
+ return existsSync(path) && statSync(path).size > 1 << 20 ? path : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function packageDir(spec) {
53
+ try {
54
+ return dirname(require.resolve(`${spec}/package.json`));
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function resolveBun() {
61
+ if (process.env.SILT_BUN) return process.env.SILT_BUN;
62
+
63
+ const bunPkg = packageDir("bun");
64
+ if (bunPkg) {
65
+ const hit =
66
+ realBinary(join(bunPkg, "bin", "bun.exe")) || realBinary(join(bunPkg, "bin", "bun"));
67
+ if (hit) return hit;
68
+ }
69
+
70
+ const oven = OVEN_PACKAGES[`${process.platform}-${process.arch}`];
71
+ if (oven) {
72
+ const ovenPkg = packageDir(`@oven/${oven}`);
73
+ if (ovenPkg) {
74
+ const hit = realBinary(join(ovenPkg, "bin", isWindows ? "bun.exe" : "bun"));
75
+ if (hit) return hit;
76
+ }
77
+ }
78
+
79
+ return "bun"; // PATH fallback; ENOENT handled below with a real message
80
+ }
81
+
82
+ const bun = resolveBun();
83
+ const cliEntry = join(here, "..", "src", "cli.ts");
84
+
85
+ const child = spawn(bun, [cliEntry, ...process.argv.slice(2)], {
86
+ stdio: "inherit",
87
+ env: { ...process.env, SILT_BUN: bun },
88
+ });
89
+
90
+ child.on("error", (err) => {
91
+ if (err && err.code === "ENOENT") {
92
+ console.error(
93
+ "silt: could not find the Bun runtime.\n" +
94
+ " The `siltrun` package ships Bun as a dependency — a normal `npm install` provides it.\n" +
95
+ " If this install ran with scripts disabled and the binary is missing, either\n" +
96
+ " install Bun (https://bun.com) or point SILT_BUN at a bun binary.",
97
+ );
98
+ process.exit(1);
99
+ }
100
+ throw err;
101
+ });
102
+
103
+ child.on("exit", (code, signal) => {
104
+ if (signal) {
105
+ // The child died from a signal — re-raise it to ourselves so our own exit
106
+ // status reflects it (128+signum), the way a transparent shim should. But our
107
+ // SIGINT/SIGTERM handlers below are STILL installed: re-raising into them just
108
+ // calls child.kill again (child's already gone) and we'd fall through and exit
109
+ // 0, swallowing the signal. Remove our handlers first so the default
110
+ // disposition (terminate by that signal) takes over.
111
+ process.removeAllListeners(signal);
112
+ process.kill(process.pid, signal);
113
+ return;
114
+ }
115
+ process.exit(code ?? 0);
116
+ });
117
+
118
+ // Forward termination to the child; with stdio inherit Ctrl-C reaches the process
119
+ // group already — this covers direct kills of the shim.
120
+ for (const sig of ["SIGINT", "SIGTERM"]) {
121
+ process.on(sig, () => {
122
+ try {
123
+ child.kill(sig);
124
+ } catch {
125
+ /* already gone */
126
+ }
127
+ });
128
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "siltrun",
3
+ "version": "0.1.0",
4
+ "description": "The Silt CLI — run an authoritative multiplayer room locally with `siltrun dev`.",
5
+ "type": "module",
6
+ "bin": {
7
+ "siltrun": "bin/silt.mjs"
8
+ },
9
+ "files": ["bin", "src"],
10
+ "scripts": {
11
+ "test": "bun test",
12
+ "typecheck": "tsc --noEmit"
13
+ },
14
+ "repository": "https://github.com/digitalpine/silt",
15
+ "dependencies": {
16
+ "@siltrun/room-host": "0.1.0",
17
+ "bun": "^1.3.0"
18
+ },
19
+ "optionalDependencies": {
20
+ "@siltrun/room-server-darwin-arm64": "0.1.0",
21
+ "@siltrun/room-server-darwin-x64": "0.1.0",
22
+ "@siltrun/room-server-linux-arm64": "0.1.0",
23
+ "@siltrun/room-server-linux-x64": "0.1.0"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "devDependencies": {
29
+ "@types/bun": "^1.2.0",
30
+ "typescript": "^5.6.0"
31
+ }
32
+ }
@@ -0,0 +1,93 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { parseDevArgs, deriveRoomName, ArgError } from "./args.ts";
3
+
4
+ describe("parseDevArgs", () => {
5
+ const noEnv = {};
6
+
7
+ test("requires a contract positional", () => {
8
+ expect(() => parseDevArgs([], noEnv)).toThrow(ArgError);
9
+ });
10
+
11
+ test("defaults", () => {
12
+ const o = parseDevArgs(["room.ts"], noEnv);
13
+ expect(o.contract).toBe("room.ts");
14
+ expect(o.wtPort).toBe(4433);
15
+ expect(o.infoPort).toBe(4000);
16
+ expect(o.wtHost).toBe("127.0.0.1");
17
+ expect(o.doctor).toBe(true);
18
+ });
19
+
20
+ test("flags: space and = forms", () => {
21
+ const o = parseDevArgs(["room.ts", "--wt-port", "5555", "--info-port=4100"], noEnv);
22
+ expect(o.wtPort).toBe(5555);
23
+ expect(o.infoPort).toBe(4100);
24
+ });
25
+
26
+ test("--no-doctor toggles doctor off", () => {
27
+ expect(parseDevArgs(["room.ts", "--no-doctor"], noEnv).doctor).toBe(false);
28
+ });
29
+
30
+ test("env fallbacks apply", () => {
31
+ const o = parseDevArgs(["room.ts"], { SILT_WT_PORT: "5000", SILT_INFO_PORT: "5001" });
32
+ expect(o.wtPort).toBe(5000);
33
+ expect(o.infoPort).toBe(5001);
34
+ });
35
+
36
+ test("explicit flag beats env", () => {
37
+ const o = parseDevArgs(["room.ts", "--wt-port", "6000"], { SILT_WT_PORT: "5000" });
38
+ expect(o.wtPort).toBe(6000);
39
+ });
40
+
41
+ test("rejects bad ports", () => {
42
+ expect(() => parseDevArgs(["room.ts", "--wt-port", "notaport"], noEnv)).toThrow(ArgError);
43
+ expect(() => parseDevArgs(["room.ts", "--info-port", "99999"], noEnv)).toThrow(ArgError);
44
+ });
45
+
46
+ test("--room flag wins over env and derivation", () => {
47
+ const o = parseDevArgs(["examples/hello-room/room.ts", "--room", "my-arena"], {
48
+ SILT_ROOM: "env-room",
49
+ });
50
+ expect(o.room).toBe("my-arena");
51
+ });
52
+
53
+ test("SILT_ROOM env wins over derivation", () => {
54
+ const o = parseDevArgs(["examples/hello-room/room.ts"], { SILT_ROOM: "env-room" });
55
+ expect(o.room).toBe("env-room");
56
+ });
57
+
58
+ test("room defaults to the derived name", () => {
59
+ const o = parseDevArgs(["examples/hello-room/room.ts"], noEnv);
60
+ expect(o.room).toBe("hello-room");
61
+ });
62
+
63
+ test("rejects invalid explicit room names (no silent rewrite)", () => {
64
+ expect(() => parseDevArgs(["room.ts", "--room", "my room"], noEnv)).toThrow(ArgError);
65
+ expect(() => parseDevArgs(["room.ts", "--room", "a/b"], noEnv)).toThrow(ArgError);
66
+ expect(() => parseDevArgs(["room.ts", "--room", ".."], noEnv)).toThrow(ArgError);
67
+ expect(() => parseDevArgs(["room.ts"], { SILT_ROOM: "bad name" })).toThrow(ArgError);
68
+ });
69
+ });
70
+
71
+ describe("deriveRoomName", () => {
72
+ test("non-generic basename names the room", () => {
73
+ expect(deriveRoomName("examples/asteroids/arena.ts")).toBe("arena");
74
+ expect(deriveRoomName("/abs/path/Graveyard.ts")).toBe("graveyard"); // lowercased
75
+ });
76
+
77
+ test("generic basenames defer to the parent directory", () => {
78
+ // coordinator ruling 2026-07-02: room.ts/index.ts/contract.ts/main.ts say nothing —
79
+ // the directory names the room (and /room/room reads like a bug).
80
+ expect(deriveRoomName("examples/hello-room/room.ts")).toBe("hello-room");
81
+ expect(deriveRoomName("examples/hello-room/index.ts")).toBe("hello-room");
82
+ expect(deriveRoomName("games/Space-War/contract.ts")).toBe("space-war");
83
+ expect(deriveRoomName("games/space-war/main.ts")).toBe("space-war");
84
+ });
85
+
86
+ test("sanitizes to [a-z0-9._-]", () => {
87
+ expect(deriveRoomName("examples/My Cool Röom/room.ts")).toBe("mycoolrom");
88
+ });
89
+
90
+ test("falls back to \"room\" when nothing usable survives", () => {
91
+ expect(deriveRoomName("examples/„…“/⚡.ts")).toBe("room");
92
+ });
93
+ });
package/src/args.ts ADDED
@@ -0,0 +1,117 @@
1
+ // Minimal, dependency-free arg parsing for the `siltrun` CLI.
2
+
3
+ import { basename, dirname, resolve } from "node:path";
4
+
5
+ export interface DevOptions {
6
+ contract: string;
7
+ /** room name; the served WT path is /room/<room>. Derived from the contract when not given. */
8
+ room: string;
9
+ wtPort: number;
10
+ infoPort: number;
11
+ wtHost: string;
12
+ doctor: boolean;
13
+ }
14
+
15
+ export class ArgError extends Error {}
16
+
17
+ // Room names ride in a URL path (/room/<name>) — keep them path-safe. Mirrors the
18
+ // server-side validation in packages/room-server/main.go (validRoomName).
19
+ const ROOM_NAME_RE = /^[A-Za-z0-9._-]+$/;
20
+
21
+ // Basenames that say nothing about WHICH room this is (coordinator ruling 2026-07-02):
22
+ // for these, the parent directory names the room (examples/hello-room/room.ts -> hello-room).
23
+ const GENERIC_BASENAMES = new Set(["room", "index", "contract", "main"]);
24
+
25
+ function sanitizeRoomName(raw: string): string {
26
+ const cleaned = raw.toLowerCase().replace(/[^a-z0-9._-]/g, "");
27
+ if (!cleaned || cleaned === "." || cleaned === "..") return "";
28
+ return cleaned;
29
+ }
30
+
31
+ /**
32
+ * Default room name, derived from the contract path: the file's basename without
33
+ * extension — unless that basename is generic (room/index/contract/main), in which
34
+ * case the parent directory names the room. Sanitized to [a-z0-9._-]; falls back
35
+ * to "room" when nothing usable survives.
36
+ */
37
+ export function deriveRoomName(contractPath: string): string {
38
+ const base = basename(contractPath).replace(/\.[^.]+$/, "");
39
+ let candidate = base;
40
+ if (GENERIC_BASENAMES.has(base.toLowerCase())) {
41
+ const parent = basename(dirname(resolve(contractPath)));
42
+ if (parent && parent !== "." && parent !== "/") candidate = parent;
43
+ }
44
+ return sanitizeRoomName(candidate) || "room";
45
+ }
46
+
47
+ function intOpt(raw: string | undefined, name: string, fallback: number): number {
48
+ if (raw == null) return fallback;
49
+ const n = Number(raw);
50
+ if (!Number.isInteger(n) || n < 0 || n > 65535) {
51
+ throw new ArgError(`${name} must be a valid port (0-65535), got ${JSON.stringify(raw)}`);
52
+ }
53
+ return n;
54
+ }
55
+
56
+ /**
57
+ * Parse `siltrun dev` arguments. `argv` is everything AFTER `dev`.
58
+ * Env fallbacks: SILT_ROOM, SILT_WT_PORT, SILT_INFO_PORT, SILT_WT_HOST.
59
+ */
60
+ export function parseDevArgs(argv: string[], env: Record<string, string | undefined> = process.env): DevOptions {
61
+ const positionals: string[] = [];
62
+ const flags = new Map<string, string | boolean>();
63
+
64
+ for (let i = 0; i < argv.length; i++) {
65
+ const a = argv[i]!;
66
+ if (a.startsWith("--")) {
67
+ const eq = a.indexOf("=");
68
+ if (eq >= 0) {
69
+ flags.set(a.slice(2, eq), a.slice(eq + 1));
70
+ } else {
71
+ const key = a.slice(2);
72
+ const next = argv[i + 1];
73
+ if (key === "no-doctor") {
74
+ flags.set(key, true);
75
+ } else if (next != null && !next.startsWith("--")) {
76
+ flags.set(key, next);
77
+ i++;
78
+ } else {
79
+ flags.set(key, true);
80
+ }
81
+ }
82
+ } else {
83
+ positionals.push(a);
84
+ }
85
+ }
86
+
87
+ const contract = positionals[0];
88
+ if (!contract) {
89
+ throw new ArgError("missing contract file — usage: siltrun dev <contract.ts>");
90
+ }
91
+
92
+ // Explicit names (flag/env) are VALIDATED, not silently rewritten — a dev who typed
93
+ // `--room "my room!"` should hear about it, not discover /room/myroom later.
94
+ const explicitRoom = (flags.get("room") as string | undefined) ?? env.SILT_ROOM;
95
+ let room: string;
96
+ if (explicitRoom != null) {
97
+ if (typeof explicitRoom !== "string" || !ROOM_NAME_RE.test(explicitRoom) || explicitRoom === "." || explicitRoom === "..") {
98
+ throw new ArgError(
99
+ `--room must match [A-Za-z0-9._-]+ (got ${JSON.stringify(explicitRoom)})`,
100
+ );
101
+ }
102
+ room = explicitRoom;
103
+ } else {
104
+ room = deriveRoomName(contract);
105
+ }
106
+
107
+ const wtHost = (flags.get("wt-host") as string) || env.SILT_WT_HOST || "127.0.0.1";
108
+ const wtPort = intOpt((flags.get("wt-port") as string) ?? env.SILT_WT_PORT, "--wt-port", 4433);
109
+ const infoPort = intOpt(
110
+ (flags.get("info-port") as string) ?? env.SILT_INFO_PORT,
111
+ "--info-port",
112
+ 4000,
113
+ );
114
+ const doctor = flags.get("no-doctor") !== true;
115
+
116
+ return { contract, room, wtPort, infoPort, wtHost, doctor };
117
+ }
package/src/banner.ts ADDED
@@ -0,0 +1,55 @@
1
+ // The friendly boot banner printed after each (re)start.
2
+
3
+ import { paint, log } from "./log.ts";
4
+ import type { RoomInfo } from "./room-info.ts";
5
+ import type { DoctorResult } from "./doctor.ts";
6
+
7
+ function doctorLine(d: DoctorResult): string {
8
+ const label = "determinism";
9
+ switch (d.status) {
10
+ case "ok":
11
+ return `${label} ${paint.green("✔ ok")} ${paint.dim("— " + d.note)}`;
12
+ case "drift":
13
+ return `${label} ${paint.red("✖ drift")} ${paint.dim("— " + d.note)}`;
14
+ case "skipped":
15
+ return `${label} ${paint.dim("– skipped")} ${paint.dim("— " + d.note)}`;
16
+ case "errored":
17
+ return `${label} ${paint.yellow("! error")} ${paint.dim("— " + d.note)}`;
18
+ }
19
+ }
20
+
21
+ export function printBoot(opts: {
22
+ roomInfoUrl: string;
23
+ info: RoomInfo;
24
+ contract: string;
25
+ doctor: DoctorResult;
26
+ reload?: boolean;
27
+ }) {
28
+ const { roomInfoUrl, info, contract, doctor, reload } = opts;
29
+ log.plain();
30
+ if (reload) {
31
+ log.plain(` ${paint.cyan(paint.bold("↻ reloaded"))} ${paint.dim(contract)}`);
32
+ } else {
33
+ log.plain(` ${paint.green(paint.bold("● silt room live"))} ${paint.dim("(" + info.mode + " mode)")}`);
34
+ }
35
+ log.plain();
36
+ log.plain(` room info ${paint.cyan(roomInfoUrl + "/.well-known/silt")}`);
37
+ log.plain(` wt endpoint ${paint.blue(info.wtEndpoint)}`);
38
+ log.plain(` cert hash ${paint.dim(info.certHash ?? "(none)")}`);
39
+ log.plain(` ${doctorLine(doctor)}`);
40
+ log.plain();
41
+ if (!reload) {
42
+ log.plain(` point your client at ${paint.cyan(roomInfoUrl)}`);
43
+ log.plain(` ${paint.dim('useRoom("' + roomInfoUrl + '", { id })')}`);
44
+ log.plain();
45
+ log.plain(paint.dim(` watching ${contract} — edit to reload · Ctrl-C to stop`));
46
+ log.plain();
47
+ }
48
+ }
49
+
50
+ export function printDrift(doctor: DoctorResult) {
51
+ if (doctor.status !== "drift" && doctor.status !== "errored") return;
52
+ if (doctor.output) {
53
+ log.plain(paint.dim(doctor.output));
54
+ }
55
+ }
@@ -0,0 +1,80 @@
1
+ import { test, expect, describe, afterAll } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { bundleContract, BundleError } from "./bundle.ts";
7
+
8
+ const dirs: string[] = [];
9
+ async function work(): Promise<string> {
10
+ const d = await mkdtemp(join(tmpdir(), "silt-bundle-test-"));
11
+ dirs.push(d);
12
+ return d;
13
+ }
14
+ afterAll(async () => {
15
+ for (const d of dirs) await rm(d, { recursive: true, force: true });
16
+ });
17
+
18
+ // The bundle OUTPUT goes to its own directory, separate from the sources — the same
19
+ // layout the product uses (dev.ts/deploy.ts write bundles to a dedicated mkdtemp
20
+ // workdir, never next to the contract). This is also load-bearing for the tests
21
+ // themselves: since bun 1.3.14, Bun.build warms the in-process resolver's directory
22
+ // cache for source dirs it resolves imports in, and a file written into such a dir
23
+ // AFTERWARD is invisible to a same-process dynamic import() ("Cannot find module",
24
+ // caught 2026-07-17 when the root runner started executing these tests under the
25
+ // npm-shipped bun). Output-in-its-own-dir both matches the product and sidesteps it.
26
+ describe("bundleContract", () => {
27
+ test("bundles a hello-room-style contract into runnable ESM", async () => {
28
+ const d = await work();
29
+ const contract = join(d, "room.ts");
30
+ await writeFile(
31
+ contract,
32
+ `const clamp = (v: number) => Math.max(0, Math.min(520, v));
33
+ export default {
34
+ tick(state: any = { ships: {} }, inputs: any[]) {
35
+ for (const ev of inputs) if (ev.kind === "join") state.ships[ev.id] = { x: clamp(260), y: 140 };
36
+ return state;
37
+ },
38
+ };`,
39
+ );
40
+ const out = join(await work(), "room.bundle.js");
41
+ const result = await bundleContract(contract, out);
42
+ expect(result).toBe(out);
43
+ expect(existsSync(out)).toBe(true);
44
+
45
+ // The bundle is loadable and its default export has a working tick.
46
+ const mod = await import(out);
47
+ expect(typeof mod.default.tick).toBe("function");
48
+ const next = mod.default.tick(undefined, [{ kind: "join", id: "a" }]);
49
+ expect(next.ships.a).toEqual({ x: 260, y: 140 });
50
+ });
51
+
52
+ test("bundles multi-file contracts (imports get inlined)", async () => {
53
+ const d = await work();
54
+ await writeFile(join(d, "helper.ts"), `export const start = () => ({ n: 1 });`);
55
+ const contract = join(d, "room.ts");
56
+ await writeFile(
57
+ contract,
58
+ `import { start } from "./helper.ts";
59
+ export default { tick(state: any = start()) { state.n++; return state; } };`,
60
+ );
61
+ const out = join(await work(), "room.bundle.js");
62
+ await bundleContract(contract, out);
63
+ const mod = await import(out);
64
+ expect(mod.default.tick(undefined)).toEqual({ n: 2 });
65
+ });
66
+
67
+ test("throws BundleError on a missing contract", async () => {
68
+ const d = await work();
69
+ await expect(bundleContract(join(d, "nope.ts"), join(d, "o.js"))).rejects.toBeInstanceOf(
70
+ BundleError,
71
+ );
72
+ });
73
+
74
+ test("throws BundleError on a syntax error", async () => {
75
+ const d = await work();
76
+ const contract = join(d, "bad.ts");
77
+ await writeFile(contract, `export default { tick(state {{{ `);
78
+ await expect(bundleContract(contract, join(d, "o.js"))).rejects.toBeInstanceOf(BundleError);
79
+ });
80
+ });