@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/package.json +9 -7
- package/scaffold/package.json +5 -5
- package/{src → server}/cache.ts +23 -51
- package/{src → server}/control.ts +30 -17
- package/server/main.ts +292 -0
- package/server/proxy.ts +72 -0
- package/server/qr.ts +35 -0
- package/server/state.ts +46 -0
- package/server/tsconfig.json +11 -0
- package/server/tunnel.ts +53 -0
- package/src/args.ts +2 -0
- package/src/commands/mcp.ts +31 -1
- package/src/commands/server.ts +13 -18
- package/src/dev-client.ts +10 -21
- package/src/dev-server.ts +141 -301
- package/src/repl.ts +54 -42
- package/src/util.ts +27 -16
- package/src/watcher.ts +3 -6
package/src/dev-server.ts
CHANGED
|
@@ -1,27 +1,64 @@
|
|
|
1
1
|
import { resolve } from "path"
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
//
|
|
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
|
|
21
|
+
return { type: "reload", proxyFiles: values["proxy-files"], proxyHttp: values["proxy-http"], ...payload }
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
let
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
}
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
31
|
+
await sendStop()
|
|
15
32
|
print("[cli] Sent stop to all clients")
|
|
16
33
|
return
|
|
17
34
|
}
|
|
18
|
-
let
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
54
|
+
await sendReload(msg, { latch: true })
|
|
43
55
|
print("[cli] Sent reload to all clients")
|
|
44
56
|
return
|
|
45
57
|
}
|
|
46
|
-
let
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
76
|
+
await sendStats(state.stats)
|
|
70
77
|
print(`[cli] Stats overlay ${state.stats ? "on" : "off"}`)
|
|
71
78
|
}
|
|
72
79
|
|
|
73
|
-
function cmdList() {
|
|
74
|
-
|
|
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(`${
|
|
86
|
+
print(`${clients.length} connected client(s):`)
|
|
79
87
|
let i = 0
|
|
80
|
-
for (let
|
|
81
|
-
print(` ${i++}: ${
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
+
}
|