@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 +7 -3
- package/package.json +6 -5
- package/scaffold/AGENTS.md +17 -1
- package/scaffold/mcp.json +8 -0
- package/scaffold/package.json +4 -4
- package/scaffold/templates/minimal/index.tsx +11 -0
- package/src/args.ts +9 -4
- package/src/commands/init.ts +1 -0
- package/src/commands/mcp.ts +128 -0
- package/src/commands/render.ts +18 -0
- package/src/commands/server.ts +6 -0
- package/src/control.ts +119 -0
- package/src/dev-server.ts +76 -3
- package/src/main.ts +7 -4
- package/src/util.ts +12 -1
- package/src/commands/record.ts +0 -16
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
|
|
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
|
|
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
|
-
`
|
|
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.
|
|
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.
|
|
26
|
-
"@solidrt/linux-x64-gnu": "0.0.
|
|
27
|
-
"@solidrt/win32-x64-msvc": "0.0.
|
|
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.
|
|
31
|
+
"@solidrt/core": "0.0.25",
|
|
31
32
|
"typescript": "^6"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
package/scaffold/AGENTS.md
CHANGED
|
@@ -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
|
|
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`.
|
package/scaffold/package.json
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
"android": "srt client --android"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@solidrt/core": "0.0.
|
|
13
|
-
"@solidrt/components": "0.0.
|
|
12
|
+
"@solidrt/core": "0.0.25",
|
|
13
|
+
"@solidrt/components": "0.0.25"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@solidrt/cli": "0.0.
|
|
17
|
-
"@solidrt/flux-types": "0.0.
|
|
16
|
+
"@solidrt/cli": "0.0.25",
|
|
17
|
+
"@solidrt/flux-types": "0.0.25",
|
|
18
18
|
"typescript": "^6"
|
|
19
19
|
}
|
|
20
20
|
}
|
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 "
|
|
47
|
-
if (!source || !isTsx) usage("srt
|
|
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
|
-
|
|
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
|
-
|
|
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)`)
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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 === "
|
|
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 === "
|
|
46
|
-
await
|
|
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.
|
package/src/commands/record.ts
DELETED
|
@@ -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
|
-
}
|