@solidrt/cli 0.0.24 → 0.0.26

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/src/dev-server.ts CHANGED
@@ -1,26 +1,64 @@
1
1
  import { resolve } from "path"
2
- import { stat as fsStat, readdir } from "node:fs/promises"
3
- import { appendFileSync } from "node:fs"
4
- import { networkInterfaces } from "node:os"
5
- import { Bonjour } from "bonjour-service"
6
- import qrcode from "qrcode-generator"
7
- import { state, print } from "./util"
2
+ import { tmpdir, networkInterfaces } from "node:os"
3
+ import { fileURLToPath } from "node:url"
4
+ import { state, print, printErr, requireBinary, pipeAbovePrompt, shutdown } from "./util"
8
5
  import { values } from "./args"
9
- import * as cache from "./cache"
10
6
 
11
7
  export const DEV_HOST = "127.0.0.1"
12
8
  export const DEV_PORT = 0x8844
13
9
 
14
- // Dev-server WS protocol helpers: the reload message shape and a broadcast to all clients.
10
+ // The dev server itself is a flux script (packages/cli/server/), spawned by
11
+ // srt: bundling, file watching, and the repl stay here and drive the server
12
+ // process over its loopback-only /__internal__/ routes. See
13
+ // docs/flux-dev-server-plan.md.
14
+
15
+ const INTERNAL_BASE = `http://${DEV_HOST}:${DEV_PORT}/__internal__`
16
+
17
+ // Build the reload message for the client protocol. The server latches a
18
+ // broadcast reload verbatim for late-joining clients, so srt owns the message
19
+ // shape (including the proxy flags) end to end.
15
20
  export function buildReload(payload: { code?: string | null; bytecode?: string }) {
16
- return JSON.stringify({ type: "reload", proxyFiles: values["proxy-files"], proxyHttp: values["proxy-http"], ...payload })
21
+ return { type: "reload", proxyFiles: values["proxy-files"], proxyHttp: values["proxy-http"], ...payload }
17
22
  }
18
23
 
