@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/repl.ts CHANGED
@@ -2,28 +2,41 @@ 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"
6
- import { bundle, codeFromOutputs } from "./bundler"
5
+ import { buildReload, getClients, sendReload, sendStop, sendStats, showBuildFailure } from "./dev-server"
6
+ import { bundle } 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
30
+ state.currentMap = null
13
31
  state.source = undefined
14
- broadcast({ type: "stop" })
32
+ await sendStop()
15
33
  print("[cli] Sent stop to all clients")
16
34
  return
17
35
  }
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}`)
36
+ let ids = await indexesToIds(args)
37
+ if (ids.length) {
38
+ await sendStop(ids)
39
+ print(`[cli] Sent stop to client(s) ${ids.join(", ")}`)
27
40
  }
28
41
  }
29
42
 
@@ -32,30 +45,26 @@ async function cmdReload(args: string) {
32
45
  let result = await bundle(state.source)
33
46
  if (!result) {
34
47
  printErr("[cli] Build failed, reload aborted")
35
- showBuildFailure()
48
+ await showBuildFailure()
36
49
  return
37
50
  }
38
- state.currentCode = await codeFromOutputs(result.outputs)
51
+ state.currentCode = result.code
52
+ state.currentMap = result.map
39
53
  }
40
54
  let msg = buildReload({ code: state.currentCode })
41
55
  if (!args) {
42
- for (let ws of state.clients.keys()) ws.send(msg)
56
+ await sendReload(msg, { latch: true, map: state.currentMap })
43
57
  print("[cli] Sent reload to all clients")
44
58
  return
45
59
  }
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}`)
60
+ let ids = await indexesToIds(args)
61
+ if (ids.length) {
62
+ await sendReload(msg, { clients: ids, map: state.currentMap })
63
+ print(`[cli] Sent reload to client(s) ${ids.join(", ")}`)
55
64
  }
56
65
  }
57
66
 
