@solidrt/cli 0.0.50 → 0.0.52

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.
Files changed (98) hide show
  1. package/AGENTS.md +79 -36
  2. package/README.md +86 -15
  3. package/agents/assets.md +46 -0
  4. package/agents/debugging.md +294 -0
  5. package/dist/console.srtapp +94123 -73
  6. package/dist/server.js +3936 -0
  7. package/package.json +11 -10
  8. package/src/android/docs.md +21 -0
  9. package/src/android/main.ts +286 -0
  10. package/src/{bundler.ts → bundle/bundler.ts} +163 -76
  11. package/src/bundle/docs.md +12 -0
  12. package/src/bundle/main.ts +203 -0
  13. package/src/check/docs.md +10 -0
  14. package/src/check/main.ts +85 -0
  15. package/src/{commands/check.ts → check/typecheck.ts} +11 -34
  16. package/src/client/docs.md +9 -0
  17. package/src/client/main.ts +33 -0
  18. package/src/console/docs.md +14 -0
  19. package/src/console/main.ts +21 -0
  20. package/src/demo/docs.md +27 -0
  21. package/src/demo/main.ts +67 -0
  22. package/src/init/docs.md +11 -0
  23. package/src/{commands/init.ts → init/main.ts} +31 -25
  24. package/src/init/scaffold/AGENTS.md +98 -0
  25. package/src/init/scaffold/package.json +23 -0
  26. package/{scaffold → src/init/scaffold}/templates/components/index.tsx +2 -3
  27. package/{scaffold → src/init/scaffold}/templates/default/index.tsx +2 -3
  28. package/src/lib/args.ts +194 -0
  29. package/src/{artifacts.ts → lib/artifacts.ts} +41 -1
  30. package/src/{dev-dir.ts → lib/dev-dir.ts} +10 -8
  31. package/src/{fonts.ts → lib/fonts.ts} +12 -26
  32. package/src/lib/mode.ts +77 -0
  33. package/src/{project.ts → lib/project.ts} +109 -61
  34. package/src/lib/registry.ts +120 -0
  35. package/src/lib/server-bundle.ts +24 -0
  36. package/src/lib/usage.ts +117 -0
  37. package/src/lib/util.ts +36 -0
  38. package/src/main.ts +109 -31
  39. package/src/mcp/docs.md +22 -0
  40. package/src/mcp/main.ts +719 -0
  41. package/src/pack/docs.md +18 -0
  42. package/src/{pack-folder.ts → pack/layout.ts} +13 -22
  43. package/src/pack/main.ts +86 -0
  44. package/src/pack/trailer.ts +97 -0
  45. package/src/render/docs.md +19 -0
  46. package/src/render/main.ts +49 -0
  47. package/src/server/args.ts +126 -0
  48. package/src/server/binaries.ts +47 -0
  49. package/src/server/config.ts +54 -0
  50. package/{server → src/server}/control.ts +246 -78
  51. package/src/server/docs.md +51 -0
  52. package/src/server/line-editor.ts +200 -0
  53. package/src/server/main.ts +473 -0
  54. package/src/server/mode.ts +92 -0
  55. package/src/server/rebuild.ts +90 -0
  56. package/src/server/registry.ts +138 -0
  57. package/src/server/remap.ts +60 -0
  58. package/src/server/repl.ts +223 -0
  59. package/src/server/state.ts +54 -0
  60. package/{server → src/server}/tsconfig.json +1 -1
  61. package/{server → src/server}/tunnel.ts +6 -6
  62. package/src/server/watcher.ts +121 -0
  63. package/src/tool/main.ts +70 -0
  64. package/src/types/bundle.d.ts +24 -0
  65. package/src/types/control.d.ts +90 -0
  66. package/src/types/registry.d.ts +16 -0
  67. package/scaffold/AGENTS.md +0 -630
  68. package/scaffold/package.json +0 -22
  69. package/scaffold/templates/components/icon.tsx +0 -48
  70. package/scaffold/templates/default/icon.tsx +0 -48
  71. package/server/main.ts +0 -308
  72. package/server/rebuild.ts +0 -68
  73. package/server/remap.ts +0 -47
  74. package/server/state.ts +0 -93
  75. package/src/args.ts +0 -212
  76. package/src/bundle-cli.ts +0 -13
  77. package/src/commands/bundle.ts +0 -76
  78. package/src/commands/client.ts +0 -34
  79. package/src/commands/mcp.ts +0 -603
  80. package/src/commands/pack.ts +0 -65
  81. package/src/commands/render.ts +0 -24
  82. package/src/commands/server.ts +0 -73
  83. package/src/dev-android.ts +0 -176
  84. package/src/dev-client.ts +0 -29
  85. package/src/dev-server.ts +0 -302
  86. package/src/packer.ts +0 -103
  87. package/src/repl.ts +0 -233
  88. package/src/util.ts +0 -121
  89. package/src/watcher.ts +0 -69
  90. /package/src/{untyped-deps.d.ts → bundle/untyped-deps.d.ts} +0 -0
  91. /package/src/{prompt.ts → init/prompt.ts} +0 -0
  92. /package/{scaffold → src/init/scaffold}/gitignore +0 -0
  93. /package/{scaffold → src/init/scaffold}/icon.svg +0 -0
  94. /package/{scaffold → src/init/scaffold}/mcp.json +0 -0
  95. /package/{scaffold → src/init/scaffold}/tsconfig.json +0 -0
  96. /package/{server → src/server}/cache.ts +0 -0
  97. /package/{server → src/server}/proxy.ts +0 -0
  98. /package/{server → src/server}/qr.ts +0 -0
