@solidrt/cli 0.0.23 → 0.0.25

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/AGENTS.md CHANGED
@@ -18,21 +18,25 @@ bundled `flux` runtime, not on Bun. Invoke via `bunx srt <command>`.
18
18
  - `bunx srt bundle src/index.tsx` - transpile to `<file>.srt.js`. With
19
19
  `--compile`, emits `.srt.bin` bytecode. `--minify`, `--dev`, `--stdout`,
20
20
  `--output` also available.
21
- - `bunx srt record src/index.tsx [flags]` - render OFFSCREEN to PNG frames.
21
+ - `bunx srt render src/index.tsx [flags]` - render OFFSCREEN to PNG frames,
22
+ optionally replaying a `--script` file recorded via `--capture`.
22
23
  - `bunx srt server [file]` / `bunx srt client` - the two halves of `run`
23
24
  separately (server distributes code; clients on other devices connect to it).
25
+ - `bunx srt run src/index.tsx --capture out.script.json` - records keydown/keyup
26
+ from every connected client into one script file (written on client
27
+ disconnect), for replaying later with `render --script`.
24
28
 
25
29
  ## Verifying without a display (headless / CI / agent box)
26
30
 
27
31
  Two reliable checks that need no GUI:
28
32
 
29
33
  1. `bunx srt bundle src/index.tsx` - exit 0 means the app compiles. Fast.
30
- 2. `bunx srt record src/index.tsx --size 480x640 --duration 1 --fps 2` -
34
+ 2. `bunx srt render src/index.tsx --size 480x640 --duration 1 --fps 2` -
31
35
  renders offscreen via EGL/wgpu and writes `frame-NNNNNN.png`. This actually
32
36
  proves the app renders. Combine with `--fps`/`--duration` (defaults
33
37
  1280x720, 60fps, 1s).
34
38
 
35
- `record` gotchas:
39
+ `render` gotchas:
36
40
  - Frames are written to the RUNTIME's working dir (`~/.local/share/SolidRT/go/`),
37
41
  NOT the directory you ran the command from. Look there for the PNGs.
38
42
  - The recording includes a debug overlay (FPS/REQ/MiB/CPU) in a corner.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/cli",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -17,17 +17,18 @@
17
17
  "@babel/core": "^7.0.0",
18
18
  "@babel/plugin-syntax-jsx": "^7.0.0",
19
19
  "@babel/preset-typescript": "^7.0.0",
20
+ "@modelcontextprotocol/sdk": "^1.29.0",
20
21
  "babel-preset-solid": "2.0.0-beta.15",
21
22
  "bonjour-service": "^1.4.0",
22
23
  "qrcode-generator": "^2.0.4"
23
24
  },
24
25
  "optionalDependencies": {
25
- "@solidrt/darwin-arm64": "0.0.23",
26
- "@solidrt/linux-x64-gnu": "0.0.23",
27
- "@solidrt/win32-x64-msvc": "0.0.23"
26
+ "@solidrt/darwin-arm64": "0.0.25",
27
+ "@solidrt/linux-x64-gnu": "0.0.25",
28
+ "@solidrt/win32-x64-msvc": "0.0.25"
28
29
  },
29
30
  "peerDependencies": {
30
- "@solidrt/core": "0.0.23",
31
+ "@solidrt/core": "0.0.25",
31
32
  "typescript": "^6"
32
33
  },
33
34
  "devDependencies": {
@@ -81,6 +81,22 @@ Authoritative references ship inside the installed packages - read them:
81
81
 
82
82
  - bunx srt run src/index.tsx - dev server + window (needs a display)
83
83
  - bunx srt bundle src/index.tsx - exit 0 means it compiles
84
- - bunx srt record src/index.tsx --size 480x640 --duration 1 --fps 2 - headless
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.23",
13
- "@solidrt/components": "0.0.23"
12
+ "@solidrt/core": "0.0.25",
13
+ "@solidrt/components": "0.0.25"
14
14
  },
15
15
  "devDependencies": {
16
- "@solidrt/cli": "0.0.23",
17
- "@solidrt/flux-types": "0.0.23",
16
+ "@solidrt/cli": "0.0.25",
17
+ "@solidrt/flux-types": "0.0.25",
18
18
  "typescript": "^6"
19
19
  }
20
20
  }