58
- function cmdStats(args: string) {
67
+ async function cmdStats(args: string) {
59
68
  if (args === "on") {
60
69
  state.stats = true
61
70
  } else if (args === "off") {
@@ -66,19 +75,20 @@ function cmdStats(args: string) {
66
75
  print("Usage: stats [on|off]")
67
76
  return
68
77
  }
69
- broadcast({ type: "stats", stats: state.stats })
78
+ await sendStats(state.stats)
70
79
  print(`[cli] Stats overlay ${state.stats ? "on" : "off"}`)
71
80
  }
72
81
 
73
- function cmdList() {
74
- if (state.clients.size === 0) {
82
+ async function cmdList() {
83
+ let clients = await getClients()
84
+ if (clients.length === 0) {
75
85
  print("No connected clients")
76
86
  return
77
87
  }
78
- print(`${state.clients.size} connected client(s):`)
88
+ print(`${clients.length} connected client(s):`)
79
89
  let i = 0
80
- for (let [ws, info] of state.clients) {
81
- print(` ${i++}: ${ws.remoteAddress} [${info.platform}, ${info.version}]`)
90
+ for (let c of clients) {
91
+ print(` ${i++}: ${c.address ?? "unknown"} [${c.platform}, ${c.version}]`)
82
92
  }
83
93
  }
84
94
 
@@ -94,13 +104,15 @@ async function cmdLoad(file: string) {
94
104
  printErr("[cli] Build failed")
95
105
  return
96
106
  }
97
- state.currentCode = await codeFromOutputs(result.outputs)
107
+ state.currentCode = result.code
108
+ state.currentMap = result.map
98
109
  } else if (file.endsWith(".srt.js")) {
99
110
  state.currentCode = await Bun.file(path).text()
111
+ state.currentMap = null
100
112
  } else if (file.endsWith(".srt.bin")) {
101
113
  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)
114
+ // One-shot: bytecode loads are pushed but not latched for late joiners.
115
+ await sendReload(buildReload({ bytecode: Buffer.from(bytes).toString("base64") }))
104
116
  print(`[cli] Loaded ${file} (bytecode, ${bytes.byteLength} bytes)`)
105
117
  return
106
118
  } else {
@@ -110,10 +122,14 @@ async function cmdLoad(file: string) {
110
122
  state.source = path
111
123
  state.sourceDir = dirname(path)
112
124
  startWatcher()
113
- let reloadMsg = buildReload({ code: state.currentCode })
114
- for (let ws of state.clients.keys()) {
115
- ws.send(reloadMsg)
116
- }
125
+ // The load also moves the server's file-serving root to the new source dir,
126
+ // and its rebuild entry to the new file (for a later MCP reload).
127
+ await sendReload(buildReload({ code: state.currentCode }), {
128
+ latch: true,
129
+ sourceDir: state.sourceDir,
130
+ entry: file.endsWith(".tsx") ? path : undefined,
131
+ map: state.currentMap,
132
+ })
117
133
  print(`[cli] Loaded ${file}`)
118
134
  }
119
135
 
@@ -146,6 +162,12 @@ function completer(line: string): [string[], string] {
146
162
  return [matches, line]
147
163
  }
148
164
 
165
+ // Run a repl command, reporting a failed server round-trip instead of leaving
166
+ // an unhandled rejection (e.g. the server process died mid-command).
167
+ function guard(p: Promise<void>) {
168
+ p.catch((e) => printErr(`[cli] ${String(e)}`))
169
+ }
170
+
149
171
  export function startRepl() {
150
172
  state.rl = createInterface({ input: process.stdin, output: process.stdout, completer })
151
173
  state.rl.setPrompt("srt> ")
@@ -155,15 +177,15 @@ export function startRepl() {
155
177
  state.rl.on("line", (line) => {
156
178
  let cmd = line.trim()
157
179
  if (cmd === "stop" || cmd.startsWith("stop ")) {
158
- cmdStop(cmd.slice(5).trim())
180
+ guard(cmdStop(cmd.slice(5).trim()))
159
181
  } else if (cmd === "reload" || cmd.startsWith("reload ")) {
160
- cmdReload(cmd.slice(7).trim())
182
+ guard(cmdReload(cmd.slice(7).trim()))
161
183
  } else if (cmd.startsWith("load ")) {
162
- cmdLoad(cmd.slice(5).trim())
184
+ guard(cmdLoad(cmd.slice(5).trim()))
163
185
  } else if (cmd === "list") {
164
- cmdList()
186
+ guard(cmdList())
165
187
  } else if (cmd === "stats" || cmd.startsWith("stats ")) {
166
- cmdStats(cmd.slice(6).trim())
188
+ guard(cmdStats(cmd.slice(6).trim()))
167
189
  } else if (cmd === "quit" || cmd === "exit") {
168
190
  shutdown()
169
191
  } else if (cmd.startsWith("!")) {
@@ -187,4 +209,4 @@ export function startRepl() {
187
209
  })
188
210
 
189
211
  state.rl.prompt()
190
- }
212
+ }
package/src/util.ts CHANGED
@@ -2,31 +2,29 @@ import { resolveBinary } from "./artifacts"
2
2
  import { existsSync } from "node:fs"
3
3
  import { resolve } from "node:path"
4
4
  import type { Interface as ReadlineInterface } from "node:readline"
5
- import type { Server as BunServer } from "bun"
6
- import type { Bonjour } from "bonjour-service"
5
+ // import type { Bonjour } from "bonjour-service" (mDNS advertise, kept - see dev-server.ts)
7
6
 
8
7
  export let state = {
9
- clients: new Map<any, { platform: string; version: string; id: number; capabilities: string[] }>(),
10
- nextClientId: 0,
8
+ // What srt believes the current bundle is; the server process keeps its own
9
+ // latched copy for late-joining clients (see packages/cli/server/).
11
10
  currentCode: null as string | null,
11
+ // The bundle's composed sourcemap (dev builds), sent to the server alongside
12
+ // reloads so it can remap logged stack traces to .tsx positions.
13
+ currentMap: null as string | null,
12
14
  source: undefined as string | undefined,
13
15
  sourceDir: process.cwd(),
14
16
  child: null as ReturnType<typeof Bun.spawn> | null,
15
- server: null as BunServer<undefined> | null,
17
+ // The spawned flux dev-server process (see dev-server.ts startServer).
18
+ serverProc: null as ReturnType<typeof Bun.spawn> | null,
19
+ shuttingDown: false,
16
20
  serverUrl: null as string | null,
17
21
  rl: null as ReadlineInterface | null,
18
- bonjour: null as Bonjour | null,
22
+ // bonjour: null as Bonjour | null, (mDNS advertise, kept - see dev-server.ts)
19
23
  stats: false,
20
24
  // --capture <file>: destination for captured key events, or undefined when
21
- // off. Clients only report kind/key; the server stamps `after` itself (one
22
- // shared clock from captureStartMs, integer milliseconds) so events from
23
- // several connected clients merge into one coherent timeline, tagged by
24
- // `device` (see dev-server.ts) so they can be told apart. Streamed to disk
25
- // as JSON Lines (one event object per line) as each arrives - see
26
- // dev-server.ts's "capture" message handling.
25
+ // off; the server process owns the capture file and clock (see
26
+ // packages/cli/server/main.ts's "capture" message handling).
27
27
  capture: undefined as string | undefined,
28
- captureStartMs: 0,
29
- captureLastAt: 0, // ms, same clock as captureStartMs
30
28
  }
31
29
 
32
30
  // Build target per binary, for the "not found" hint. Run from the repo root.
@@ -93,9 +91,25 @@ export function printErr(...args: any[]) {
93
91
  state.rl?.prompt(true)
94
92
  }
95
93
 
94
+ // Pipe a child stream to `out` without mangling the repl prompt: clear the
95
+ // prompt line, write the chunk, redraw the prompt.
96
+ export function pipeAbovePrompt(stream: ReadableStream<Uint8Array>, out: NodeJS.WriteStream) {
97
+ let reader = stream.getReader()
98
+ ;(async () => {
99
+ while (true) {
100
+ let { done, value } = await reader.read()
101
+ if (done || !value) break
102
+ process.stdout.write("\r\x1b[K")
103
+ out.write(value)
104
+ state.rl?.prompt(true)
105
+ }
106
+ })()
107
+ }
108
+
96
109
  export function shutdown() {
110
+ state.shuttingDown = true
97
111
  if (state.child) state.child.kill()
98
- if (state.server) state.server.stop()
99
- if (state.bonjour) state.bonjour.destroy()
112
+ if (state.serverProc) state.serverProc.kill()
113
+ // if (state.bonjour) state.bonjour.destroy() (mDNS advertise, kept - see dev-server.ts)
100
114
  process.exit(0)
101
115
  }
package/src/watcher.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { watch } from "node:fs"
2
2
  import { resolve, dirname } from "path"
3
3
  import { state, print, printErr } from "./util"
4
- import { buildReload, showBuildFailure } from "./dev-server"
5
- import { bundle, codeFromOutputs } from "./bundler"
4
+ import { buildReload, sendReload, showBuildFailure } from "./dev-server"
5
+ import { bundle } from "./bundler"
6
6
 
7
7
  let currentWatcher: ReturnType<typeof watch> | null = null
8
8
 
@@ -28,13 +28,11 @@ export function startWatcher() {
28
28
  let result = await bundle(state.source)
29
29
  if (!result) {
30
30
  printErr("[cli] Build failed, waiting for changes...")
31
- showBuildFailure()
31
+ await showBuildFailure()
32
32
  return
33
33
  }
34
- state.currentCode = await codeFromOutputs(result.outputs)
35
- let msg = buildReload({ code: state.currentCode })
36
- for (let ws of state.clients.keys()) {
37
- ws.send(msg)
38
- }
34
+ state.currentCode = result.code
35
+ state.currentMap = result.map
36
+ await sendReload(buildReload({ code: state.currentCode }), { latch: true, map: state.currentMap })
39
37
  })
40
38
  }
package/src/control.ts DELETED
@@ -1,119 +0,0 @@
1
- import { state } from "./util"
2
-
3
- // The control API under /__control__/: read-only introspection of connected
4
- // app clients, served next to the file routes. The MCP bridge (srt mcp) is the
5
- // primary consumer. Two shapes: server-held data answered directly (clients,
6
- // logs) and queries forwarded to a client over its websocket and correlated
7
- // back by id (tree, stats).
8
-
9
- export type LogEntry = { seq: number; at: number; client: number; level: string; text: string }
10
-
11
- // Ring buffer of forwarded client logs. Capped so a chatty app cannot grow the
12
- // server without bound; readers page through it with the `since` cursor.
13
- const LOG_CAP = 2000
14
- const QUERY_TIMEOUT_MS = 5000
15
- const MAX_WAIT_MS = 30000
16
-
17
- let logs: LogEntry[] = []
18
- let logSeq = 0
19
- // Pending long-poll wakeups (see handleLogs). Flushed on every append; a
20
- // waiter that already timed out resolves again harmlessly.
21
- let logWaiters: Array<() => void> = []
22
-
23
- let nextQueryId = 1
24
- let pendingQueries = new Map<number, (msg: any) => void>()
25
-
26
- /// A `log` message arrived from a client: buffer it and wake long-polls.
27
- export function appendLog(client: number, level: string, text: string) {
28
- logs.push({ seq: ++logSeq, at: Date.now(), client, level, text })
29
- if (logs.length > LOG_CAP) logs.splice(0, logs.length - LOG_CAP)
30
- let waiters = logWaiters
31
- logWaiters = []
32
- for (let wake of waiters) wake()
33
- }
34
-
35
- /// A `result` message arrived from a client: hand it to the awaiting query.
36
- export function resolveQuery(msg: { id?: number }) {
37
- if (typeof msg.id !== "number") return
38
- let resolve = pendingQueries.get(msg.id)
39
- if (resolve) {
40
- pendingQueries.delete(msg.id)
41
- resolve(msg)
42
- }
43
- }
44
-
45
- function clientList() {
46
- return [...state.clients.values()].map((info) => ({
47
- id: info.id,
48
- platform: info.platform,
49
- version: info.version,
50
- capabilities: info.capabilities,
51
- }))
52
- }
53
-
54
- // Resolve the target client for a query: an explicit ?client=<id>, or the only
55
- // connected client when the parameter is omitted.
56
- function findClient(param: string | null): { ws: any } | { error: Response } {
57
- let entries = [...state.clients.entries()]
58
- if (param === null) {
59
- if (entries.length === 1) return { ws: entries[0]![0] }
60
- if (entries.length === 0) return { error: Response.json({ error: "No connected clients" }, { status: 503 }) }
61
- return { error: Response.json({ error: "Multiple clients connected; pass ?client=<id>" }, { status: 400 }) }
62
- }
63
- let id = parseInt(param, 10)
64
- let entry = entries.find(([, info]) => info.id === id)
65
- if (!entry) return { error: Response.json({ error: `No client with id ${param}` }, { status: 404 }) }
66
- return { ws: entry[0] }
67
- }
68
-
69
- async function handleQuery(url: URL, kind: string): Promise<Response> {
70
- let target = findClient(url.searchParams.get("client"))
71
- if ("error" in target) return target.error
72
- let id = nextQueryId++
73
- let reply = new Promise<any>((resolve) => {
74
- pendingQueries.set(id, resolve)
75
- })
76
- target.ws.send(JSON.stringify({ type: "query", kind, id }))
77
- let msg = await Promise.race([reply, Bun.sleep(QUERY_TIMEOUT_MS)])
78
- pendingQueries.delete(id)
79
- if (!msg) return Response.json({ error: "Query timed out" }, { status: 504 })
80
- if (msg.error) return Response.json({ error: msg.error }, { status: 502 })
81
- return Response.json(msg.data)
82
- }
83
-
84
- // GET /__control__/logs?since=N&wait=MS: entries with seq > since, plus the
85
- // latest seq as the next cursor. With `wait`, holds the response until a new
86
- // entry arrives or the timeout passes (long-poll), so a caller can follow the
87
- // stream without tight polling.
88
- async function handleLogs(url: URL): Promise<Response> {
89
- let since = parseInt(url.searchParams.get("since") ?? "0", 10) || 0
90
- let wait = Math.min(parseInt(url.searchParams.get("wait") ?? "0", 10) || 0, MAX_WAIT_MS)
91
- let entries = logs.filter((e) => e.seq > since)
92
- if (entries.length === 0 && wait > 0) {
93
- await new Promise<void>((resolve) => {
94
- let timer = setTimeout(resolve, wait)
95
- logWaiters.push(() => {
96
- clearTimeout(timer)
97
- resolve()
98
- })
99
- })
100
- entries = logs.filter((e) => e.seq > since)
101
- }
102
- return Response.json({ entries, latest: logSeq })
103
- }
104
-
105
- export async function handleControl(req: Request, path: string): Promise<Response> {
106
- let url = new URL(req.url)
107
- switch (path) {
108
- case "/__control__/clients":
109
- return Response.json(clientList())
110
- case "/__control__/logs":
111
- return handleLogs(url)
112
- case "/__control__/tree":
113
- return handleQuery(url, "tree")
114
- case "/__control__/stats":
115
- return handleQuery(url, "stats")
116
- default:
117
- return Response.json({ error: "Unknown control endpoint" }, { status: 404 })
118
- }
119
- }