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 +133 -0
- package/bin/silt.mjs +128 -0
- package/package.json +32 -0
- package/src/args.test.ts +93 -0
- package/src/args.ts +117 -0
- package/src/banner.ts +55 -0
- package/src/bundle.test.ts +80 -0
- package/src/bundle.ts +53 -0
- package/src/cli.ts +95 -0
- package/src/credentials.ts +73 -0
- package/src/deploy-client.ts +143 -0
- package/src/deploy.test.ts +330 -0
- package/src/deploy.ts +396 -0
- package/src/dev.test.ts +59 -0
- package/src/dev.ts +264 -0
- package/src/doctor.test.ts +31 -0
- package/src/doctor.ts +74 -0
- package/src/log.ts +39 -0
- package/src/login.test.ts +307 -0
- package/src/login.ts +263 -0
- package/src/paths.ts +92 -0
- package/src/room-info.test.ts +80 -0
- package/src/room-info.ts +70 -0
- package/src/server-build.ts +78 -0
- package/src/silt-shim.test.ts +54 -0
- package/src/supervisor.test.ts +23 -0
- package/src/supervisor.ts +218 -0
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
|
+
}
|
package/src/args.test.ts
ADDED
|
@@ -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
|
+
});
|