@solidrt/cli 0.0.25 → 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,27 +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
- import { appendLog, handleControl, resolveQuery } from "./control"
11
6
 
12
7
  export const DEV_HOST = "127.0.0.1"
13
8
  export const DEV_PORT = 0x8844
14
9
 
15
- // 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.
16
20
  export function buildReload(payload: { code?: string | null; bytecode?: string }) {
17
- 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 }
18
22
  }
19
23
 
20
- export function broadcast(msg: object) {
21
- let text = JSON.stringify(msg)
22
- for (let ws of state.clients.keys()) {
23
- ws.send(text)
24
- }
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()
25
62
  }
26
63
 
27
64
  // Reload code that fails to start the engine on purpose. The runtime treats a
@@ -33,308 +70,111 @@ const BSOD_TRIGGER = `throw new Error("SolidRT: build failed")`
33
70
  // Called when a bundle fails to compile. Latches the BSOD trigger as the
34
71
  // current code (so a client connecting after the failure gets it too) and
35
72
  // pushes it to every connected client.
36
- export function showBuildFailure() {
73
+ export async function showBuildFailure() {
37
74
  state.currentCode = BSOD_TRIGGER
38
- let msg = buildReload({ code: BSOD_TRIGGER })
39
- for (let ws of state.clients.keys()) {
40
- ws.send(msg)
41
- }
75
+ await sendReload(buildReload({ code: BSOD_TRIGGER }), { latch: true })
42
76
  }
43
77
 
44
- function headersToObject(h: Headers): Record<string, string> {
45
- let out: Record<string, string> = {}
46
- h.forEach((v, k) => {
47
- out[k] = v
48
- })
49
- return out
50
- }
51
- async function handleProxy(req: Request): Promise<Response> {
52
- let target = req.headers.get("x-srt-proxy-url")
53
- if (!target) {
54
- return new Response("Missing X-SRT-Proxy-Url", { status: 400 })
55
- }
56
-
57
- let forwardHeaders = new Headers(req.headers)
58
- forwardHeaders.delete("host")
59
- forwardHeaders.delete("x-srt-proxy-url")
60
- forwardHeaders.delete("x-srt-cache")
61
- forwardHeaders.delete("content-length")
62
-
63
- let cacheStatus: cache.Decision = "skip"
64
- let cacheable = !cache.shouldConsider(req.method, req.headers).skip
65
- let bypass = cacheable && cache.isBypass(req.headers)
66
-
67
- if (cacheable && !bypass) {
68
- let hit = cache.get(req.method, target)
69
- if (hit) {
70
- print("[cli] proxy %s %s [cache hit]", req.method, target)
71
- let respHeaders = new Headers(hit.headers)
72
- respHeaders.set("x-srt-cache", "hit")
73
- // await new Promise(resolve => setTimeout(resolve, 1000))
74
- 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()
75
90
  }
76
- }
77
-
78
- let hasBody = req.method !== "GET" && req.method !== "HEAD"
79
- if (cacheable) {
80
- cacheStatus = bypass ? "bypass" : "miss"
81
- print("[cli] proxy %s %s [%s]", req.method, target, cacheStatus)
82
- } else {
83
- print("[cli] proxy %s %s", req.method, target)
84
- }
85
-
86
- try {
87
- let upstream = await fetch(target, {
88
- method: req.method,
89
- headers: forwardHeaders,
90
- body: hasBody ? await req.arrayBuffer() : undefined,
91
- redirect: "follow",
92
- })
93
- let respHeaders = new Headers(upstream.headers)
94
- respHeaders.delete("content-encoding")
95
- respHeaders.delete("transfer-encoding")
91
+ }, 2000)
92
+ }
96
93
 
97
- let bodyBytes = new Uint8Array(await upstream.arrayBuffer())
98
- if (cacheable) {
99
- cache.put(
100
- req.method,
101
- target,
102
- upstream.status,
103
- headersToObject(respHeaders),
104
- bodyBytes,
105
- )
106
- respHeaders.set("x-srt-cache", cacheStatus)
107
- }
108
- return new Response(bodyBytes, {
109
- status: upstream.status,
110
- statusText: upstream.statusText,
111
- headers: respHeaders,
112
- })
113
- } catch (e) {
114
- print("[cli] proxy error %s: %s", target, String(e))
115
- 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)
116
110
  }
111
+ await Bun.write(outfile, result.outputs[0]!)
112
+ return outfile
117
113
  }
118
114
 
