@solidrt/cli 0.0.24 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/cli",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "bin/",
12
12
  "src/",
13
+ "server/",
13
14
  "scaffold/",
14
15
  "AGENTS.md"
15
16
  ],
@@ -17,20 +18,22 @@
17
18
  "@babel/core": "^7.0.0",
18
19
  "@babel/plugin-syntax-jsx": "^7.0.0",
19
20
  "@babel/preset-typescript": "^7.0.0",
20
- "babel-preset-solid": "2.0.0-beta.15",
21
+ "@modelcontextprotocol/sdk": "^1.29.0",
22
+ "babel-preset-solid": "2.0.0-beta.17",
21
23
  "bonjour-service": "^1.4.0",
22
24
  "qrcode-generator": "^2.0.4"
23
25
  },
24
26
  "optionalDependencies": {
25
- "@solidrt/darwin-arm64": "0.0.24",
26
- "@solidrt/linux-x64-gnu": "0.0.24",
27
- "@solidrt/win32-x64-msvc": "0.0.24"
27
+ "@solidrt/darwin-arm64": "0.0.26",
28
+ "@solidrt/linux-x64-gnu": "0.0.26",
29
+ "@solidrt/win32-x64-msvc": "0.0.26"
28
30
  },
29
31
  "peerDependencies": {
30
- "@solidrt/core": "0.0.24",
31
- "typescript": "^6"
32
+ "@solidrt/core": "0.0.26",
33
+ "typescript": "^7"
32
34
  },
33
35
  "devDependencies": {
36
+ "@solidrt/flux-types": "0.0.26",
34
37
  "@types/bun": "latest"
35
38
  }
36
39
  }
@@ -84,3 +84,19 @@ Authoritative references ship inside the installed packages - read them:
84
84
  - bunx srt render src/index.tsx --size 480x640 --duration 1 --fps 2 - headless
85
85
  render to PNG frames (proves it renders; see the cli AGENTS.md for where the
86
86
  frames land)
87
+
88
+ ## MCP: inspect the running app
89
+
90
+ The project ships an MCP server (.mcp.json, `srt mcp`) that talks to the dev
91
+ server `bunx srt run` starts. When it is loaded in your environment, prefer
92
+ its tools over guessing at runtime state:
93
+
94
+ - list_clients: connected app clients, their platform and runtime capabilities
95
+ - get_logs: console output and runtime errors (seq cursor; `wait_ms` long-poll
96
+ to catch output right after a reload)
97
+ - get_render_tree: what the app actually rendered - node kinds, text, and
98
+ window-relative boxes
99
+ - get_stats: fps, CPU/memory, frame phase timings, setProperty rate
100
+
101
+ The tools need a running app: if list_clients is empty, ask the user to start
102
+ `bunx srt run src/index.tsx`.
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "solidrt": {
4
+ "command": "bun",
5
+ "args": ["node_modules/@solidrt/cli/bin/srt", "mcp"]
6
+ }
7
+ }
8
+ }
@@ -9,12 +9,12 @@
9
9
  "android": "srt client --android"
10
10
  },
11
11
  "dependencies": {
12
- "@solidrt/core": "0.0.24",
13
- "@solidrt/components": "0.0.24"
12
+ "@solidrt/core": "0.0.26",
13
+ "@solidrt/components": "0.0.26"
14
14
  },
15
15
  "devDependencies": {
16
- "@solidrt/cli": "0.0.24",
17
- "@solidrt/flux-types": "0.0.24",
18
- "typescript": "^6"
16
+ "@solidrt/cli": "0.0.26",
17
+ "@solidrt/flux-types": "0.0.26",
18
+ "typescript": "^7"
19
19
  }
20
20
  }
@@ -1,15 +1,16 @@
1
1
  // SQLite-backed HTTP response cache for the dev server's /__proxy__ endpoint.
2
2
  //
3
- // Project-local: stored at <cwd>/.srt-cache.db. Opt-in via the --cache
4
- // flag. Entries live forever; delete .srt-cache.db to drop them.
3
+ // Project-local: stored at <dir>/.srt-cache.db. Entries live forever; delete
4
+ // .srt-cache.db to drop them.
5
5
  //
