@solidrt/cli 0.0.25 → 0.0.27

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,69 @@
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`); `map` is the bundle's
34
+ * sourcemap, kept server-side for stack-trace remapping (omitting it clears
35
+ * the server's map, so a mapless reload never remaps against a stale one).
36
+ */
37
+ export async function sendReload(
38
+ message: object,
39
+ opts: { clients?: number[]; latch?: boolean; sourceDir?: string; entry?: string; map?: string | null } = {},
40
+ ) {
41
+ await post("/reload", { message, ...opts })
42
+ }
43
+
44
+ /** Send a stop to the given client ids, or all. A broadcast stop clears the server's latched reload. */
45
+ export async function sendStop(clients?: number[]) {
46
+ await post("/stop", clients ? { clients } : {})
47
+ }
48
+
49
+ /** Latch the stats-overlay flag on the server (for welcome) and broadcast it. */
50
+ export async function sendStats(stats: boolean) {
51
+ await post("/stats", { stats })
52
+ }
53
+
54
+ export type ClientEntry = {
55
+ id: number
56
+ platform: string
57
+ version: string
58
+ capabilities: string[]
59
+ address: string | null
60
+ }
61
+
62
+ /** The connected-client list, in connect order. */
63
+ export async function getClients(): Promise<ClientEntry[]> {
64
+ let resp = await fetch(`${INTERNAL_BASE}/clients`)
65
+ if (!resp.ok) throw new Error(`Dev server /clients failed: ${resp.status}`)
66
+ return resp.json()
25
67
  }
26
68
 
27
69
  // Reload code that fails to start the engine on purpose. The runtime treats a
@@ -33,308 +75,119 @@ const BSOD_TRIGGER = `throw new Error("SolidRT: build failed")`
33
75
  // Called when a bundle fails to compile. Latches the BSOD trigger as the
34
76
  // current code (so a client connecting after the failure gets it too) and
35
77
  // pushes it to every connected client.
36
- export function showBuildFailure() {
78
+ export async function showBuildFailure() {
37
79
  state.currentCode = BSOD_TRIGGER
38
- let msg = buildReload({ code: BSOD_TRIGGER })
39
- for (let ws of state.clients.keys()) {
40
- ws.send(msg)
41
- }
80
+ await sendReload(buildReload({ code: BSOD_TRIGGER }), { latch: true })
42
81
  }
43
82
 
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 })
83
+ // The Bun-hosted server used to exit from its ws close handler once the
84
+ // spawned local client had exited and the last remote client disconnected.
85
+ // The server process cannot see the child, so the policy lives here: called
86
+ // after the local client exits, poll the client list and shut down when it
87
+ // empties.
88
+ export function shutdownWhenEmpty() {
89
+ let timer = setInterval(async () => {
90
+ let clients = await getClients().catch(() => null)
91
+ if (clients && clients.length === 0) {
92
+ clearInterval(timer)
93
+ print("[cli] All clients disconnected, shutting down")
94
+ shutdown()
75
95
  }
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")
96
+ }, 2000)
97
+ }
96
98
 
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 })
99
+ // Bundle the server script to one plain-JS file the flux binary can run. Bun
100
+ // is already the bundler; the browser target keeps node builtins out, and the
101
+ // flux: capability modules stay external (the runtime provides them).
102
+ async function bundleServer(): Promise<string> {
103
+ let entry = fileURLToPath(new URL("../server/main.ts", import.meta.url))
104
+ let outfile = resolve(tmpdir(), `srt-dev-server-${process.pid}.js`)
105
+ let result = await Bun.build({
106
+ entrypoints: [entry],
107
+ target: "browser",
108
+ format: "esm",
109
+ external: ["flux:*"],
110
+ })
111
+ if (!result.success) {
112
+ printErr("[cli] Failed to bundle the dev server:")
113
+ for (let log of result.logs) printErr(String(log))
114
+ process.exit(1)
116
115
  }
116
+ await Bun.write(outfile, result.outputs[0]!)
117
+ return outfile
117
118
  }
118
119
 
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
- })
120
+ export async function startServer() {
121
+ let flux = requireBinary("flux")
122
+ let script = await bundleServer()
281
123
 
282
124
  let lanAddress = Object.values(networkInterfaces())
283
125
  .flat()
284
126
  .find((i) => i?.family === "IPv4" && !i.internal)?.address
285
-
286
127
  let address = lanAddress ?? DEV_HOST
287
- let serverUrl = `${address}:${state.server.port}`
288
- state.serverUrl = serverUrl
128
+ // The address clients can reach us on; also the dev base URL the bundler
129
+ // rewrites asset imports against. The server has no OS module, so srt
130
+ // computes it and passes it down.
131
+ state.serverUrl = `${address}:${DEV_PORT}`
289
132
 
290
- console.log("")
133
+ // How the server rebuilds on an MCP-triggered reload: it cannot call
134
+ // Bun.build itself (it is a flux process), so it spawns srt's own bun on the
135
+ // standalone bundle-cli entry. Both paths are known here at spawn time.
136
+ let bundleCli = fileURLToPath(new URL("./bundle-cli.ts", import.meta.url))
291
137
 
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)
138
+ let config = {
139
+ port: DEV_PORT,
140
+ sourceDir: state.sourceDir,
141
+ address,
142
+ proxyFiles: values["proxy-files"],
143
+ proxyHttp: values["proxy-http"],
144
+ entry: state.source,
145
+ minify: values.minify,
146
+ bundlerCmd: [process.execPath, bundleCli],
147
+ cache: values["proxy-http"],
148
+ cacheDir: process.cwd(),
149
+ capture: state.capture,
150
+ stats: state.stats,
151
+ tunnel: values.tunnel,
318
152
  }
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
153
 
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`)
154
+ state.serverProc = Bun.spawn([flux, script, JSON.stringify(config)], {
155
+ stdio: ["ignore", "pipe", "pipe"],
156
+ })
157
+ if (state.serverProc.stdout && typeof state.serverProc.stdout !== "number")
158
+ pipeAbovePrompt(state.serverProc.stdout, process.stdout)
159
+ if (state.serverProc.stderr && typeof state.serverProc.stderr !== "number")
160
+ pipeAbovePrompt(state.serverProc.stderr, process.stderr)
161
+
162
+ state.serverProc.exited.then((code) => {
163
+ if (!state.shuttingDown) {
164
+ printErr(`[cli] Dev server exited unexpectedly (${code})`)
165
+ process.exit(1)
166
+ }
167
+ })
333
168
 