119
- export function startServer() {
120
- state.server = Bun.serve({
121
- port: DEV_PORT,
122
- async fetch(req, server) {
123
- if (server.upgrade(req)) return
124
-
125
- let url = new URL(req.url)
126
- let path = decodeURIComponent(url.pathname)
127
-
128
- if (path === "/__proxy__") {
129
- return handleProxy(req)
130
- }
131
-
132
- if (path.startsWith("/__control__/")) {
133
- return handleControl(req, path)
134
- }
135
-
136
- let filePath = resolve(state.sourceDir, "." + path)
137
- if (!filePath.startsWith(state.sourceDir)) {
138
- return new Response("Forbidden", { status: 403 })
139
- }
140
-
141
- if (req.method === "PUT") {
142
- print("[cli] put", path)
143
- let bytes = new Uint8Array(await req.arrayBuffer())
144
- await Bun.write(filePath, bytes)
145
- return new Response(null, { status: 204 })
146
- }
147
-
148
- print("[cli] get", path)
149
-
150
- let stat
151
- try {
152
- stat = await fsStat(filePath)
153
- } catch {
154
- print("[cli] file not found %s", path)
155
- return new Response("Not found", { status: 404 })
156
- }
157
-
158
- if (stat.isDirectory()) {
159
- let dirents = await readdir(filePath, { withFileTypes: true })
160
- let entries = await Promise.all(
161
- dirents.map(async (d) => {
162
- let entry = { name: d.name, type: d.isDirectory() ? 2 : 1, size: 0, modified: 0 }
163
- if (!d.isDirectory()) {
164
- try {
165
- let s = await fsStat(resolve(filePath, d.name))
166
- entry.size = s.size
167
- entry.modified = Math.floor(s.mtimeMs)
168
- } catch {}
169
- }
170
- return entry
171
- }),
172
- )
173
- entries.sort((a, b) => a.name.localeCompare(b.name))
174
- return Response.json(entries, { headers: { "X-SRT-Type": "directory" } })
175
- }
176
-
177
- let file = Bun.file(filePath)
178
- let baseHeaders: Record<string, string> = { "X-SRT-Type": "file", "Accept-Ranges": "bytes" }
179
-
180
- // Honor a single byte-range request (e.g. streaming audio decoding on the
181
- // client, which seeks and reads on demand). Only the common "bytes=a-b" /
182
- // "bytes=a-" / "bytes=-n" forms; anything else falls through to the whole
183
- // file. Range makes proxied streaming viable without pulling the whole
184
- // track over the wire.
185
- let range = req.headers.get("range")
186
- let match = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null
187
- if (match) {
188
- let size = stat.size
189
- let start: number
190
- let end: number
191
- if (match[1] === "") {
192
- // Suffix range: the last N bytes.
193
- let n = parseInt(match[2], 10)
194
- start = isNaN(n) ? 0 : Math.max(0, size - n)
195
- end = size - 1
196
- } else {
197
- start = parseInt(match[1], 10)
198
- end = match[2] === "" ? size - 1 : Math.min(parseInt(match[2], 10), size - 1)
199
- }
200
- if (start > end || start >= size) {
201
- return new Response("Range not satisfiable", {
202
- status: 416,
203
- headers: { ...baseHeaders, "Content-Range": `bytes */${size}` },
204
- })
205
- }
206
- return new Response(file.slice(start, end + 1), {
207
- status: 206,
208
- headers: {
209
- ...baseHeaders,
210
- "Content-Range": `bytes ${start}-${end}/${size}`,
211
- "Content-Length": String(end - start + 1),
212
- },
213
- })
214
- }
215
-
216
- return new Response(file, { headers: baseHeaders })
217
- },
218
- websocket: {
219
- open(ws) {
220
- let id = state.nextClientId++
221
- state.clients.set(ws, { platform: "unknown", version: "unknown", id, capabilities: [] })
222
- print(`[cli] Client connected ${ws.remoteAddress}`)
223
- // Advertise our real LAN address so clients dialed over the adb loopback
224
- // can show/remember the directly reachable address (see connection.rs).
225
- ws.send(
226
- JSON.stringify({ type: "welcome", address: state.serverUrl, stats: state.stats, capture: !!state.capture }),
227
- )
228
- if (state.currentCode) {
229
- ws.send(buildReload({ code: state.currentCode }))
230
- }
231
- },
232
- close(ws) {
233
- let info = state.clients.get(ws)
234
- state.clients.delete(ws)
235
- print(`[cli] Client disconnected: ${info?.platform ?? "unknown"}`)
236
- if (state.child && state.clients.size === 0 && state.child.exitCode !== null) {
237
- print("[cli] All clients disconnected, shutting down")
238
- state.server?.stop()
239
- process.exit(0)
240
- }
241
- },
242
- message(ws, msg) {
243
- try {
244
- let data = JSON.parse(typeof msg === "string" ? msg : Buffer.from(msg).toString())
245
- if (data.type === "info") {
246
- let existing = state.clients.get(ws)
247
- state.clients.set(ws, {
248
- platform: data.platform ?? "unknown",
249
- version: data.version ?? "unknown",
250
- id: existing?.id ?? state.nextClientId++,
251
- capabilities: Array.isArray(data.capabilities) ? data.capabilities.map(String) : [],
252
- })
253
- print(`[cli] Client info ${ws.remoteAddress} ${data.platform} (${data.version})`)
254
- } else if (data.type === "log") {
255
- // Forwarded console output / runtime errors from the client's
256
- // engine logger, buffered for the control API (see control.ts).
257
- // Not printed here: the local client already writes to this
258
- // terminal, so echoing would duplicate every line.
259
- let device = state.clients.get(ws)?.id ?? -1
260
- appendLog(device, String(data.level ?? "log"), String(data.text ?? ""))
261
- } else if (data.type === "result") {
262
- // Reply to a query the control API forwarded to this client.
263
- resolveQuery(data)
264
- } else if (data.type === "capture" && state.capture) {
265
- let device = state.clients.get(ws)?.id ?? -1
266
- // Milliseconds, integer: Date.now() is already integer ms, so the
267
- // delta needs no rounding.
268
- let at = Date.now() - state.captureStartMs
269
- let after = at - state.captureLastAt
270
- state.captureLastAt = at
271
- // JSON Lines: one event object per line, streamed to disk as it
272
- // arrives rather than buffered - no in-memory growth for a long
273
- // capture, and the file is always complete on disk mid-session.
274
- let line = JSON.stringify({ after, type: data.kind, key: data.key, device }) + "\n"
275
- appendFileSync(state.capture, line)
276
- }
277
- } catch {}
278
- },
279
- },
280
- })
115
+ export async function startServer() {
116
+ let flux = requireBinary("flux")
117
+ let script = await bundleServer()
281
118
 
282
119
  let lanAddress = Object.values(networkInterfaces())
283
120
  .flat()
284
121
  .find((i) => i?.family === "IPv4" && !i.internal)?.address
285
-
286
122
  let address = lanAddress ?? DEV_HOST
287
- let serverUrl = `${address}:${state.server.port}`
288
- state.serverUrl = serverUrl
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}`
289
127
 
290
- console.log("")
291
-
292
- let qr = qrcode(0, "L")
293
- qr.addData(serverUrl)
294
- qr.make()
295
- let modCount = qr.getModuleCount()
296
- // Render as a white tile with black modules (explicit ANSI colors) plus the
297
- // 4-module quiet zone the QR spec requires. Drawing with the terminal's
298
- // default foreground inverts the code on dark themes, which standard
299
- // decoders reject.
300
- const QR_INK = "\x1b[30;107m" // black modules on bright-white tile (tile = background)
301
- const QR_TILE_FG = "\x1b[97m" // bright-white as foreground over the default background
302
- const QR_RESET = "\x1b[0m"
303
- const QUIET_ZONE = 2 // modules (spec says 4, but scanners cope and it reads tighter)
304
- let qrWidth = modCount + 2 * QUIET_ZONE
305
- let dark = (y: number, x: number) => y >= 0 && y < modCount && x >= 0 && x < modCount && qr.isDark(y, x)
306
- // modCount is always odd, so the tile is a half-line taller than an even row
307
- // count. The loop packs two module-rows per line via half-blocks and stops on
308
- // the last content row, leaving the bottom quiet zone half a line short of the
309
- // full-line top quiet zone.
310
- for (let y = -QUIET_ZONE; y < modCount + QUIET_ZONE - 1; y += 2) {
311
- let row = " " + QR_INK
312
- for (let x = -QUIET_ZONE; x < modCount + QUIET_ZONE; x++) {
313
- let top = dark(y, x)
314
- let bot = dark(y + 1, x)
315
- row += top && bot ? "\u2588" : top ? "\u2580" : bot ? "\u2584" : " "
316
- }
317
- 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,
318
139
  }
319
- // Close that gap with a half-height tile line: upper half painted in the tile
320
- // color (foreground), lower half the terminal background. The 0.5 here plus
321
- // the 0.5 already under the last content row equal the full-line top margin.
322
- console.log(" " + QR_TILE_FG + "\u2580".repeat(qrWidth) + QR_RESET)
323
140
 
324
- console.log("")
325
- console.log(`[cli] WebSocket server on ws://${serverUrl}`)
326
-
327
- // LAN discovery: advertise the dev server as a DNS-SD service so go clients on
328
- // the same network can find it (see lattice/src/go/connection.rs). Stored on
329
- // state so shutdown() can send the mDNS goodbye.
330
- state.bonjour = new Bonjour()
331
- state.bonjour.publish({ name: "SolidRT Dev Server", type: "solidrt", protocol: "tcp", port: DEV_PORT })
332
- 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
+ })
333
155
 
334
- // Keepalive
335
- setInterval(() => {
336
- for (let ws of state.clients.keys()) {
337
- 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)
338
168
  }
339
- }, 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`)
340
180
  }
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
+ }