@@ -0,0 +1,11 @@
1
+ import { render } from "@solidrt/core"
2
+
3
+ function App() {
4
+ return (
5
+ <window>
6
+ <text>Hello, World!</text>
7
+ </window>
8
+ )
9
+ }
10
+
11
+ render(() => <App />)
package/src/args.ts CHANGED
@@ -13,6 +13,8 @@ export let { values, positionals } = parseArgs({
13
13
  fps: { type: "string" },
14
14
  duration: { type: "string" },
15
15
  size: { type: "string" },
16
+ script: { type: "string" },
17
+ capture: { type: "string" },
16
18
  stats: { type: "boolean", default: false },
17
19
  android: { type: "boolean", default: false },
18
20
  device: { type: "string" },
@@ -43,8 +45,8 @@ export function validateArgs() {
43
45
  usage("srt bundle [options] <entry.[tsx|jsx|ts|js|srt.js|srt.bin]>")
44
46
  }
45
47
  break
46
- case "record":
47
- if (!source || !isTsx) usage("srt record <entry.[tsx|jsx]>")
48
+ case "render":
49
+ if (!source || !isTsx) usage("srt render <entry.[tsx|jsx]>")
48
50
  break
49
51
  case "pack":
50
52
  if (values.flux) {
@@ -71,8 +73,9 @@ Commands:
71
73
  server [file] Start dev server only
72
74
  client Start solidrt-go client only
73
75
  bundle <file> Transpile TS/JS/TSX/JSX to JS or bytecode
74
- record <file.tsx|jsx> Capture frames for video generation
76
+ render <file.tsx|jsx> Replay a script (optional) and render frames for video generation
75
77
  pack <file> Bundle + compile to a standalone executable (experimental)
78
+ mcp MCP server (stdio) exposing the running dev server to coding agents
76
79
 
77
80
  init options:
78
81
  -t, --template <name> Start from a named template (skips the interactive picker)
@@ -80,6 +83,7 @@ init options:
80
83
  run/server options:
81
84
  --proxy-files Route file/dir access through the dev server
82
85
  --proxy-http Route fetch calls through the dev server (HTTP cache enabled)
86
+ --capture <file> Record connected clients' key events to a script file
83
87
 
84
88
  run/client options:
85
89
  --size <WxH> Window size (default: 1280x720)
@@ -102,7 +106,8 @@ pack options:
102
106
  -m, --minify Minify the output
103
107
  -o, --output <name> Output filename
104
108
 
105
- record options:
109
+ render options:
110
+ --script <file> Script file to replay (default: no scripted input)
106
111
  --fps <N> Frames per second (default: 60)
107
112
  --duration <N> Duration in seconds (default: 1)
108
113
  --size <WxH> Frame size (default: 1280x720)`)
@@ -16,6 +16,7 @@ const TEMPLATE_FILES: Array<{ from: string; to: string }> = [
16
16
  { from: "package.json", to: "package.json" },
17
17
  { from: "tsconfig.json", to: "tsconfig.json" },
18
18
  { from: "gitignore", to: ".gitignore" },
19
+ { from: "mcp.json", to: ".mcp.json" },
19
20
  { from: "AGENTS.md", to: "AGENTS.md" },
20
21
  ]
21
22
 
@@ -0,0 +1,128 @@
1
+ // The MCP bridge: a stdio Model Context Protocol server exposing the dev
2
+ // server's control API (/__control__/) as tools for coding agents. Stateless
3
+ // glue: every tool call is one HTTP request to the running dev server, so the
4
+ // bridge works no matter which process (or how many agents) spawned it.
5
+ //
6
+ // stdout is the JSON-RPC channel; nothing here may print to it.
7
+
8
+ import { DEV_PORT } from "../dev-server"
9
+
10
+ const CONTROL_BASE = `http://127.0.0.1:${DEV_PORT}/__control__`
11
+
12
+ type ControlResult = { ok: true; body: any } | { ok: false; message: string }
13
+
14
+ async function control(path: string): Promise<ControlResult> {
15
+ let resp
16
+ try {
17
+ resp = await fetch(CONTROL_BASE + path)
18
+ } catch {
19
+ return {
20
+ ok: false,
21
+ message: "Dev server not running. Start it in the project first: srt run src/index.tsx (or srt server)",
22
+ }
23
+ }
24
+ let body: any = null
25
+ try {
26
+ body = await resp.json()
27
+ } catch {}
28
+ if (!resp.ok) return { ok: false, message: String(body?.error ?? `Dev server responded with HTTP ${resp.status}`) }
29
+ return { ok: true, body }
30
+ }
31
+
32
+ let TOOLS = [
33
+ {
34
+ name: "list_clients",
35
+ description:
36
+ "List the app clients connected to the SolidRT dev server. Each entry has id (pass it as `client` to the other tools), platform, runtime version, and the capability names compiled into that client's runtime.",
37
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
38
+ },
39
+ {
40
+ name: "get_logs",
41
+ description:
42
+ "Read console output and runtime errors from connected app clients. Returns entries (seq, at, client, level, text) plus `latest`, the newest seq. Pass `since` (a seq or `latest` from a previous call) to only get newer entries; pass `wait_ms` to hold the call until new output arrives, e.g. right after triggering a reload.",
43
+ inputSchema: {
44
+ type: "object",
45
+ properties: {
46
+ since: {
47
+ type: "integer",
48
+ description: "Only return entries with seq greater than this (default 0: the whole buffer)",
49
+ },
50
+ wait_ms: {
51
+ type: "integer",
52
+ description: "If nothing is newer than `since`, wait up to this many milliseconds for new output (max 30000)",
53
+ },
54
+ },
55
+ additionalProperties: false,
56
+ },
57
+ },
58
+ {
59
+ name: "get_stats",
60
+ description:
61
+ "Performance statistics from a running app client: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count.",
62
+ inputSchema: {
63
+ type: "object",
64
+ properties: {
65
+ client: { type: "integer", description: "Client id from list_clients (default: the only connected client)" },
66
+ },
67
+ additionalProperties: false,
68
+ },
69
+ },
70
+ {
71
+ name: "get_render_tree",
72
+ description:
73
+ "Snapshot of a running app client's render tree: node id, kind, window-relative box (x, y, width, height), text content, and children. Use it to verify what the app actually rendered and where.",
74
+ inputSchema: {
75
+ type: "object",
76
+ properties: {
77
+ client: { type: "integer", description: "Client id from list_clients (default: the only connected client)" },
78
+ },
79
+ additionalProperties: false,
80
+ },
81
+ },
82
+ ]
83
+
84
+ function clientParam(args: any): string {
85
+ return typeof args?.client === "number" ? `?client=${args.client}` : ""
86
+ }
87
+
88
+ async function callTool(name: string, args: any): Promise<ControlResult> {
89
+ switch (name) {
90
+ case "list_clients":
91
+ return control("/clients")
92
+ case "get_logs": {
93
+ let params = new URLSearchParams()
94
+ if (typeof args?.since === "number") params.set("since", String(args.since))
95
+ if (typeof args?.wait_ms === "number") params.set("wait", String(args.wait_ms))
96
+ let qs = params.toString()
97
+ return control(qs ? `/logs?${qs}` : "/logs")
98
+ }
99
+ case "get_stats":
100
+ return control(`/stats${clientParam(args)}`)
101
+ case "get_render_tree":
102
+ return control(`/tree${clientParam(args)}`)
103
+ default:
104
+ return { ok: false, message: `Unknown tool: ${name}` }
105
+ }
106
+ }
107
+
108
+ export async function runMcpCommand() {
109
+ let { Server } = await import("@modelcontextprotocol/sdk/server/index.js")
110
+ let { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js")
111
+ let { ListToolsRequestSchema, CallToolRequestSchema } = await import("@modelcontextprotocol/sdk/types.js")
112
+
113
+ let server = new Server({ name: "solidrt", version: "0.0.0" }, { capabilities: { tools: {} } })
114
+
115
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
116
+
117
+ server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
118
+ let result = await callTool(request.params.name, request.params.arguments ?? {})
119
+ if (!result.ok) {
120
+ return { content: [{ type: "text", text: result.message }], isError: true }
121
+ }
122
+ return { content: [{ type: "text", text: JSON.stringify(result.body, null, 2) }] }
123
+ })
124
+
125
+ // The stdin read keeps the process alive; it exits when the agent host
126
+ // closes the pipe.
127
+ await server.connect(new StdioServerTransport())
128
+ }
@@ -0,0 +1,18 @@
1
+ import { source, values } from "../args"
2
+ import { requireBinary, run } from "../util"
3
+ import { bundleTo } from "../bundler"
4
+ import { resolve } from "path"
5
+
6
+ export async function runRenderCommand() {
7
+ let jsOutfile = source!.replace(/\.[jt]sx$/, "") + ".srt.js"
8
+ await bundleTo(jsOutfile)
9
+ let runner = requireBinary("solidrt-go")
10
+ let playbackArgs = ["--playback"]
11
+ if (values.fps) playbackArgs.push("--fps", values.fps)
12
+ if (values.duration) playbackArgs.push("--duration", values.duration)
13
+ if (values.size) playbackArgs.push("--size", values.size)
14
+ if (values.script) playbackArgs.push("--script", resolve(values.script))
15
+ playbackArgs.push(resolve(jsOutfile))
16
+ let exit = await run(runner, playbackArgs)
17
+ process.exit(exit)
18
+ }
@@ -7,6 +7,7 @@ import { startRepl } from "../repl"
7
7
  import { startWatcher } from "../watcher"
8
8
  import * as cache from "../cache"
9
9
  import { resolve, dirname } from "path"
10
+ import { writeFileSync } from "node:fs"
10
11
 
11
12
  // Brings up the dev server (HTTP/WS + initial bundle + repl + watcher). The
12
13
  // `run` command spawns a local client on top of this from main.ts.
@@ -15,6 +16,11 @@ export async function runServerCommand() {
15
16
  state.source = source
16
17
  state.sourceDir = source ? dirname(resolve(source)) : process.cwd()
17
18
  state.stats = values.stats
19
+ state.capture = values.capture ? resolve(values.capture) : undefined
20
+ state.captureStartMs = Date.now()
21
+ // Start each capture from an empty file: appendFileSync (dev-server.ts)
22
+ // would otherwise tack onto whatever a previous run left behind.
23
+ if (state.capture) writeFileSync(state.capture, "")
18
24
 
19
25
  if (values["proxy-http"]) {
20
26
  cache.initCache({ dir: process.cwd() })
package/src/control.ts ADDED
@@ -0,0 +1,119 @@
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
+ }
package/src/dev-server.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { resolve } from "path"
2
2
  import { stat as fsStat, readdir } from "node:fs/promises"
3
+ import { appendFileSync } from "node:fs"
3
4
  import { networkInterfaces } from "node:os"
4
5
  import { Bonjour } from "bonjour-service"
5
6
  import qrcode from "qrcode-generator"
6
7
  import { state, print } from "./util"
7
8
  import { values } from "./args"
8
9
  import * as cache from "./cache"
10
+ import { appendLog, handleControl, resolveQuery } from "./control"
9
11
 
10
12
  export const DEV_HOST = "127.0.0.1"
11
13
  export const DEV_PORT = 0x8844
@@ -127,6 +129,10 @@ export function startServer() {
127
129
  return handleProxy(req)
128
130
  }
129
131
 
132
+ if (path.startsWith("/__control__/")) {
133
+ return handleControl(req, path)
134
+ }
135
+
130
136
  let filePath = resolve(state.sourceDir, "." + path)
131
137
  if (!filePath.startsWith(state.sourceDir)) {
132
138
  return new Response("Forbidden", { status: 403 })
@@ -168,15 +174,57 @@ export function startServer() {
168
174
  return Response.json(entries, { headers: { "X-SRT-Type": "directory" } })
169
175
  }
170
176
 
171
- return new Response(Bun.file(filePath), { headers: { "X-SRT-Type": "file" } })
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 })
172
217
  },
173
218
  websocket: {
174
219
  open(ws) {
175
- state.clients.set(ws, { platform: "unknown", version: "unknown" })
220
+ let id = state.nextClientId++
221
+ state.clients.set(ws, { platform: "unknown", version: "unknown", id, capabilities: [] })
176
222
  print(`[cli] Client connected ${ws.remoteAddress}`)
177
223
  // Advertise our real LAN address so clients dialed over the adb loopback
178
224
  // can show/remember the directly reachable address (see connection.rs).
179
- ws.send(JSON.stringify({ type: "welcome", address: state.serverUrl, stats: state.stats }))
225
+ ws.send(
226
+ JSON.stringify({ type: "welcome", address: state.serverUrl, stats: state.stats, capture: !!state.capture }),
227
+ )
180
228
  if (state.currentCode) {
181
229
  ws.send(buildReload({ code: state.currentCode }))
182
230
  }
@@ -195,11 +243,36 @@ export function startServer() {
195
243
  try {
196
244
  let data = JSON.parse(typeof msg === "string" ? msg : Buffer.from(msg).toString())
197
245
  if (data.type === "info") {
246
+ let existing = state.clients.get(ws)
198
247
  state.clients.set(ws, {
199
248
  platform: data.platform ?? "unknown",
200
249
  version: data.version ?? "unknown",
250
+ id: existing?.id ?? state.nextClientId++,
251
+ capabilities: Array.isArray(data.capabilities) ? data.capabilities.map(String) : [],
201
252
  })
202
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)
203
276
  }
204
277
  } catch {}
205
278
  },
package/src/main.ts CHANGED
@@ -4,9 +4,10 @@ import { values, command, validateArgs, printUsage } from "./args"
4
4
  import { runInitCommand } from "./commands/init"
5
5
  import { runBundleCommand } from "./commands/bundle"
6
6
  import { runPackCommand } from "./commands/pack"
7
- import { runRecordCommand } from "./commands/record"
7
+ import { runRenderCommand } from "./commands/render"
8
8
  import { runServerCommand } from "./commands/server"
9
9
  import { runClientCommand } from "./commands/client"
10
+ import { runMcpCommand } from "./commands/mcp"
10
11
  import { spawnClient } from "./dev-client"
11
12
 
12
13
  // -- Validate args --
@@ -22,7 +23,7 @@ validateArgs()
22
23
  // the auto-activation by setting NODE_ENV=production. Since that is read at startup,
23
24
  // we re-exec rather than mutate process.env. Assumes srt runs via bun (argv is
24
25
  // [bun, script, ...]); would need rework if ever shipped as a compiled binary.
25
- let isProdBuild = (command === "bundle" || command === "record" || command === "pack") && !values.dev
26
+ let isProdBuild = (command === "bundle" || command === "render" || command === "pack") && !values.dev
26
27
  if (isProdBuild && process.env.NODE_ENV !== "production") {
27
28
  let proc = Bun.spawnSync({
28
29
  cmd: [process.execPath, ...process.argv.slice(1)],
@@ -42,8 +43,8 @@ if (command === "init") {
42
43
  await runBundleCommand()
43
44
  } else if (command === "pack") {
44
45
  await runPackCommand()
45
- } else if (command === "record") {
46
- await runRecordCommand()
46
+ } else if (command === "render") {
47
+ await runRenderCommand()
47
48
  } else if (command === "client") {
48
49
  await runClientCommand()
49
50
  } else if (command === "server") {
@@ -51,6 +52,8 @@ if (command === "init") {
51
52
  } else if (command === "run") {
52
53
  await runServerCommand()
53
54
  spawnClient()
55
+ } else if (command === "mcp") {
56
+ await runMcpCommand()
54
57
  } else {
55
58
  printUsage()
56
59
  process.exit(1)
package/src/util.ts CHANGED
@@ -6,7 +6,8 @@ import type { Server as BunServer } from "bun"
6
6
  import type { Bonjour } from "bonjour-service"
7
7
 
8
8
  export let state = {
9
- clients: new Map<any, { platform: string; version: string }>(),
9
+ clients: new Map<any, { platform: string; version: string; id: number; capabilities: string[] }>(),
10
+ nextClientId: 0,
10
11
  currentCode: null as string | null,
11
12
  source: undefined as string | undefined,
12
13
  sourceDir: process.cwd(),
@@ -16,6 +17,16 @@ export let state = {
16
17
  rl: null as ReadlineInterface | null,
17
18
  bonjour: null as Bonjour | null,
18
19
  stats: false,
20
+ // --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.
27
+ capture: undefined as string | undefined,
28
+ captureStartMs: 0,
29
+ captureLastAt: 0, // ms, same clock as captureStartMs
19
30
  }
20
31
 
21
32
  // Build target per binary, for the "not found" hint. Run from the repo root.
@@ -1,16 +0,0 @@
1
- import { source, values } from "../args"
2
- import { requireBinary, run } from "../util"
3
- import { bundleTo } from "../bundler"
4
- import { resolve } from "path"
5
-
6
- export async function runRecordCommand() {
7
- let jsOutfile = source!.replace(/\.[jt]sx$/, "") + ".srt.js"
8
- await bundleTo(jsOutfile)
9
- let runner = requireBinary("solidrt-go")
10
- let recordArgs = ["--record", resolve(jsOutfile)]
11
- if (values.fps) recordArgs.push("--fps", values.fps)
12
- if (values.duration) recordArgs.push("--duration", values.duration)
13
- if (values.size) recordArgs.push("--size", values.size)
14
- let exit = await run(runner, recordArgs)
15
- process.exit(exit)
16
- }