19
- export function broadcast(msg: object) {
20
- let text = JSON.stringify(msg)
21
- for (let ws of state.clients.keys()) {
22
- ws.send(text)
23
- }
24
+ async function post(path: string, body: object) {
25
+ let resp = await fetch(`${INTERNAL_BASE}${path}`, { method: "POST", body: JSON.stringify(body) })
26
+ if (!resp.ok) throw new Error(`Dev server ${path} failed: ${resp.status}`)
27
+ }
28
+
29
+ /**
30
+ * Send a client-protocol message through the server: to the given client ids,
31
+ * or to every client when omitted. `latch` keeps the message for late-joining
32
+ * clients (code reloads latch, one-shot bytecode loads do not); `sourceDir`
33
+ * moves the server's file-serving root (repl `load`).
34
+ */
35
+ export async function sendReload(message: object, opts: { clients?: number[]; latch?: boolean; sourceDir?: string } = {}) {
36
+ await post("/reload", { message, ...opts })
37
+ }
38
+
39
+ /** Send a stop to the given client ids, or all. A broadcast stop clears the server's latched reload. */
40
+ export async function sendStop(clients?: number[]) {
41
+ await post("/stop", clients ? { clients } : {})
42
+ }
43
+
44
+ /** Latch the stats-overlay flag on the server (for welcome) and broadcast it. */
45
+ export async function sendStats(stats: boolean) {
46
+ await post("/stats", { stats })
47
+ }
48
+
49
+ export type ClientEntry = {
50
+ id: number
51
+ platform: string
52
+ version: string
53
+ capabilities: string[]
54
+ address: string | null
55
+ }
56
+
57
+ /** The connected-client list, in connect order. */
58
+ export async function getClients(): Promise<ClientEntry[]> {
59
+ let resp = await fetch(`${INTERNAL_BASE}/clients`)
60
+ if (!resp.ok) throw new Error(`Dev server /clients failed: ${resp.status}`)
61
+ return resp.json()
24
62
  }
25
63
 
26
64
  // Reload code that fails to start the engine on purpose. The runtime treats a
@@ -32,254 +70,111 @@ const BSOD_TRIGGER = `throw new Error("SolidRT: build failed")`
32
70
  // Called when a bundle fails to compile. Latches the BSOD trigger as the
33
71
  // current code (so a client connecting after the failure gets it too) and
34
72
  // pushes it to every connected client.
35
- export function showBuildFailure() {
73
+ export async function showBuildFailure() {
36
74
  state.currentCode = BSOD_TRIGGER
37
- let msg = buildReload({ code: BSOD_TRIGGER })
38
- for (let ws of state.clients.keys()) {
39
- ws.send(msg)
40
- }
75
+ await sendReload(buildReload({ code: BSOD_TRIGGER }), { latch: true })
41
76
  }
42
77
 
43
- function headersToObject(h: Headers): Record<string, string> {
44
- let out: Record<string, string> = {}
45
- h.forEach((v, k) => {
46
- out[k] = v
47
- })
48
- return out
49
- }
50
- async function handleProxy(req: Request): Promise<Response> {
51
- let target = req.headers.get("x-srt-proxy-url")
52
- if (!target) {
53
- return new Response("Missing X-SRT-Proxy-Url", { status: 400 })
54
- }
55
-
56
- let forwardHeaders = new Headers(req.headers)
57
- forwardHeaders.delete("host")
58
- forwardHeaders.delete("x-srt-proxy-url")
59
- forwardHeaders.delete("x-srt-cache")
60
- forwardHeaders.delete("content-length")
61
-
62
- let cacheStatus: cache.Decision = "skip"
63
- let cacheable = !cache.shouldConsider(req.method, req.headers).skip
64
- let bypass = cacheable && cache.isBypass(req.headers)
65
-
66
- if (cacheable && !bypass) {
67
- let hit = cache.get(req.method, target)
68
- if (hit) {
69
- print("[cli] proxy %s %s [cache hit]", req.method, target)
70
- let respHeaders = new Headers(hit.headers)
71
- respHeaders.set("x-srt-cache", "hit")
72
- // await new Promise(resolve => setTimeout(resolve, 1000))
73
- return new Response(hit.body, { status: hit.status, headers: respHeaders })
78
+ // The Bun-hosted server used to exit from its ws close handler once the
79
+ // spawned local client had exited and the last remote client disconnected.
80
+ // The server process cannot see the child, so the policy lives here: called
81
+ // after the local client exits, poll the client list and shut down when it
82
+ // empties.
83
+ export function shutdownWhenEmpty() {
84
+ let timer = setInterval(async () => {
85
+ let clients = await getClients().catch(() => null)
86
+ if (clients && clients.length === 0) {
87
+ clearInterval(timer)
88
+ print("[cli] All clients disconnected, shutting down")
89
+ shutdown()
74
90
  }
75
- }
76
-
77
- let hasBody = req.method !== "GET" && req.method !== "HEAD"
78
- if (cacheable) {
79
- cacheStatus = bypass ? "bypass" : "miss"
80
- print("[cli] proxy %s %s [%s]", req.method, target, cacheStatus)
81
- } else {
82
- print("[cli] proxy %s %s", req.method, target)
83
- }
84
-
85
- try {
86
- let upstream = await fetch(target, {
87
- method: req.method,
88
- headers: forwardHeaders,
89
- body: hasBody ? await req.arrayBuffer() : undefined,
90
- redirect: "follow",
91
- })
92
- let respHeaders = new Headers(upstream.headers)
93
- respHeaders.delete("content-encoding")
94
- respHeaders.delete("transfer-encoding")
91
+ }, 2000)
92
+ }
95
93
 
96
- let bodyBytes = new Uint8Array(await upstream.arrayBuffer())
97
- if (cacheable) {
98
- cache.put(
99
- req.method,
100
- target,
101
- upstream.status,
102
- headersToObject(respHeaders),
103
- bodyBytes,
104
- )
105
- respHeaders.set("x-srt-cache", cacheStatus)
106
- }
107
- return new Response(bodyBytes, {
108
- status: upstream.status,
109
- statusText: upstream.statusText,
110
- headers: respHeaders,
111
- })
112
- } catch (e) {
113
- print("[cli] proxy error %s: %s", target, String(e))
114
- return new Response(`Proxy error: ${String(e)}`, { status: 502 })
94
+ // Bundle the server script to one plain-JS file the flux binary can run. Bun
95
+ // is already the bundler; the browser target keeps node builtins out, and the
96
+ // flux: capability modules stay external (the runtime provides them).
97
+ async function bundleServer(): Promise<string> {
98
+ let entry = fileURLToPath(new URL("../server/main.ts", import.meta.url))
99
+ let outfile = resolve(tmpdir(), `srt-dev-server-${process.pid}.js`)
100
+ let result = await Bun.build({
101
+ entrypoints: [entry],
102
+ target: "browser",
103
+ format: "esm",
104
+ external: ["flux:*"],
105
+ })
106
+ if (!result.success) {
107
+ printErr("[cli] Failed to bundle the dev server:")
108
+ for (let log of result.logs) printErr(String(log))
109
+ process.exit(1)
115
110
  }
111
+ await Bun.write(outfile, result.outputs[0]!)
112
+ return outfile
116
113
  }
117
114
 
118
- export function startServer() {
119
- state.server = Bun.serve({
120
- port: DEV_PORT,
121
- async fetch(req, server) {
122
- if (server.upgrade(req)) return
123
-
124
- let url = new URL(req.url)
125
- let path = decodeURIComponent(url.pathname)
126
-
127
- if (path === "/__proxy__") {
128
- return handleProxy(req)
129
- }
130
-
131
- let filePath = resolve(state.sourceDir, "." + path)
132
- if (!filePath.startsWith(state.sourceDir)) {
133
- return new Response("Forbidden", { status: 403 })
134
- }
135
-
136
- if (req.method === "PUT") {
137
- print("[cli] put", path)
138
- let bytes = new Uint8Array(await req.arrayBuffer())
139
- await Bun.write(filePath, bytes)
140
- return new Response(null, { status: 204 })
141
- }
142
-
143
- print("[cli] get", path)
144
-
145
- let stat
146
- try {
147
- stat = await fsStat(filePath)
148
- } catch {
149
- print("[cli] file not found %s", path)
150
- return new Response("Not found", { status: 404 })
151
- }
152
-
153
- if (stat.isDirectory()) {
154
- let dirents = await readdir(filePath, { withFileTypes: true })
155
- let entries = await Promise.all(
156
- dirents.map(async (d) => {
157
- let entry = { name: d.name, type: d.isDirectory() ? 2 : 1, size: 0, modified: 0 }
158
- if (!d.isDirectory()) {
159
- try {
160
- let s = await fsStat(resolve(filePath, d.name))
161
- entry.size = s.size
162
- entry.modified = Math.floor(s.mtimeMs)
163
- } catch {}
164
- }
165
- return entry
166
- }),
167
- )
168
- entries.sort((a, b) => a.name.localeCompare(b.name))
169
- return Response.json(entries, { headers: { "X-SRT-Type": "directory" } })
170
- }
171
-
172
- return new Response(Bun.file(filePath), { headers: { "X-SRT-Type": "file" } })
173
- },
174
- websocket: {
175
- open(ws) {
176
- let id = state.nextClientId++
177
- state.clients.set(ws, { platform: "unknown", version: "unknown", id })
178
- print(`[cli] Client connected ${ws.remoteAddress}`)
179
- // Advertise our real LAN address so clients dialed over the adb loopback
180
- // can show/remember the directly reachable address (see connection.rs).
181
- ws.send(
182
- JSON.stringify({ type: "welcome", address: state.serverUrl, stats: state.stats, capture: !!state.capture }),
183
- )
184
- if (state.currentCode) {
185
- ws.send(buildReload({ code: state.currentCode }))
186
- }
187
- },
188
- close(ws) {
189
- let info = state.clients.get(ws)
190
- state.clients.delete(ws)
191
- print(`[cli] Client disconnected: ${info?.platform ?? "unknown"}`)
192
- if (state.child && state.clients.size === 0 && state.child.exitCode !== null) {
193
- print("[cli] All clients disconnected, shutting down")
194
- state.server?.stop()
195
- process.exit(0)
196
- }
197
- },
198
- message(ws, msg) {
199
- try {
200
- let data = JSON.parse(typeof msg === "string" ? msg : Buffer.from(msg).toString())
201
- if (data.type === "info") {
202
- let existing = state.clients.get(ws)
203
- state.clients.set(ws, {
204
- platform: data.platform ?? "unknown",
205
- version: data.version ?? "unknown",
206
- id: existing?.id ?? state.nextClientId++,
207
- })
208
- print(`[cli] Client info ${ws.remoteAddress} ${data.platform} (${data.version})`)
209
- } else if (data.type === "capture" && state.capture) {
210
- let device = state.clients.get(ws)?.id ?? -1
211
- // Milliseconds, integer: Date.now() is already integer ms, so the
212
- // delta needs no rounding.
213
- let at = Date.now() - state.captureStartMs
214
- let after = at - state.captureLastAt
215
- state.captureLastAt = at
216
- // JSON Lines: one event object per line, streamed to disk as it
217
- // arrives rather than buffered - no in-memory growth for a long
218
- // capture, and the file is always complete on disk mid-session.
219
- let line = JSON.stringify({ after, type: data.kind, key: data.key, device }) + "\n"
220
- appendFileSync(state.capture, line)
221
- }
222
- } catch {}
223
- },
224
- },
225
- })
115
+ export async function startServer() {
116
+ let flux = requireBinary("flux")
117
+ let script = await bundleServer()
226
118
 
227
119
  let lanAddress = Object.values(networkInterfaces())
228
120
  .flat()
229
121
  .find((i) => i?.family === "IPv4" && !i.internal)?.address
230
-
231
122
  let address = lanAddress ?? DEV_HOST
232
- let serverUrl = `${address}:${state.server.port}`
233
- state.serverUrl = serverUrl
234
-
235
- console.log("")
123
+ // The address clients can reach us on; also the dev base URL the bundler
124
+ // rewrites asset imports against. The server has no OS module, so srt
125
+ // computes it and passes it down.
126
+ state.serverUrl = `${address}:${DEV_PORT}`
236
127
 
237
- let qr = qrcode(0, "L")
238
- qr.addData(serverUrl)
239
- qr.make()
240
- let modCount = qr.getModuleCount()
241
- // Render as a white tile with black modules (explicit ANSI colors) plus the
242
- // 4-module quiet zone the QR spec requires. Drawing with the terminal's
243
- // default foreground inverts the code on dark themes, which standard
244
- // decoders reject.
245
- const QR_INK = "\x1b[30;107m" // black modules on bright-white tile (tile = background)
246
- const QR_TILE_FG = "\x1b[97m" // bright-white as foreground over the default background
247
- const QR_RESET = "\x1b[0m"
248
- const QUIET_ZONE = 2 // modules (spec says 4, but scanners cope and it reads tighter)
249
- let qrWidth = modCount + 2 * QUIET_ZONE
250
- let dark = (y: number, x: number) => y >= 0 && y < modCount && x >= 0 && x < modCount && qr.isDark(y, x)
251
- // modCount is always odd, so the tile is a half-line taller than an even row
252
- // count. The loop packs two module-rows per line via half-blocks and stops on
253
- // the last content row, leaving the bottom quiet zone half a line short of the
254
- // full-line top quiet zone.
255
- for (let y = -QUIET_ZONE; y < modCount + QUIET_ZONE - 1; y += 2) {
256
- let row = " " + QR_INK
257
- for (let x = -QUIET_ZONE; x < modCount + QUIET_ZONE; x++) {
258
- let top = dark(y, x)
259
- let bot = dark(y + 1, x)
260
- row += top && bot ? "\u2588" : top ? "\u2580" : bot ? "\u2584" : " "
261
- }
262
- console.log(row + QR_RESET)
128
+ let config = {
129
+ port: DEV_PORT,
130
+ sourceDir: state.sourceDir,
131
+ address,
132
+ proxyFiles: values["proxy-files"],
133
+ proxyHttp: values["proxy-http"],
134
+ cache: values["proxy-http"],
135
+ cacheDir: process.cwd(),
136
+ capture: state.capture,
137
+ stats: state.stats,
138
+ tunnel: values.tunnel,
263
139
  }
264
- // Close that gap with a half-height tile line: upper half painted in the tile
265
- // color (foreground), lower half the terminal background. The 0.5 here plus
266
- // the 0.5 already under the last content row equal the full-line top margin.
267
- console.log(" " + QR_TILE_FG + "\u2580".repeat(qrWidth) + QR_RESET)
268
-
269
- console.log("")
270
- console.log(`[cli] WebSocket server on ws://${serverUrl}`)
271
140
 
272
- // LAN discovery: advertise the dev server as a DNS-SD service so go clients on
273
- // the same network can find it (see lattice/src/go/connection.rs). Stored on
274
- // state so shutdown() can send the mDNS goodbye.
275
- state.bonjour = new Bonjour()
276
- state.bonjour.publish({ name: "SolidRT Dev Server", type: "solidrt", protocol: "tcp", port: DEV_PORT })
277
- print(`[cli] Advertising _solidrt._tcp on port ${DEV_PORT} via mDNS`)
141
+ state.serverProc = Bun.spawn([flux, script, JSON.stringify(config)], {
142
+ stdio: ["ignore", "pipe", "pipe"],
143
+ })
144
+ if (state.serverProc.stdout && typeof state.serverProc.stdout !== "number")
145
+ pipeAbovePrompt(state.serverProc.stdout, process.stdout)
146
+ if (state.serverProc.stderr && typeof state.serverProc.stderr !== "number")
147
+ pipeAbovePrompt(state.serverProc.stderr, process.stderr)
148
+
149
+ state.serverProc.exited.then((code) => {
150
+ if (!state.shuttingDown) {
151
+ printErr(`[cli] Dev server exited unexpectedly (${code})`)
152
+ process.exit(1)
153
+ }
154
+ })
278
155
 
279
- // Keepalive
280
- setInterval(() => {
281
- for (let ws of state.clients.keys()) {
282
- ws.ping()
156
+ // Wait until the server answers on the internal API before anything else
157
+ // (the initial bundle needs the dev base URL, clients need the port bound).
158
+ for (let i = 0; ; i++) {
159
+ try {
160
+ await getClients()
161
+ break
162
+ } catch {
163
+ if (i >= 100) {
164
+ printErr("[cli] Dev server did not start")
165
+ process.exit(1)
166
+ }
167
+ await Bun.sleep(100)
283
168
  }
284
- }, 5000)
169
+ }
170
+
171
+ // mDNS advertise (dropped, code kept for future use - see
172
+ // docs/flux-dev-server-plan.md): the p2p ticket is the cross-device connect
173
+ // story now. If advertise returns, it belongs next to the server (a flux
174
+ // capability), not here.
175
+ //
176
+ // import { Bonjour } from "bonjour-service" (top of file)
177
+ // state.bonjour = new Bonjour()
178
+ // state.bonjour.publish({ name: "SolidRT Dev Server", type: "solidrt", protocol: "tcp", port: DEV_PORT })
179
+ // print(`[cli] Advertising _solidrt._tcp on port ${DEV_PORT} via mDNS`)
285
180
  }
package/src/main.ts CHANGED
@@ -7,6 +7,7 @@ import { runPackCommand } from "./commands/pack"
7
7
  import { runRenderCommand } from "./commands/render"
8
8
  import { runServerCommand } from "./commands/server"
9
9
  import { runClientCommand } from "./commands/client"
10
+ import { runMcpCommand } from "./commands/mcp"
10
11
  import { spawnClient } from "./dev-client"
11
12
 
12
13
  // -- Validate args --
@@ -51,6 +52,8 @@ if (command === "init") {
51
52
  } else if (command === "run") {
52
53
  await runServerCommand()
53
54
  spawnClient()
55
+ } else if (command === "mcp") {
56
+ await runMcpCommand()
54
57
  } else {
55
58
  printUsage()
56
59
  process.exit(1)
package/src/repl.ts CHANGED
@@ -2,28 +2,40 @@ import { createInterface } from "node:readline"
2
2
  import { resolve, dirname } from "path"
3
3
  import { readdirSync } from "node:fs"
4
4
  import { state, print, printErr, shutdown } from "./util"
5
- import { buildReload, broadcast, showBuildFailure } from "./dev-server"
5
+ import { buildReload, getClients, sendReload, sendStop, sendStats, showBuildFailure } from "./dev-server"
6
6
  import { bundle, codeFromOutputs } from "./bundler"
7
7
  import { startWatcher, stopWatcher } from "./watcher"
8
8
 
9
- function cmdStop(args: string) {
9
+ // Resolve repl client indexes ("0 2") against the server's client list,
10
+ // printing a complaint for each invalid token. The list order is connect
11
+ // order, matching what `list` shows.
12
+ async function indexesToIds(args: string): Promise<number[]> {
13
+ let clients = await getClients()
14
+ let ids: number[] = []
15
+ for (let token of args.split(/\s+/)) {
16
+ let idx = parseInt(token, 10)
17
+ if (isNaN(idx) || idx < 0 || idx >= clients.length) {
18
+ print(`Invalid client index: ${token}`)
19
+ continue
20
+ }
21
+ ids.push(clients[idx]!.id)
22
+ }
23
+ return ids
24
+ }
25
+
26
+ async function cmdStop(args: string) {
10
27
  if (!args) {
11
28
  stopWatcher()
12
29
  state.currentCode = null
13
30
  state.source = undefined
14
- broadcast({ type: "stop" })
31
+ await sendStop()
15
32
  print("[cli] Sent stop to all clients")
16
33
  return
17
34
  }
18
- let clientList = [...state.clients.keys()]
19
- for (let token of args.split(/\s+/)) {
20
- let idx = parseInt(token, 10)
21
- if (isNaN(idx) || idx < 0 || idx >= clientList.length) {
22
- print(`Invalid client index: ${token}`)
23
- continue
24
- }
25
- clientList[idx].send(JSON.stringify({ type: "stop" }))
26
- print(`[cli] Sent stop to client ${idx}`)
35
+ let ids = await indexesToIds(args)
36
+ if (ids.length) {
37
+ await sendStop(ids)
38
+ print(`[cli] Sent stop to client(s) ${ids.join(", ")}`)
27
39
  }
28
40
  }
29
41
 
@@ -32,30 +44,25 @@ async function cmdReload(args: string) {
32
44
  let result = await bundle(state.source)
33
45
  if (!result) {
34
46
  printErr("[cli] Build failed, reload aborted")
35
- showBuildFailure()
47
+ await showBuildFailure()
36
48
  return
37
49
  }
38
50
  state.currentCode = await codeFromOutputs(result.outputs)
39
51
  }
40
52
  let msg = buildReload({ code: state.currentCode })
41
53
  if (!args) {
42
- for (let ws of state.clients.keys()) ws.send(msg)
54
+ await sendReload(msg, { latch: true })
43
55
  print("[cli] Sent reload to all clients")
44
56
  return
45
57
  }
46
- let clientList = [...state.clients.keys()]
47
- for (let token of args.split(/\s+/)) {
48
- let idx = parseInt(token, 10)
49
- if (isNaN(idx) || idx < 0 || idx >= clientList.length) {
50
- print(`Invalid client index: ${token}`)
51
- continue
52
- }
53
- clientList[idx].send(msg)
54
- print(`[cli] Sent reload to client ${idx}`)
58
+ let ids = await indexesToIds(args)
59
+ if (ids.length) {
60
+ await sendReload(msg, { clients: ids })
61
+ print(`[cli] Sent reload to client(s) ${ids.join(", ")}`)
55
62
  }
56
63
  }
57
64
 
58
- function cmdStats(args: string) {
65
+ async function cmdStats(args: string) {
59
66
  if (args === "on") {
60
67
  state.stats = true
61
68
  } else if (args === "off") {
@@ -66,19 +73,20 @@ function cmdStats(args: string) {
66
73
  print("Usage: stats [on|off]")
67
74
  return
68
75
  }
69
- broadcast({ type: "stats", stats: state.stats })
76
+ await sendStats(state.stats)
70
77
  print(`[cli] Stats overlay ${state.stats ? "on" : "off"}`)
71
78
  }
72
79
 
73
- function cmdList() {
74
- if (state.clients.size === 0) {
80
+ async function cmdList() {
81
+ let clients = await getClients()
82
+ if (clients.length === 0) {
75
83
  print("No connected clients")
76
84
  return
77
85
  }
78
- print(`${state.clients.size} connected client(s):`)
86
+ print(`${clients.length} connected client(s):`)
79
87
  let i = 0
80
- for (let [ws, info] of state.clients) {
81
- print(` ${i++}: ${ws.remoteAddress} [${info.platform}, ${info.version}]`)
88
+ for (let c of clients) {
89
+ print(` ${i++}: ${c.address ?? "unknown"} [${c.platform}, ${c.version}]`)
82
90
  }
83
91
  }
84
92
 
@@ -99,8 +107,8 @@ async function cmdLoad(file: string) {
99
107
  state.currentCode = await Bun.file(path).text()
100
108
  } else if (file.endsWith(".srt.bin")) {
101
109
  let bytes = await Bun.file(path).arrayBuffer()
102
- let msg = buildReload({ bytecode: Buffer.from(bytes).toString("base64") })
103
- for (let ws of state.clients.keys()) ws.send(msg)
110
+ // One-shot: bytecode loads are pushed but not latched for late joiners.
111
+ await sendReload(buildReload({ bytecode: Buffer.from(bytes).toString("base64") }))
104
112
  print(`[cli] Loaded ${file} (bytecode, ${bytes.byteLength} bytes)`)
105
113
  return
106
114
  } else {
@@ -110,10 +118,8 @@ async function cmdLoad(file: string) {
110
118
  state.source = path
111
119
  state.sourceDir = dirname(path)
112
120
  startWatcher()
113
- let reloadMsg = buildReload({ code: state.currentCode })
114
- for (let ws of state.clients.keys()) {
115
- ws.send(reloadMsg)
116
- }
121
+ // The load also moves the server's file-serving root to the new source dir.
122
+ await sendReload(buildReload({ code: state.currentCode }), { latch: true, sourceDir: state.sourceDir })
117
123
  print(`[cli] Loaded ${file}`)
118
124
  }
119
125
 
@@ -146,6 +152,12 @@ function completer(line: string): [string[], string] {
146
152
  return [matches, line]
147
153
  }
148
154
 
155
+ // Run a repl command, reporting a failed server round-trip instead of leaving
156
+ // an unhandled rejection (e.g. the server process died mid-command).
157
+ function guard(p: Promise<void>) {
158
+ p.catch((e) => printErr(`[cli] ${String(e)}`))
159
+ }
160
+
149
161
  export function startRepl() {
150
162
  state.rl = createInterface({ input: process.stdin, output: process.stdout, completer })
151
163
  state.rl.setPrompt("srt> ")
@@ -155,15 +167,15 @@ export function startRepl() {
155
167
  state.rl.on("line", (line) => {
156
168
  let cmd = line.trim()
157
169
  if (cmd === "stop" || cmd.startsWith("stop ")) {
158
- cmdStop(cmd.slice(5).trim())
170
+ guard(cmdStop(cmd.slice(5).trim()))
159
171
  } else if (cmd === "reload" || cmd.startsWith("reload ")) {
160
- cmdReload(cmd.slice(7).trim())
172
+ guard(cmdReload(cmd.slice(7).trim()))
161
173
  } else if (cmd.startsWith("load ")) {
162
- cmdLoad(cmd.slice(5).trim())
174
+ guard(cmdLoad(cmd.slice(5).trim()))
163
175
  } else if (cmd === "list") {
164
- cmdList()
176
+ guard(cmdList())
165
177
  } else if (cmd === "stats" || cmd.startsWith("stats ")) {
166
- cmdStats(cmd.slice(6).trim())
178
+ guard(cmdStats(cmd.slice(6).trim()))
167
179
  } else if (cmd === "quit" || cmd === "exit") {
168
180
  shutdown()
169
181
  } else if (cmd.startsWith("!")) {
@@ -187,4 +199,4 @@ export function startRepl() {
187
199
  })
188
200
 
189
201
  state.rl.prompt()
190
- }
202
+ }