334
- // Keepalive
335
- setInterval(() => {
336
- for (let ws of state.clients.keys()) {
337
- ws.ping()
169
+ // Wait until the server answers on the internal API before anything else
170
+ // (the initial bundle needs the dev base URL, clients need the port bound).
171
+ for (let i = 0; ; i++) {
172
+ try {
173
+ await getClients()
174
+ break
175
+ } catch {
176
+ if (i >= 100) {
177
+ printErr("[cli] Dev server did not start")
178
+ process.exit(1)
179
+ }
180
+ await Bun.sleep(100)
338
181
  }
339
- }, 5000)
182
+ }
183
+
184
+ // mDNS advertise (dropped, code kept for future use - see
185
+ // docs/flux-dev-server-plan.md): the p2p ticket is the cross-device connect
186
+ // story now. If advertise returns, it belongs next to the server (a flux
187
+ // capability), not here.
188
+ //
189
+ // import { Bonjour } from "bonjour-service" (top of file)
190
+ // state.bonjour = new Bonjour()
191
+ // state.bonjour.publish({ name: "SolidRT Dev Server", type: "solidrt", protocol: "tcp", port: DEV_PORT })
192
+ // print(`[cli] Advertising _solidrt._tcp on port ${DEV_PORT} via mDNS`)
340
193
  }