@@ -0,0 +1,200 @@
1
+ import { on, setRawMode, write } from "flux:tty"
2
+ import type { Key } from "flux:tty"
3
+
4
+ // A line editor over flux:tty raw mode: the terminal echoes nothing, every
5
+ // key arrives as an event, and this redraws the prompt line itself. Cursor
6
+ // movement, history (this session) and Tab completion; Ctrl-C, or Ctrl-D on
7
+ // an empty line, quits. While a submitted line runs, keys still edit the
8
+ // buffer, and the prompt comes back when it is done. Log lines written by
9
+ // console.* while the editor is up are placed above the prompt line: the
10
+ // line is cleared, the message printed, the prompt redrawn.
11
+
12
+ export type Completion = {
13
+ /** The candidates for the trailing part of the line. */
14
+ matches: string[]
15
+ /** The trailing part of the line a candidate replaces. */
16
+ replace: string
17
+ }
18
+
19
+ export type EditorOptions = {
20
+ prompt: string
21
+ /** A submitted line; the prompt returns once it settles. */
22
+ onLine: (line: string) => Promise<void> | void
23
+ onQuit: () => void
24
+ complete?: (line: string) => Promise<Completion>
25
+ }
26
+
27
+ // Clear the line and return to column 0, then the prompt and the buffer; the
28
+ // cursor ends after the buffer, so move it back for a cursor inside it.
29
+ const CLEAR_LINE = "\r\x1b[K"
30
+
31
+ function commonPrefix(items: string[]): string {
32
+ let prefix = items[0] ?? ""
33
+ for (let item of items) {
34
+ let i = 0
35
+ while (i < prefix.length && i < item.length && prefix[i] === item[i]) i++
36
+ prefix = prefix.slice(0, i)
37
+ }
38
+ return prefix
39
+ }
40
+
41
+ // Start the editor; returns the function that stops it (restores console,
42
+ // ends the line so the shell prompt starts fresh, leaves raw mode).
43
+ export function startLineEditor(opts: EditorOptions): () => void {
44
+ let buffer = ""
45
+ let cursor = 0
46
+ let history: string[] = []
47
+ let historyAt = -1
48
+ let draft = ""
49
+ let busy = false
50
+ let stopped = false
51
+
52
+ let redraw = () => {
53
+ if (stopped || busy) return
54
+ let back = buffer.length - cursor
55
+ write(CLEAR_LINE + opts.prompt + buffer + (back > 0 ? `\x1b[${back}D` : ""))
56
+ }
57
+
58
+ let submit = () => {
59
+ let line = buffer
60
+ write("\r\n")
61
+ if (line.trim() && history[history.length - 1] !== line) history.push(line)
62
+ historyAt = -1
63
+ buffer = ""
64
+ cursor = 0
65
+ busy = true
66
+ Promise.resolve()
67
+ .then(() => opts.onLine(line))
68
+ .catch((e) => console.error(`[cli] ${String(e)}`))
69
+ .then(() => {
70
+ busy = false
71
+ redraw()
72
+ })
73
+ }
74
+
75
+ let recall = (at: number) => {
76
+ if (historyAt === -1) draft = buffer
77
+ historyAt = at
78
+ buffer = at === -1 ? draft : history[at]!
79
+ cursor = buffer.length
80
+ }
81
+
82
+ let complete = async () => {
83
+ if (!opts.complete) return
84
+ let head = buffer.slice(0, cursor)
85
+ let asked = buffer
86
+ let { matches, replace } = await opts.complete(head)
87
+ // Typed on (or submitted) while the candidates were being looked up:
88
+ // they answer a line that no longer exists.
89
+ if (buffer !== asked || cursor !== head.length) return
90
+ if (matches.length === 0) return
91
+ let insert = matches.length === 1 ? matches[0]! : commonPrefix(matches)
92
+ if (insert.length > replace.length) {
93
+ buffer = head.slice(0, head.length - replace.length) + insert + buffer.slice(cursor)
94
+ cursor += insert.length - replace.length
95
+ } else if (matches.length > 1) {
96
+ write("\r\n" + matches.join(" ") + "\r\n")
97
+ }
98
+ redraw()
99
+ }
100
+
101
+ let onKey = (key: Key) => {
102
+ if (key.ctrl) {
103
+ switch (key.name) {
104
+ case "c":
105
+ opts.onQuit()
106
+ return
107
+ case "d":
108
+ if (buffer.length === 0) opts.onQuit()
109
+ else if (cursor < buffer.length) buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1)
110
+ break
111
+ case "a":
112
+ cursor = 0
113
+ break
114
+ case "e":
115
+ cursor = buffer.length
116
+ break
117
+ case "u":
118
+ buffer = buffer.slice(cursor)
119
+ cursor = 0
120
+ break
121
+ default:
122
+ return
123
+ }
124
+ redraw()
125
+ return
126
+ }
127
+ if (key.meta) return
128
+ switch (key.name) {
129
+ case "return":
130
+ submit()
131
+ return
132
+ case "backspace":
133
+ if (cursor > 0) {
134
+ buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor)
135
+ cursor--
136
+ }
137
+ break
138
+ case "delete":
139
+ if (cursor < buffer.length) buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1)
140
+ break
141
+ case "left":
142
+ if (cursor > 0) cursor--
143
+ break
144
+ case "right":
145
+ if (cursor < buffer.length) cursor++
146
+ break
147
+ case "home":
148
+ cursor = 0
149
+ break
150
+ case "end":
151
+ cursor = buffer.length
152
+ break
153
+ case "up":
154
+ if (history.length && historyAt !== 0) recall(historyAt === -1 ? history.length - 1 : historyAt - 1)
155
+ break
156
+ case "down":
157
+ if (historyAt !== -1) recall(historyAt === history.length - 1 ? -1 : historyAt + 1)
158
+ break
159
+ case "tab":
160
+ complete()
161
+ return
162
+ default:
163
+ if (key.char === undefined) return
164
+ buffer = buffer.slice(0, cursor) + key.char + buffer.slice(cursor)
165
+ cursor += key.char.length
166
+ }
167
+ redraw()
168
+ }
169
+
170
+ setRawMode(true)
171
+ let offKey = on("key", onKey)
172
+ let offClose = on("close", opts.onQuit)
173
+
174
+ // console.* output lands above the prompt line: the runtime writes the
175
+ // message with "\r\n" line breaks in raw mode, this side clears the prompt
176
+ // first and redraws it after.
177
+ let original = { log: console.log, warn: console.warn, error: console.error, debug: console.debug }
178
+ let wrap =
179
+ (print: (...args: unknown[]) => void) =>
180
+ (...args: unknown[]) => {
181
+ if (!busy) write(CLEAR_LINE)
182
+ print(...args)
183
+ redraw()
184
+ }
185
+ console.log = wrap(original.log)
186
+ console.warn = wrap(original.warn)
187
+ console.error = wrap(original.error)
188
+ console.debug = wrap(original.debug)
189
+
190
+ redraw()
191
+ return () => {
192
+ if (stopped) return
193
+ stopped = true
194
+ Object.assign(console, original)
195
+ offKey()
196
+ offClose()
197
+ write("\r\n")
198
+ setRawMode(false)
199
+ }
200
+ }
@@ -0,0 +1,473 @@
1
+ // The srt dev server as a flux script, complete on its own: started as
2
+ // `flux server.js [flags]` from a project root (or with a file), by `srt run`
3
+ // / `srt server` (bun launchers that only resolve the binaries and spawn it)
4
+ // or by the console. It resolves its mode (mode.ts), owns everything with a
5
+ // lifetime - the port (bound here: remembered, given, or the first free one),
6
+ // the registry record, the local client, the latched bundle, the control API -
7
+ // and spawns the two things only bun can do, the bundle (`srt bundle --json`,
8
+ // rebuild.ts) and the startup typecheck (`srt check`), by command name
9
+ // (okf/done/srt-command-folders.md).
10
+
11
+ import { serve } from "flux:http"
12
+ import type { FluxRequest, Server, ServerWebSocket } from "flux:http"
13
+ import { file, realpath } from "flux:fs"
14
+ import { on as onSignal } from "flux:process"
15
+ import { join, resolveWithin } from "flux:path"
16
+ import { command } from "flux:subprocess"
17
+ import { interfaces, probe } from "flux:net"
18
+ import type { Child } from "flux:subprocess"
19
+ import { state } from "./state"
20
+ import { fail, parseArgs } from "./args"
21
+ import type { ServerConfig } from "./config"
22
+ import { absolute, resolveMode, sourceDirOf } from "./mode"
23
+ import { requireBinary, srtCommand } from "./binaries"
24
+ import * as cache from "./cache"
25
+ import { handleProxy } from "./proxy"
26
+ import { appendLog, handleControl, resolveQuery } from "./control"
27
+ import { printQr } from "./qr"
28
+ import { createTunnelEndpoint, TUNNEL_PROTOCOL } from "./tunnel"
29
+ import { rebuildAndBroadcast, showBuildFailure } from "./rebuild"
30
+ import { stopWatcher } from "./watcher"
31
+ import { startRepl } from "./repl"
32
+ import { devDir, pruneDeadRecords, rememberedPort, removeRecord, runningFor, serverDirFor, writeRecord } from "./registry"
33
+
34
+ let args = parseArgs()
35
+ let mode = await resolveMode(args)
36
+
37
+ // Records left behind by crashed servers are fossils; clear them first.
38
+ await pruneDeadRecords()
39
+
40
+ // One server per key: a second run in the same project (or on the same
41
+ // file) points at the running one instead of racing it.
42
+ let running = await runningFor(mode.key)
43
+ if (running) {
44
+ fail(`A dev server already serves ${mode.key} on port ${running.port} (pid ${running.pid}). Stop it first, or attach a client with srt client.`)
45
+ }
46
+
47
+ let srt = srtCommand()
48
+ let runner = args.client !== null ? await requireBinary("solidrt-go") : null
49
+ let serverDir = await serverDirFor(mode.key)
50
+
51
+ // The LAN address (for --lan): the IPv4 of the interface holding the default
52
+ // route, which is the one other hosts reach; VPN and bridge interfaces (wg0,
53
+ // docker0) are up too, so first-up would announce them at random. Without a
54
+ // default route, the first up, non-loopback IPv4.
55
+ let lanAddress = args.lan ? lanAddressOf(interfaces()) : undefined
56
+ function lanAddressOf(ifaces: ReturnType<typeof interfaces>): string | undefined {
57
+ let v4 = (list: typeof ifaces) => list.flatMap((i) => i.addrs).find((a) => a.family === "v4")?.ip
58
+ let reachable = ifaces.filter((i) => i.up && !i.loopback)
59
+ return v4(reachable.filter((i) => i.default)) ?? v4(reachable)
60
+ }
61
+
62
+ // Storage flags for the local client: dev client trees live under
63
+ // ~/.solidrt/clients/client<N>/ (an explicit --data-root wins), passed
64
+ // absolute because the client chdirs into its app sandbox at startup.
65
+ let clientArgs: string[] = []
66
+ if (args.client !== null) {
67
+ clientArgs.push("--data-root", args.dataRoot ? absolute(args.dataRoot, await realpath(".")) : devDir("clients"))
68
+ clientArgs.push("--client", String(args.client))
69
+ if (args.size) clientArgs.push("--size", args.size)
70
+ }
71
+
72
+ let config: ServerConfig = {
73
+ mode: mode.mode,
74
+ key: mode.key,
75
+ serverDir,
76
+ entry: mode.entry,
77
+ sourceDir: sourceDirOf(mode),
78
+ projectDir: mode.projectDir,
79
+ cwd: mode.projectDir ?? sourceDirOf(mode),
80
+ entryArgs: [mode.entry, mode.mode === "project" ? "--project" : "--file"],
81
+ srt,
82
+ port: args.port,
83
+ lan: args.lan,
84
+ address: lanAddress ?? "127.0.0.1",
85
+ proxyHttp: args.proxyHttp,
86
+ args: args.appArgs,
87
+ minify: args.minify,
88
+ cache: args.proxyHttp,
89
+ // Build outputs and the proxy cache: the project's .srt-data, or the
90
+ // server folder for a file served on its own (nothing else owns it).
91
+ cacheDir: mode.projectDir ? join(mode.projectDir, ".srt-data") : join(serverDir, "data"),
92
+ capture: args.capture,
93
+ stats: args.stats,
94
+ tunnel: args.tunnel,
95
+ client: runner ? { cmd: runner, args: clientArgs } : null,
96
+ }
97
+ state.config = config
98
+ state.stats = config.stats
99
+
100
+ if (config.cache) {
101
+ await cache.initCache({ dir: config.cacheDir })
102
+ console.log("[cli] HTTP cache enabled")
103
+ }
104
+
105
+ if (config.capture) {
106
+ // Start each capture from an empty file: appends would otherwise tack onto
107
+ // whatever a previous run left behind.
108
+ await file(config.capture).write("")
109
+ state.captureStartMs = Date.now()
110
+ }
111
+
112
+ // Split an origin-form request URL ("/path?a=1&b=2") into its decoded path and
113
+ // query parameters. flux has no URL global; this covers what the routes need.
114
+ function splitQuery(url: string): { path: string; query: Map<string, string> } {
115
+ let i = url.indexOf("?")
116
+ let path = i < 0 ? url : url.slice(0, i)
117
+ let query = new Map<string, string>()
118
+ if (i >= 0) {
119
+ for (let pair of url.slice(i + 1).split("&")) {
120
+ if (!pair) continue
121
+ let j = pair.indexOf("=")
122
+ let k = j < 0 ? pair : pair.slice(0, j)
123
+ let v = j < 0 ? "" : pair.slice(j + 1)
124
+ query.set(decodeURIComponent(k), decodeURIComponent(v.replace(/\+/g, " ")))
125
+ }
126
+ }
127
+ return { path: decodeURIComponent(path), query }
128
+ }
129
+
130
+ // The file routes: GET file with single-range 206 support. All paths are
131
+ // contained in `root` (the source directory, or the project dir for the
132
+ // /assets/ convention route).
133
+ async function handleFiles(req: FluxRequest, path: string, root: string): Promise<Response> {
134
+ let filePath = resolveWithin(root, "." + path)
135
+ if (!filePath) {
136
+ return new Response("Forbidden", { status: 403 })
137
+ }
138
+
139
+ console.log("[cli] get " + path)
140
+
141
+ let stat
142
+ try {
143
+ stat = await file(filePath).stat()
144
+ } catch {
145
+ console.log(`[cli] file not found ${path}`)
146
+ return new Response("Not found", { status: 404 })
147
+ }
148
+ if (stat.type === "directory") {
149
+ return new Response("Not found", { status: 404 })
150
+ }
151
+
152
+ let baseHeaders: Record<string, string> = { "Accept-Ranges": "bytes" }
153
+
154
+ // Honor a single byte-range request (e.g. streaming audio decoding on the
155
+ // client, which seeks and reads on demand). Only the common "bytes=a-b" /
156
+ // "bytes=a-" / "bytes=-n" forms; anything else falls through to the whole
157
+ // file. Range makes proxied streaming viable without pulling the whole
158
+ // track over the wire.
159
+ let range = req.headers.get("range")
160
+ let match = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null
161
+ if (match) {
162
+ let size = stat.size
163
+ let start: number
164
+ let end: number
165
+ if (match[1] === "") {
166
+ // Suffix range: the last N bytes.
167
+ let n = parseInt(match[2]!, 10)
168
+ start = isNaN(n) ? 0 : Math.max(0, size - n)
169
+ end = size - 1
170
+ } else {
171
+ start = parseInt(match[1]!, 10)
172
+ end = match[2] === "" ? size - 1 : Math.min(parseInt(match[2]!, 10), size - 1)
173
+ }
174
+ if (start > end || start >= size) {
175
+ return new Response("Range not satisfiable", {
176
+ status: 416,
177
+ headers: { ...baseHeaders, "Content-Range": `bytes */${size}` },
178
+ })
179
+ }
180
+ return new Response(await file(filePath).read(start, end - start + 1), {
181
+ status: 206,
182
+ headers: {
183
+ ...baseHeaders,
184
+ "Content-Range": `bytes ${start}-${end}/${size}`,
185
+ "Content-Length": String(end - start + 1),
186
+ },
187
+ })
188
+ }
189
+
190
+ return new Response(await file(filePath).bytes(), { headers: baseHeaders })
191
+ }
192
+
193
+ async function handleRequest(req: FluxRequest, server: Server): Promise<Response | undefined> {
194
+ if (server.upgrade(req)) return
195
+
196
+ let { path, query } = splitQuery(req.url)
197
+
198
+ if (path === "/__proxy__") {
199
+ return handleProxy(req)
200
+ }
201
+ if (path.startsWith("/__control__/")) {
202
+ // Every control response names the key this server serves, so a caller
203
+ // that resolved the port from the registry can confirm it reached the
204
+ // server it meant.
205
+ let resp = await handleControl(req, path, query)
206
+ resp.headers.set("x-solidrt-project", config.key)
207
+ resp.headers.set("x-solidrt-generation", String(state.generation))
208
+ return resp
209
+ }
210
+ // The assets/ convention roots at the project dir (package.json), which is
211
+ // not necessarily the entry's dir the file routes serve; clients fetch
212
+ // manifest asset paths here (live proxy reads and store installs alike).
213
+ // File mode has no project and so no assets.
214
+ if (path === "/assets" || path.startsWith("/assets/")) {
215
+ if (!config.projectDir) return new Response("Not found", { status: 404 })
216
+ return handleFiles(req, path, config.projectDir)
217
+ }
218
+ // Isolate bundles are build outputs, not project files: the rebuild writes
219
+ // them under <cacheDir>/isolates/, and the manifest lists them as
220
+ // isolates/<id>.js.
221
+ if (path.startsWith("/isolates/")) {
222
+ return handleFiles(req, path, config.cacheDir)
223
+ }
224
+ return handleFiles(req, path, config.sourceDir)
225
+ }
226
+
227
+ // A string field of a client's `info`, or null when absent or malformed
228
+ // (an older runtime, or one without the fact).
229
+ function text(value: unknown): string | null {
230
+ return typeof value === "string" ? value : null
231
+ }
232
+
233
+ function onOpen(ws: ServerWebSocket) {
234
+ let id = state.nextClientId++
235
+ state.clients.set(ws, {
236
+ platform: "unknown",
237
+ version: "unknown",
238
+ profile: "unknown",
239
+ id,
240
+ capabilities: [],
241
+ queries: [],
242
+ stats: state.stats,
243
+ timeScale: 1,
244
+ clientDir: null,
245
+ pid: null,
246
+ execPath: null,
247
+ host: null,
248
+ os: null,
249
+ kernel: null,
250
+ videoDriver: null,
251
+ gpu: null,
252
+ })
253
+ console.log(`[cli] Client connected ${ws.remoteAddr ?? "unknown"}`)
254
+ // Advertise the address we are reachable on, so clients dialed over a
255
+ // loopback hop can show/remember it (see connection.rs).
256
+ ws.send(JSON.stringify({ type: "welcome", address: state.serverUrl, stats: state.stats, capture: !!config.capture, mute: state.userInputMuted }))
257
+ if (state.currentReload) {
258
+ ws.send(state.currentReload)
259
+ }
260
+ }
261
+
262
+ function onClose(ws: ServerWebSocket) {
263
+ let info = state.clients.get(ws)
264
+ state.clients.delete(ws)
265
+ console.log(`[cli] Client disconnected: ${info?.platform ?? "unknown"}`)
266
+ // `srt run` lives as long as its clients: once the local client is gone,
267
+ // the last remote disconnect ends the server. `srt server` runs until
268
+ // stopped.
269
+ if (config.client && localClientExited && state.clients.size === 0) shutdown()
270
+ }
271
+
272
+ function onMessage(ws: ServerWebSocket, msg: string | Uint8Array) {
273
+ try {
274
+ let data = JSON.parse(typeof msg === "string" ? msg : new TextDecoder().decode(msg))
275
+ if (data.type === "info") {
276
+ let existing = state.clients.get(ws)
277
+ state.clients.set(ws, {
278
+ platform: data.platform ?? "unknown",
279
+ version: data.version ?? "unknown",
280
+ profile: data.profile ?? "unknown",
281
+ id: existing?.id ?? state.nextClientId++,
282
+ capabilities: Array.isArray(data.capabilities) ? data.capabilities.map(String) : [],
283
+ queries: Array.isArray(data.queries) ? data.queries.map(String) : [],
284
+ stats: existing?.stats ?? state.stats,
285
+ timeScale: existing?.timeScale ?? 1,
286
+ clientDir: text(data.clientDir),
287
+ pid: typeof data.pid === "number" ? data.pid : null,
288
+ execPath: text(data.execPath),
289
+ host: text(data.host),
290
+ os: text(data.os),
291
+ kernel: text(data.kernel),
292
+ videoDriver: text(data.videoDriver),
293
+ gpu:
294
+ data.gpu && typeof data.gpu === "object"
295
+ ? { vendor: text(data.gpu.vendor) ?? "", renderer: text(data.gpu.renderer) ?? "", version: text(data.gpu.version) ?? "" }
296
+ : null,
297
+ })
298
+ console.log(`[cli] Client info ${ws.remoteAddr ?? "unknown"} ${data.platform} (${data.version})`)
299
+ } else if (data.type === "log") {
300
+ // Forwarded console output / runtime errors from the client's engine
301
+ // logger, buffered for the control API (see control.ts). Not printed
302
+ // here: the local client already writes to this terminal, so echoing
303
+ // would duplicate every line.
304
+ let device = state.clients.get(ws)?.id ?? -1
305
+ appendLog(device, String(data.level ?? "log"), String(data.text ?? ""))
306
+ } else if (data.type === "result") {
307
+ // Reply to a query the control API forwarded to this client.
308
+ resolveQuery(data)
309
+ } else if (data.type === "capture" && config.capture) {
310
+ let device = state.clients.get(ws)?.id ?? -1
311
+ // Milliseconds, integer: Date.now() is already integer ms, so the
312
+ // delta needs no rounding.
313
+ let at = Date.now() - state.captureStartMs
314
+ let after = at - state.captureLastAt
315
+ state.captureLastAt = at
316
+ // JSON Lines: one event object per line, streamed to disk as it
317
+ // arrives rather than buffered - no in-memory growth for a long
318
+ // capture, and the file is always complete on disk mid-session.
319
+ // Appends are chained so events land in arrival order.
320
+ let line = JSON.stringify({ after, type: data.kind, key: data.key, device }) + "\n"
321
+ state.captureChain = state.captureChain.then(() => file(config.capture!).append(line))
322
+ }
323
+ } catch {}
324
+ }
325
+
326
+ // The port: an explicit --port, else the one this server bound last time
327
+ // (so a project keeps its port in practice), else the first free one from
328
+ // DEFAULT_PORT upward, so servers on one machine read as 34884, 34885, ...
329
+ // A remembered or default port that is taken is skipped; an explicit one
330
+ // that is taken is the user's problem to see.
331
+ const DEFAULT_PORT = 0x8844
332
+ const PORT_TRIES = 100
333
+ let host = config.lan ? "0.0.0.0" : "127.0.0.1"
334
+ let remembered = config.port ?? (await rememberedPort(config.serverDir))
335
+
336
+ // Ticket-paired clients connect through this endpoint; serve() accepts its
337
+ // connections directly alongside the TCP listener. Its UDP port follows the
338
+ // remembered port so a ticket stays stable across restarts.
339
+ let tunnel = config.tunnel ? await createTunnelEndpoint(remembered, config.serverDir) : null
340
+
341
+ function bind(port: number): Server {
342
+ return serve({
343
+ host,
344
+ port,
345
+ p2p: tunnel ? { endpoint: tunnel, protocol: TUNNEL_PROTOCOL } : undefined,
346
+ fetch: handleRequest,
347
+ websocket: { open: onOpen, close: onClose, message: onMessage },
348
+ })
349
+ }
350
+
351
+ // A bind alone does not prove a port free: with SO_REUSEADDR (the default
352
+ // on a listener) Linux lets a loopback bind coexist with another process's
353
+ // all-interfaces listener on the same port, and the newcomer then silently
354
+ // takes the loopback traffic. So each candidate is dialed first; only a
355
+ // refusal means free.
356
+ async function bindFirstFree(): Promise<Server> {
357
+ if (config.port !== undefined) return bind(config.port)
358
+ let candidates: number[] = remembered !== null ? [remembered] : []
359
+ for (let p = DEFAULT_PORT; p < DEFAULT_PORT + PORT_TRIES; p++) candidates.push(p)
360
+ let last: unknown = null
361
+ for (let port of candidates) {
362
+ if ((await probe("127.0.0.1", port, { timeoutMs: 200 })) !== "closed") continue
363
+ try {
364
+ return bind(port)
365
+ } catch (e) {
366
+ last = e
367
+ }
368
+ }
369
+ throw last ?? new Error(`No free port between ${DEFAULT_PORT} and ${DEFAULT_PORT + PORT_TRIES - 1}`)
370
+ }
371
+
372
+ let server = await bindFirstFree()
373
+
374
+ let address = config.lan ? config.address : "127.0.0.1"
375
+ state.serverUrl = `${address}:${server.port}`
376
+ await writeRecord(config, server.port, address)
377
+
378
+ // One QR on screen: with the tunnel on, the ticket QR (printed by
379
+ // createTunnelEndpoint) is the pairing story and the address stays text-only;
380
+ // on the LAN without it, the address QR is the scan target. Loopback-only has
381
+ // nothing to scan.
382
+ if (config.lan && !config.tunnel) {
383
+ console.log("")
384
+ printQr(state.serverUrl)
385
+ console.log("")
386
+ }
387
+ console.log(`[cli] Dev server on http://${state.serverUrl} serving ${config.mode} ${config.key}`)
388
+
389
+ // Keepalive
390
+ let keepalive = setInterval(() => {
391
+ for (let ws of state.clients.keys()) {
392
+ ws.ping()
393
+ }
394
+ }, 5000)
395
+
396
+ let shuttingDown = false
397
+ let stopRepl = () => {}
398
+ let localClient: Child | null = null
399
+ let localClientExited = false
400
+ let signalOffs = ["SIGINT", "SIGTERM"].map((signal) =>
401
+ onSignal(signal, () => {
402
+ shutdown()
403
+ }),
404
+ )
405
+
406
+ // Orderly exit: drop the record, stop the client, close the listeners and
407
+ // release every handle that keeps the loop alive, so the process ends on
408
+ // its own (flux has no exit call; an idle loop is the exit).
409
+ async function shutdown() {
410
+ if (shuttingDown) return
411
+ shuttingDown = true
412
+ clearInterval(keepalive)
413
+ for (let off of signalOffs) off()
414
+ stopRepl()
415
+ stopWatcher()
416
+ await removeRecord(config.serverDir)
417
+ if (localClient) localClient.kill()
418
+ server.close()
419
+ if (tunnel) await tunnel.close()
420
+ }
421
+
422
+ // Print a child's output line by line as it arrives.
423
+ async function pump(stream: AsyncIterable<Uint8Array>, print: (line: string) => void) {
424
+ let decoder = new TextDecoder()
425
+ let rest = ""
426
+ for await (let chunk of stream) {
427
+ rest += decoder.decode(chunk, { stream: true })
428
+ let lines = rest.split("\n")
429
+ rest = lines.pop() ?? ""
430
+ for (let line of lines) print(line)
431
+ }
432
+ if (rest) print(rest)
433
+ }
434
+
435
+ // The initial bundle, latched for the clients about to connect. A failed
436
+ // build shows the BSOD rather than nothing; the next reload retries. The
437
+ // rebuild arms reload-on-save from the bundle's inputs (watcher.ts).
438
+ console.log("[cli] Bundling (development)")
439
+ let buildError = await rebuildAndBroadcast()
440
+ if (buildError) {
441
+ console.error(buildError)
442
+ showBuildFailure()
443
+ }
444
+ console.log("[cli] Reload on save is on (pause it with the MCP pause_watch tool)")
445
+ stopRepl = startRepl(shutdown)
446
+
447
+ // Startup typecheck (`srt check <entry>`), deliberately not awaited: the
448
+ // report prints when tsc finishes, and a type error never gates the boot
449
+ // (srt check is the hard gate). Once per server lifetime; reloads never
450
+ // typecheck. A prebuilt .srt.js has no checkable program.
451
+ if (!config.entry.endsWith(".srt.js")) {
452
+ let check = command(config.srt[0]!, [...config.srt.slice(1), "check", config.entry], { cwd: config.cwd }).spawn()
453
+ pump(check.stdout, (line) => console.log(line))
454
+ pump(check.stderr, (line) => console.error(line))
455
+ }
456
+
457
+ if (config.client) {
458
+ // The local client dials the port bound above; srt could not know it.
459
+ let child = command(config.client.cmd, [...config.client.args, "--dev-server", `127.0.0.1:${server.port}`]).spawn()
460
+ localClient = child
461
+ pump(child.stdout, (line) => console.log(line))
462
+ pump(child.stderr, (line) => console.error(line))
463
+ child.status().then(() => {
464
+ localClient = null
465
+ localClientExited = true
466
+ if (shuttingDown) return
467
+ if (state.clients.size === 0) {
468
+ shutdown()
469
+ } else {
470
+ console.log(`[cli] Local client exited, ${state.clients.size} remote client(s) still connected`)
471
+ }
472
+ })
473
+ }