6
6
  // Cached: GET (and HEAD) 2xx responses with no Authorization on the request
7
7
  // and no Cache-Control: no-store on either side. The cache key is
8
- // sha256(method + "\n" + url); headers are intentionally not part of the key.
8
+ // method + "\n" + url, stored raw; headers are intentionally not part of the
9
+ // key. (The Bun predecessor hashed the key with sha256, which was cosmetic:
10
+ // old hashed entries simply miss and re-populate.)
9
11
 
10
- import { Database } from "bun:sqlite"
11
- import { resolve } from "path"
12
- import { createHash } from "node:crypto"
12
+ import { Database } from "flux:sqlite"
13
+ import { join } from "flux:path"
13
14
 
14
15
  const CACHE_FILE = ".srt-cache.db"
15
16
 
@@ -27,9 +28,9 @@ export type Entry = {
27
28
  let db: Database | null = null
28
29
  let enabled = false
29
30
 
30
- export function initCache(opts: { dir: string }) {
31
- let d = new Database(resolve(opts.dir, CACHE_FILE), { create: true })
32
- d.run(`CREATE TABLE IF NOT EXISTS entries (
31
+ export async function initCache(opts: { dir: string }) {
32
+ let d = await Database.connect(join(opts.dir, CACHE_FILE), "rw+")
33
+ await d.exec(`CREATE TABLE IF NOT EXISTS entries (
33
34
  key TEXT PRIMARY KEY,
34
35
  method TEXT NOT NULL,
35
36
  url TEXT NOT NULL,
@@ -46,28 +47,8 @@ export function isEnabled(): boolean {
46
47
  return enabled
47
48
  }
48
49
 
49
- // Strings returned by bun:sqlite carry an internal representation that
50
- // Headers.set() rejects, even when value-identical to an acceptable string
51
- // (Bun bug oven-sh/bun#28266, present through 1.3.14). Rebuilding each char
52
- // yields a clean string.
53
- function reflatten(s: string): string {
54
- return Array.from(s, (c) => String.fromCharCode(c.charCodeAt(0))).join("")
55
- }
56
-
57
- function reflattenHeaders(obj: Record<string, string>): Record<string, string> {
58
- let out: Record<string, string> = {}
59
- for (let key in obj) {
60
- out[reflatten(key)] = reflatten(obj[key]!)
61
- }
62
- return out
63
- }
64
-
65
50
  function keyFor(method: string, url: string): string {
66
- let h = createHash("sha256")
67
- h.update(method)
68
- h.update("\n")
69
- h.update(url)
70
- return h.digest("hex")
51
+ return method + "\n" + url
71
52
  }
72
53
 
73
54
  function cacheableMethod(method: string): boolean {
@@ -98,32 +79,23 @@ export function isBypass(reqHeaders: Headers): boolean {
98
79
  return false
99
80
  }
100
81
 
101
- export function get(method: string, url: string): Entry | null {
82
+ export async function get(method: string, url: string): Promise<Entry | null> {
102
83
  if (!db || !enabled) return null
103
- let row = db
84
+ let row = await db
104
85
  .query("SELECT method, url, status, headers, body, cached_at FROM entries WHERE key = ?")
105
- .get(keyFor(method, url)) as
106
- | {
107
- method: string
108
- url: string
109
- status: number
110
- headers: string
111
- body: Uint8Array
112
- cached_at: number
113
- }
114
- | null
86
+ .get([keyFor(method, url)])
115
87
  if (!row) return null
116
88
  return {
117
- method: row.method,
118
- url: row.url,
119
- status: row.status,
120
- headers: reflattenHeaders(JSON.parse(row.headers)),
121
- body: row.body,
122
- cachedAt: row.cached_at,
89
+ method: row.method as string,
90
+ url: row.url as string,
91
+ status: row.status as number,
92
+ headers: JSON.parse(row.headers as string),
93
+ body: row.body as Uint8Array,
94
+ cachedAt: row.cached_at as number,
123
95
  }
124
96
  }
125
97
 
126
- export function put(
98
+ export async function put(
127
99
  method: string,
128
100
  url: string,
129
101
  status: number,
@@ -133,10 +105,10 @@ export function put(
133
105
  if (!db || !enabled) return
134
106
  if (status < 200 || status >= 300) return
135
107
  if (hasNoStore(headers["cache-control"] ?? null)) return
136
- db.run(
108
+ await db.run(
137
109
  `INSERT OR REPLACE INTO entries
138
110
  (key, method, url, status, headers, body, cached_at)
139
111
  VALUES (?, ?, ?, ?, ?, ?, ?)`,
140
112
  [keyFor(method, url), method, url, status, JSON.stringify(headers), body, Date.now()],
141
113
  )
142
- }
114
+ }
@@ -0,0 +1,132 @@
1
+ import { state } from "./state"
2
+ import type { ServerWebSocket } from "flux:http"
3
+
4
+ // The control API under /__control__/: read-only introspection of connected
5
+ // app clients, served next to the file routes. The MCP bridge (srt mcp) is the
6
+ // primary consumer. Two shapes: server-held data answered directly (clients,
7
+ // logs) and queries forwarded to a client over its websocket and correlated
8
+ // back by id (tree, stats).
9
+
10
+ export type LogEntry = { seq: number; at: number; client: number; level: string; text: string }
11
+
12
+ // Ring buffer of forwarded client logs. Capped so a chatty app cannot grow the
13
+ // server without bound; readers page through it with the `since` cursor.
14
+ const LOG_CAP = 2000
15
+ const QUERY_TIMEOUT_MS = 5000
16
+ const MAX_WAIT_MS = 30000
17
+
18
+ let logs: LogEntry[] = []
19
+ let logSeq = 0
20
+ // Pending long-poll wakeups (see handleLogs). Flushed on every append; a
21
+ // waiter that already timed out resolves again harmlessly.
22
+ let logWaiters: Array<() => void> = []
23
+
24
+ let nextQueryId = 1
25
+ let pendingQueries = new Map<number, (msg: any) => void>()
26
+
27
+ function sleep(ms: number): Promise<void> {
28
+ return new Promise((resolve) => setTimeout(resolve, ms))
29
+ }
30
+
31
+ /// A `log` message arrived from a client: buffer it and wake long-polls.
32
+ export function appendLog(client: number, level: string, text: string) {
33
+ logs.push({ seq: ++logSeq, at: Date.now(), client, level, text })
34
+ if (logs.length > LOG_CAP) logs.splice(0, logs.length - LOG_CAP)
35
+ let waiters = logWaiters
36
+ logWaiters = []
37
+ for (let wake of waiters) wake()
38
+ }
39
+
40
+ /// A `result` message arrived from a client: hand it to the awaiting query.
41
+ export function resolveQuery(msg: { id?: number }) {
42
+ if (typeof msg.id !== "number") return
43
+ let resolve = pendingQueries.get(msg.id)
44
+ if (resolve) {
45
+ pendingQueries.delete(msg.id)
46
+ resolve(msg)
47
+ }
48
+ }
49
+
50
+ // The connected-client list. `withAddress` adds each socket's peer address for
51
+ // the internal API (the repl `list` display); the public control shape stays
52
+ // without it.
53
+ export function clientList(withAddress = false) {
54
+ return [...state.clients.entries()].map(([ws, info]) => ({
55
+ id: info.id,
56
+ platform: info.platform,
57
+ version: info.version,
58
+ capabilities: info.capabilities,
59
+ ...(withAddress ? { address: ws.remoteAddress ?? null } : {}),
60
+ }))
61
+ }
62
+
63
+ // Resolve the target client for a query: an explicit ?client=<id>, or the only
64
+ // connected client when the parameter is omitted.
65
+ function findClient(param: string | undefined): { ws: ServerWebSocket } | { error: Response } {
66
+ let entries = [...state.clients.entries()]
67
+ if (param === undefined) {
68
+ if (entries.length === 1) return { ws: entries[0]![0] }
69
+ if (entries.length === 0) return { error: Response.json({ error: "No connected clients" }, { status: 503 }) }
70
+ return { error: Response.json({ error: "Multiple clients connected; pass ?client=<id>" }, { status: 400 }) }
71
+ }
72
+ let id = parseInt(param, 10)
73
+ let entry = entries.find(([, info]) => info.id === id)
74
+ if (!entry) return { error: Response.json({ error: `No client with id ${param}` }, { status: 404 }) }
75
+ return { ws: entry[0] }
76
+ }
77
+
78
+ async function handleQuery(query: Map<string, string>, kind: string, extra?: Record<string, unknown>): Promise<Response> {
79
+ let target = findClient(query.get("client"))
80
+ if ("error" in target) return target.error
81
+ let id = nextQueryId++
82
+ let reply = new Promise<any>((resolve) => {
83
+ pendingQueries.set(id, resolve)
84
+ })
85
+ target.ws.send(JSON.stringify({ type: "query", kind, id, ...extra }))
86
+ let msg = await Promise.race([reply, sleep(QUERY_TIMEOUT_MS)])
87
+ pendingQueries.delete(id)
88
+ if (!msg) return Response.json({ error: "Query timed out" }, { status: 504 })
89
+ if (msg.error) return Response.json({ error: msg.error }, { status: 502 })
90
+ return Response.json(msg.data)
91
+ }
92
+
93
+ // GET /__control__/logs?since=N&wait=MS: entries with seq > since, plus the
94
+ // latest seq as the next cursor. With `wait`, holds the response until a new
95
+ // entry arrives or the timeout passes (long-poll), so a caller can follow the
96
+ // stream without tight polling.
97
+ async function handleLogs(query: Map<string, string>): Promise<Response> {
98
+ let since = parseInt(query.get("since") ?? "0", 10) || 0
99
+ let wait = Math.min(parseInt(query.get("wait") ?? "0", 10) || 0, MAX_WAIT_MS)
100
+ let entries = logs.filter((e) => e.seq > since)
101
+ if (entries.length === 0 && wait > 0) {
102
+ await new Promise<void>((resolve) => {
103
+ let timer = setTimeout(resolve, wait)
104
+ logWaiters.push(() => {
105
+ clearTimeout(timer)
106
+ resolve()
107
+ })
108
+ })
109
+ entries = logs.filter((e) => e.seq > since)
110
+ }
111
+ return Response.json({ entries, latest: logSeq })
112
+ }
113
+
114
+ export async function handleControl(req: Request, path: string, query: Map<string, string>): Promise<Response> {
115
+ switch (path) {
116
+ case "/__control__/clients":
117
+ return Response.json(clientList())
118
+ case "/__control__/logs":
119
+ return handleLogs(query)
120
+ case "/__control__/tree":
121
+ return handleQuery(query, "tree")
122
+ case "/__control__/stats":
123
+ return handleQuery(query, "stats")
124
+ case "/__control__/snapshot": {
125
+ let nodeId = parseInt(query.get("node") ?? "", 10)
126
+ if (!Number.isFinite(nodeId)) return Response.json({ error: "Snapshot requires ?node=<id>" }, { status: 400 })
127
+ return handleQuery(query, "snapshot", { nodeId })
128
+ }
129
+ default:
130
+ return Response.json({ error: "Unknown control endpoint" }, { status: 404 })
131
+ }
132
+ }
package/server/main.ts ADDED
@@ -0,0 +1,292 @@
1
+ // The srt dev server as a flux script. srt (Bun) spawns this with one JSON
2
+ // config argument; bundling, file watching, and the repl stay in srt, which
3
+ // drives this process over the loopback-only /__internal__/ routes. The
4
+ // shutdown-when-empty policy also lives in srt (it polls /__internal__/clients);
5
+ // this process runs until srt kills it. See docs/flux-dev-server-plan.md.
6
+
7
+ import { serve } from "flux:http"
8
+ import type { FluxRequest, Server } from "flux:http"
9
+ import { file, dir } from "flux:fs"
10
+ import { argv } from "flux:process"
11
+ import { resolveWithin, join } from "flux:path"
12
+ import { state, type Config } from "./state"
13
+ import * as cache from "./cache"
14
+ import { handleProxy } from "./proxy"
15
+ import { appendLog, clientList, handleControl, resolveQuery } from "./control"
16
+ import { printQr } from "./qr"
17
+ import { createTunnelEndpoint, TUNNEL_PROTOCOL } from "./tunnel"
18
+
19
+ // argv layout differs between hosts; the config JSON is always the last argument.
20
+ let config: Config = JSON.parse(argv[argv.length - 1]!)
21
+ state.config = config
22
+ state.sourceDir = config.sourceDir
23
+ state.stats = config.stats
24
+ state.serverUrl = `${config.address}:${config.port}`
25
+
26
+ if (config.cache) {
27
+ await cache.initCache({ dir: config.cacheDir })
28
+ console.log("[cli] HTTP cache enabled")
29
+ }
30
+
31
+ if (config.capture) {
32
+ // Start each capture from an empty file: appends would otherwise tack onto
33
+ // whatever a previous run left behind.
34
+ await file(config.capture).write("")
35
+ state.captureStartMs = Date.now()
36
+ }
37
+
38
+ // Split an origin-form request URL ("/path?a=1&b=2") into its decoded path and
39
+ // query parameters. flux has no URL global; this covers what the routes need.
40
+ function splitQuery(url: string): { path: string; query: Map<string, string> } {
41
+ let i = url.indexOf("?")
42
+ let path = i < 0 ? url : url.slice(0, i)
43
+ let query = new Map<string, string>()
44
+ if (i >= 0) {
45
+ for (let pair of url.slice(i + 1).split("&")) {
46
+ if (!pair) continue
47
+ let j = pair.indexOf("=")
48
+ let k = j < 0 ? pair : pair.slice(0, j)
49
+ let v = j < 0 ? "" : pair.slice(j + 1)
50
+ query.set(decodeURIComponent(k), decodeURIComponent(v.replace(/\+/g, " ")))
51
+ }
52
+ }
53
+ return { path: decodeURIComponent(path), query }
54
+ }
55
+
56
+ // Send `text` to the clients with the given ids, or to every client when
57
+ // `ids` is omitted.
58
+ function sendTo(ids: number[] | undefined, text: string) {
59
+ for (let [ws, info] of state.clients) {
60
+ if (!ids || ids.includes(info.id)) ws.send(text)
61
+ }
62
+ }
63
+
64
+ // The srt -> server IPC under /__internal__/: only the srt process on this
65
+ // machine may drive it, so reject any peer that is not loopback.
66
+ async function handleInternal(req: FluxRequest, server: Server, path: string): Promise<Response> {
67
+ let ip = server.requestIP(req)
68
+ let loopback = ip && (ip.address === "127.0.0.1" || ip.address === "::1" || ip.address === "::ffff:127.0.0.1")
69
+ if (!loopback) return new Response("Forbidden", { status: 403 })
70
+
71
+ if (path === "/__internal__/clients") return Response.json(clientList(true))
72
+ if (req.method !== "POST") return new Response("Method not allowed", { status: 405 })
73
+
74
+ switch (path) {
75
+ case "/__internal__/reload": {
76
+ // { message, clients?, latch?, sourceDir? }: send `message` (a full
77
+ // client-protocol message, built by srt) to the listed client ids, or to
78
+ // all when omitted. `latch` keeps it for late-joining clients (code
79
+ // reloads latch, one-shot bytecode loads do not); `sourceDir` moves the
80
+ // file-serving root (repl `load`).
81
+ let body = await req.json()
82
+ if (typeof body.sourceDir === "string") state.sourceDir = body.sourceDir
83
+ let text = JSON.stringify(body.message)
84
+ if (body.latch) state.currentReload = text
85
+ sendTo(body.clients, text)
86
+ return new Response("", { status: 204 })
87
+ }
88
+ case "/__internal__/stop": {
89
+ let body = await req.json()
90
+ // A broadcast stop also forgets the latched reload, so a client that
91
+ // connects afterwards starts clean.
92
+ if (!body.clients) state.currentReload = null
93
+ sendTo(body.clients, JSON.stringify({ type: "stop" }))
94
+ return new Response("", { status: 204 })
95
+ }
96
+ case "/__internal__/stats": {
97
+ let body = await req.json()
98
+ state.stats = !!body.stats
99
+ sendTo(undefined, JSON.stringify({ type: "stats", stats: state.stats }))
100
+ return new Response("", { status: 204 })
101
+ }
102
+ default:
103
+ return Response.json({ error: "Unknown internal endpoint" }, { status: 404 })
104
+ }
105
+ }
106
+
107
+ // The file routes: GET file (with single-range 206 support) or directory
108
+ // listing, PUT file write. All paths are contained in the source directory.
109
+ async function handleFiles(req: FluxRequest, path: string): Promise<Response> {
110
+ let filePath = resolveWithin(state.sourceDir, "." + path)
111
+ if (!filePath) {
112
+ return new Response("Forbidden", { status: 403 })
113
+ }
114
+
115
+ if (req.method === "PUT") {
116
+ console.log("[cli] put " + path)
117
+ let bytes = await req.bytes()
118
+ await file(filePath).write(bytes)
119
+ return new Response("", { status: 204 })
120
+ }
121
+
122
+ console.log("[cli] get " + path)
123
+
124
+ let stat
125
+ try {
126
+ stat = await file(filePath).stat()
127
+ } catch {
128
+ console.log(`[cli] file not found ${path}`)
129
+ return new Response("Not found", { status: 404 })
130
+ }
131
+
132
+ if (stat.type === "directory") {
133
+ let dirents = await dir(filePath).entries()
134
+ let entries = await Promise.all(
135
+ dirents.map(async (d) => {
136
+ let entry = { name: d.name, type: d.type === "directory" ? 2 : 1, size: 0, modified: 0 }
137
+ if (d.type !== "directory") {
138
+ try {
139
+ let s = await file(join(filePath, d.name)).stat()
140
+ entry.size = s.size
141
+ entry.modified = Math.floor(s.mtime ?? 0)
142
+ } catch {}
143
+ }
144
+ return entry
145
+ }),
146
+ )
147
+ entries.sort((a, b) => a.name.localeCompare(b.name))
148
+ return Response.json(entries, { headers: { "X-SRT-Type": "directory" } })
149
+ }
150
+
151
+ let baseHeaders: Record<string, string> = { "X-SRT-Type": "file", "Accept-Ranges": "bytes" }
152
+
153
+ // Honor a single byte-range request (e.g. streaming audio decoding on the
154
+ // client, which seeks and reads on demand). Only the common "bytes=a-b" /
155
+ // "bytes=a-" / "bytes=-n" forms; anything else falls through to the whole
156
+ // file. Range makes proxied streaming viable without pulling the whole
157
+ // track over the wire.
158
+ let range = req.headers.get("range")
159
+ let match = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null
160
+ if (match) {
161
+ let size = stat.size
162
+ let start: number
163
+ let end: number
164
+ if (match[1] === "") {
165
+ // Suffix range: the last N bytes.
166
+ let n = parseInt(match[2]!, 10)
167
+ start = isNaN(n) ? 0 : Math.max(0, size - n)
168
+ end = size - 1
169
+ } else {
170
+ start = parseInt(match[1]!, 10)
171
+ end = match[2] === "" ? size - 1 : Math.min(parseInt(match[2]!, 10), size - 1)
172
+ }
173
+ if (start > end || start >= size) {
174
+ return new Response("Range not satisfiable", {
175
+ status: 416,
176
+ headers: { ...baseHeaders, "Content-Range": `bytes */${size}` },
177
+ })
178
+ }
179
+ return new Response(await file(filePath).read(start, end - start + 1), {
180
+ status: 206,
181
+ headers: {
182
+ ...baseHeaders,
183
+ "Content-Range": `bytes ${start}-${end}/${size}`,
184
+ "Content-Length": String(end - start + 1),
185
+ },
186
+ })
187
+ }
188
+
189
+ return new Response(await file(filePath).bytes(), { headers: baseHeaders })
190
+ }
191
+
192
+ // Ticket-paired clients connect through this endpoint; serve() accepts its
193
+ // connections directly alongside the TCP listener.
194
+ let tunnel = config.tunnel ? await createTunnelEndpoint(config.port, config.cacheDir) : null
195
+
196
+ serve({
197
+ port: config.port,
198
+ p2p: tunnel ? { endpoint: tunnel, protocol: TUNNEL_PROTOCOL } : undefined,
199
+ async fetch(req, server) {
200
+ if (server.upgrade(req)) return
201
+
202
+ let { path, query } = splitQuery(req.url)
203
+
204
+ if (path === "/__proxy__") {
205
+ return handleProxy(req)
206
+ }
207
+ if (path.startsWith("/__control__/")) {
208
+ return handleControl(req, path, query)
209
+ }
210
+ if (path.startsWith("/__internal__/")) {
211
+ return handleInternal(req, server, path)
212
+ }
213
+ return handleFiles(req, path)
214
+ },
215
+ websocket: {
216
+ open(ws) {
217
+ let id = state.nextClientId++
218
+ state.clients.set(ws, { platform: "unknown", version: "unknown", id, capabilities: [] })
219
+ console.log(`[cli] Client connected ${ws.remoteAddress ?? "unknown"}`)
220
+ // Advertise our real LAN address so clients dialed over a loopback hop
221
+ // can show/remember the directly reachable address (see connection.rs).
222
+ ws.send(
223
+ JSON.stringify({ type: "welcome", address: state.serverUrl, stats: state.stats, capture: !!config.capture }),
224
+ )
225
+ if (state.currentReload) {
226
+ ws.send(state.currentReload)
227
+ }
228
+ },
229
+ close(ws) {
230
+ let info = state.clients.get(ws)
231
+ state.clients.delete(ws)
232
+ console.log(`[cli] Client disconnected: ${info?.platform ?? "unknown"}`)
233
+ },
234
+ message(ws, msg) {
235
+ try {
236
+ let data = JSON.parse(typeof msg === "string" ? msg : new TextDecoder().decode(msg))
237
+ if (data.type === "info") {
238
+ let existing = state.clients.get(ws)
239
+ state.clients.set(ws, {
240
+ platform: data.platform ?? "unknown",
241
+ version: data.version ?? "unknown",
242
+ id: existing?.id ?? state.nextClientId++,
243
+ capabilities: Array.isArray(data.capabilities) ? data.capabilities.map(String) : [],
244
+ })
245
+ console.log(`[cli] Client info ${ws.remoteAddress ?? "unknown"} ${data.platform} (${data.version})`)
246
+ } else if (data.type === "log") {
247
+ // Forwarded console output / runtime errors from the client's
248
+ // engine logger, buffered for the control API (see control.ts).
249
+ // Not printed here: the local client already writes to this
250
+ // terminal, so echoing would duplicate every line.
251
+ let device = state.clients.get(ws)?.id ?? -1
252
+ appendLog(device, String(data.level ?? "log"), String(data.text ?? ""))
253
+ } else if (data.type === "result") {
254
+ // Reply to a query the control API forwarded to this client.
255
+ resolveQuery(data)
256
+ } else if (data.type === "capture" && config.capture) {
257
+ let device = state.clients.get(ws)?.id ?? -1
258
+ // Milliseconds, integer: Date.now() is already integer ms, so the
259
+ // delta needs no rounding.
260
+ let at = Date.now() - state.captureStartMs
261
+ let after = at - state.captureLastAt
262
+ state.captureLastAt = at
263
+ // JSON Lines: one event object per line, streamed to disk as it
264
+ // arrives rather than buffered - no in-memory growth for a long
265
+ // capture, and the file is always complete on disk mid-session.
266
+ // Appends are chained so events land in arrival order.
267
+ let line = JSON.stringify({ after, type: data.kind, key: data.key, device }) + "\n"
268
+ state.captureChain = state.captureChain.then(() => file(config.capture!).append(line))
269
+ }
270
+ } catch {}
271
+ },
272
+ },
273
+ })
274
+
275
+ // One QR on screen: with the tunnel on, the ticket QR (printed by
276
+ // createTunnelEndpoint) is the pairing story and the address stays text-only;
277
+ // without it, the address QR is the scan target as before.
278
+ if (!config.tunnel) {
279
+ console.log("")
280
+ printQr(state.serverUrl)
281
+ console.log("")
282
+ }
283
+ console.log(`[cli] WebSocket server on ws://${state.serverUrl}`)
284
+ // mDNS advertise is intentionally not implemented here: the p2p ticket is the
285
+ // cross-device connect story (see docs/flux-dev-server-plan.md).
286
+
287
+ // Keepalive
288
+ setInterval(() => {
289
+ for (let ws of state.clients.keys()) {
290
+ ws.ping()
291
+ }
292
+ }, 5000)