@solidrt/cli 0.0.26 → 0.0.27
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 +10 -7
- package/scaffold/AGENTS.md +60 -0
- package/scaffold/package.json +4 -4
- package/server/control.ts +57 -2
- package/server/main.ts +13 -4
- package/server/rebuild.ts +54 -0
- package/server/remap.ts +47 -0
- package/server/state.ts +17 -1
- package/src/bundle-cli.ts +12 -0
- package/src/bundler.ts +69 -23
- package/src/commands/bundle.ts +4 -7
- package/src/commands/mcp.ts +140 -62
- package/src/commands/server.ts +4 -3
- package/src/dev-server.ts +15 -2
- package/src/repl.ts +17 -7
- package/src/util.ts +3 -0
- package/src/watcher.ts +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.27",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Antoine van Wel",
|
|
6
6
|
"type": "module",
|
|
@@ -18,22 +18,25 @@
|
|
|
18
18
|
"@babel/core": "^7.0.0",
|
|
19
19
|
"@babel/plugin-syntax-jsx": "^7.0.0",
|
|
20
20
|
"@babel/preset-typescript": "^7.0.0",
|
|
21
|
+
"@jridgewell/remapping": "^2.3.0",
|
|
22
|
+
"@jridgewell/trace-mapping": "^0.3.25",
|
|
21
23
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
22
24
|
"babel-preset-solid": "2.0.0-beta.17",
|
|
23
25
|
"bonjour-service": "^1.4.0",
|
|
24
|
-
"qrcode-generator": "^2.0.4"
|
|
26
|
+
"qrcode-generator": "^2.0.4",
|
|
27
|
+
"zod": "^4.4.3"
|
|
25
28
|
},
|
|
26
29
|
"optionalDependencies": {
|
|
27
|
-
"@solidrt/darwin-arm64": "0.0.
|
|
28
|
-
"@solidrt/linux-x64-gnu": "0.0.
|
|
29
|
-
"@solidrt/win32-x64-msvc": "0.0.
|
|
30
|
+
"@solidrt/darwin-arm64": "0.0.27",
|
|
31
|
+
"@solidrt/linux-x64-gnu": "0.0.27",
|
|
32
|
+
"@solidrt/win32-x64-msvc": "0.0.27"
|
|
30
33
|
},
|
|
31
34
|
"peerDependencies": {
|
|
32
|
-
"@solidrt/core": "0.0.
|
|
35
|
+
"@solidrt/core": "0.0.27",
|
|
33
36
|
"typescript": "^7"
|
|
34
37
|
},
|
|
35
38
|
"devDependencies": {
|
|
36
|
-
"@solidrt/flux-types": "0.0.
|
|
39
|
+
"@solidrt/flux-types": "0.0.27",
|
|
37
40
|
"@types/bun": "latest"
|
|
38
41
|
}
|
|
39
42
|
}
|
package/scaffold/AGENTS.md
CHANGED
|
@@ -97,6 +97,66 @@ its tools over guessing at runtime state:
|
|
|
97
97
|
- get_render_tree: what the app actually rendered - node kinds, text, and
|
|
98
98
|
window-relative boxes
|
|
99
99
|
- get_stats: fps, CPU/memory, frame phase timings, setProperty rate
|
|
100
|
+
- get_snapshot: PNG capture of any render-tree node's pixels (get node ids
|
|
101
|
+
from get_render_tree; the window node captures everything)
|
|
102
|
+
- get_gpu_resources: inventory of GPU state - textures (size, render target
|
|
103
|
+
or not), vertex buffers (byteLength), pipelines (draw count, attribute
|
|
104
|
+
layout, bound textures, last-applied uniform values)
|
|
105
|
+
- get_texture: any GPU texture read back as a PNG by id - atlases, data
|
|
106
|
+
textures, and shader/pipeline render targets alike (a render target is
|
|
107
|
+
"what this pipeline last drew", no frame or snapshot needed); crop with
|
|
108
|
+
x/y/width/height
|
|
109
|
+
- get_buffer: a vertex-buffer range decoded to numbers (f32/u16/u8, 64 KiB
|
|
110
|
+
per call) - verify geometry after a writeBuffer instead of inferring it
|
|
111
|
+
from pixels
|
|
112
|
+
- reload: rebuild from source and push to every client - THE dev loop is
|
|
113
|
+
edit -> reload -> get_logs -> get_snapshot. reload surfaces build errors
|
|
114
|
+
but not type errors; run the typecheck separately.
|
|
100
115
|
|
|
101
116
|
The tools need a running app: if list_clients is empty, ask the user to start
|
|
102
117
|
`bunx srt run src/index.tsx`.
|
|
118
|
+
|
|
119
|
+
## Debugging a running app (lessons that cost real time)
|
|
120
|
+
|
|
121
|
+
- console.log + get_logs is your primary probe into runtime state. For state
|
|
122
|
+
you will want repeatedly (a pose, a mode, a counter), bind a debug key that
|
|
123
|
+
logs it and read it back via get_logs.
|
|
124
|
+
- Key events are delivered ONLY to the focused node (no bubbling): call
|
|
125
|
+
setFocus(node.id) from the window's ref or onKeyDown never fires. This
|
|
126
|
+
runtime names arrow keys "Left"/"Right"/"Up"/"Down", not "ArrowLeft".
|
|
127
|
+
- Idle frames skip work: shaders/pipelines only re-render when their params
|
|
128
|
+
change, so measure performance while uniforms are actually changing, and
|
|
129
|
+
a get_snapshot of an idle client can time out - retry, make the app
|
|
130
|
+
produce a frame, or use get_texture on the pipeline's render target, which
|
|
131
|
+
reads the last-drawn frame without needing a new one.
|
|
132
|
+
- When a human reports a visual bug: capture a snapshot and SAY WHAT YOU SEE
|
|
133
|
+
in it before investigating, so you agree on the symptom. If you cannot see
|
|
134
|
+
the problem in the capture, say that instead of guessing.
|
|
135
|
+
- GPU/geometry bugs: inspect the actual GPU data FIRST - get_gpu_resources
|
|
136
|
+
for draw counts/uniforms/sizes, get_texture for atlas or data-texture
|
|
137
|
+
contents ("is this tile blank?" is a ten-second question), get_buffer for
|
|
138
|
+
vertex data. The pixels only tell you THAT something is wrong; the
|
|
139
|
+
resources tell you WHERE the data stops being right. In a one-big-pipeline
|
|
140
|
+
app the render tree is a single <texture> leaf and tells you nothing -
|
|
141
|
+
these tools are the visibility layer behind it. Only when the GPU data is
|
|
142
|
+
all correct (so the bug is in producing it, or in the shader), reproduce
|
|
143
|
+
the math CPU-side in a scratch bun script against the app's real data and
|
|
144
|
+
print values.
|
|
145
|
+
- Validate assets at load time and log anomalies (missing lumps/files,
|
|
146
|
+
fully-transparent composites, zero-sized images). Silent fallbacks hide
|
|
147
|
+
bugs for days; a one-line warning surfaces them the first run.
|
|
148
|
+
- After every reload the app restarts from its initial state. If reaching
|
|
149
|
+
the bug site takes navigation, add a dev shortcut (teleport key, noclip,
|
|
150
|
+
initial-state override) before iterating - the round trips add up fast.
|
|
151
|
+
- Clamp onFrame time deltas to [0, cap], not just capped: across a hot
|
|
152
|
+
reload the runtime's tick counter resets AFTER the new instance's first
|
|
153
|
+
frame, so the second frame computes a hugely NEGATIVE delta.
|
|
154
|
+
Math.min(dt, cap) lets it through, and one bad frame can corrupt anything
|
|
155
|
+
integrated from dt (positions fly off, accumulators go so negative they
|
|
156
|
+
never recover). Math.max(0, Math.min(dt, cap)) costs nothing.
|
|
157
|
+
- Frames are demand-gated: JS frame callbacks only run when the previous
|
|
158
|
+
frame changed something (input, signal write, GPU upload). An app whose
|
|
159
|
+
onFrame returns early without side effects on its first frame never gets
|
|
160
|
+
a second one - self-running animation (game clocks, shader-driven
|
|
161
|
+
effects) must make one state change at startup to prime the loop; after
|
|
162
|
+
that its own writes keep it awake.
|
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.27",
|
|
13
|
+
"@solidrt/components": "0.0.27"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@solidrt/cli": "0.0.
|
|
17
|
-
"@solidrt/flux-types": "0.0.
|
|
16
|
+
"@solidrt/cli": "0.0.27",
|
|
17
|
+
"@solidrt/flux-types": "0.0.27",
|
|
18
18
|
"typescript": "^7"
|
|
19
19
|
}
|
|
20
20
|
}
|
package/server/control.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { state } from "./state"
|
|
2
|
+
import { rebuildAndBroadcast } from "./rebuild"
|
|
3
|
+
import { remapPositions } from "./remap"
|
|
2
4
|
import type { ServerWebSocket } from "flux:http"
|
|
3
5
|
|
|
4
6
|
// The control API under /__control__/: read-only introspection of connected
|
|
@@ -29,8 +31,9 @@ function sleep(ms: number): Promise<void> {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/// A `log` message arrived from a client: buffer it and wake long-polls.
|
|
34
|
+
/// Bundle positions in stack traces are remapped to .tsx sources on the way in.
|
|
32
35
|
export function appendLog(client: number, level: string, text: string) {
|
|
33
|
-
logs.push({ seq: ++logSeq, at: Date.now(), client, level, text })
|
|
36
|
+
logs.push({ seq: ++logSeq, at: Date.now(), client, level, text: remapPositions(text, state.currentMap) })
|
|
34
37
|
if (logs.length > LOG_CAP) logs.splice(0, logs.length - LOG_CAP)
|
|
35
38
|
let waiters = logWaiters
|
|
36
39
|
logWaiters = []
|
|
@@ -55,6 +58,7 @@ export function clientList(withAddress = false) {
|
|
|
55
58
|
id: info.id,
|
|
56
59
|
platform: info.platform,
|
|
57
60
|
version: info.version,
|
|
61
|
+
profile: info.profile,
|
|
58
62
|
capabilities: info.capabilities,
|
|
59
63
|
...(withAddress ? { address: ws.remoteAddress ?? null } : {}),
|
|
60
64
|
}))
|
|
@@ -86,7 +90,9 @@ async function handleQuery(query: Map<string, string>, kind: string, extra?: Rec
|
|
|
86
90
|
let msg = await Promise.race([reply, sleep(QUERY_TIMEOUT_MS)])
|
|
87
91
|
pendingQueries.delete(id)
|
|
88
92
|
if (!msg) return Response.json({ error: "Query timed out" }, { status: 504 })
|
|
89
|
-
|
|
93
|
+
// Error strings may carry stack traces (e.g. a debug command threw); remap
|
|
94
|
+
// bundle positions to .tsx sources like appendLog does for forwarded logs.
|
|
95
|
+
if (msg.error) return Response.json({ error: remapPositions(String(msg.error), state.currentMap) }, { status: 502 })
|
|
90
96
|
return Response.json(msg.data)
|
|
91
97
|
}
|
|
92
98
|
|
|
@@ -126,6 +132,55 @@ export async function handleControl(req: Request, path: string, query: Map<strin
|
|
|
126
132
|
if (!Number.isFinite(nodeId)) return Response.json({ error: "Snapshot requires ?node=<id>" }, { status: 400 })
|
|
127
133
|
return handleQuery(query, "snapshot", { nodeId })
|
|
128
134
|
}
|
|
135
|
+
case "/__control__/gpu":
|
|
136
|
+
return handleQuery(query, "gpu")
|
|
137
|
+
case "/__control__/debug": {
|
|
138
|
+
// GET lists the app's registered debug commands; POST calls one, with
|
|
139
|
+
// an optional JSON body as its args.
|
|
140
|
+
if (req.method !== "POST") return handleQuery(query, "debug_list")
|
|
141
|
+
let name = query.get("name")
|
|
142
|
+
if (!name) return Response.json({ error: "Debug call requires ?name=<command>" }, { status: 400 })
|
|
143
|
+
let args: unknown = null
|
|
144
|
+
try {
|
|
145
|
+
args = await req.json()
|
|
146
|
+
} catch {}
|
|
147
|
+
return handleQuery(query, "debug_call", { name, args })
|
|
148
|
+
}
|
|
149
|
+
case "/__control__/texture": {
|
|
150
|
+
let textureId = parseInt(query.get("id") ?? "", 10)
|
|
151
|
+
if (!Number.isFinite(textureId)) return Response.json({ error: "Texture requires ?id=<textureId>" }, { status: 400 })
|
|
152
|
+
// Optional crop: all four of x/y/width/height, in texture pixels.
|
|
153
|
+
let rectParams = ["x", "y", "width", "height"].map((k) => query.get(k))
|
|
154
|
+
let extra: Record<string, unknown> = { textureId }
|
|
155
|
+
if (rectParams.some((v) => v !== undefined)) {
|
|
156
|
+
let [x, y, width, height] = rectParams.map((v) => parseInt(v ?? "", 10))
|
|
157
|
+
if (![x, y, width, height].every(Number.isFinite))
|
|
158
|
+
return Response.json({ error: "Texture rect requires all of x, y, width, height" }, { status: 400 })
|
|
159
|
+
extra.rect = { x, y, width, height }
|
|
160
|
+
}
|
|
161
|
+
return handleQuery(query, "texture", extra)
|
|
162
|
+
}
|
|
163
|
+
case "/__control__/buffer": {
|
|
164
|
+
let bufferId = parseInt(query.get("id") ?? "", 10)
|
|
165
|
+
if (!Number.isFinite(bufferId)) return Response.json({ error: "Buffer requires ?id=<bufferId>" }, { status: 400 })
|
|
166
|
+
let extra: Record<string, unknown> = { bufferId }
|
|
167
|
+
let byteOffset = parseInt(query.get("offset") ?? "", 10)
|
|
168
|
+
if (Number.isFinite(byteOffset)) extra.byteOffset = byteOffset
|
|
169
|
+
let length = parseInt(query.get("length") ?? "", 10)
|
|
170
|
+
if (Number.isFinite(length)) extra.length = length
|
|
171
|
+
let as = query.get("as")
|
|
172
|
+
if (as !== undefined) extra.as = as
|
|
173
|
+
return handleQuery(query, "buffer", extra)
|
|
174
|
+
}
|
|
175
|
+
case "/__control__/reload": {
|
|
176
|
+
// Explicit rebuild-and-push, the primary way a coding agent applies its
|
|
177
|
+
// edits (srt mcp's reload tool). Unlike the repl's file watcher this is
|
|
178
|
+
// on demand, so a burst of edits collapses into one reload.
|
|
179
|
+
if (req.method !== "POST") return Response.json({ error: "Reload requires POST" }, { status: 405 })
|
|
180
|
+
let error = await rebuildAndBroadcast()
|
|
181
|
+
if (error) return Response.json({ error }, { status: 502 })
|
|
182
|
+
return Response.json({ ok: true, clients: state.clients.size })
|
|
183
|
+
}
|
|
129
184
|
default:
|
|
130
185
|
return Response.json({ error: "Unknown control endpoint" }, { status: 404 })
|
|
131
186
|
}
|
package/server/main.ts
CHANGED
|
@@ -73,13 +73,18 @@ async function handleInternal(req: FluxRequest, server: Server, path: string): P
|
|
|
73
73
|
|
|
74
74
|
switch (path) {
|
|
75
75
|
case "/__internal__/reload": {
|
|
76
|
-
// { message, clients?, latch?, sourceDir? }: send `message` (a full
|
|
76
|
+
// { message, clients?, latch?, sourceDir?, map? }: send `message` (a full
|
|
77
77
|
// client-protocol message, built by srt) to the listed client ids, or to
|
|
78
78
|
// all when omitted. `latch` keeps it for late-joining clients (code
|
|
79
79
|
// reloads latch, one-shot bytecode loads do not); `sourceDir` moves the
|
|
80
|
-
// file-serving root (repl `load`)
|
|
80
|
+
// file-serving root (repl `load`); `map` is the bundle's sourcemap for
|
|
81
|
+
// log remapping, replaced on every reload (absent means none).
|
|
81
82
|
let body = await req.json()
|
|
82
83
|
if (typeof body.sourceDir === "string") state.sourceDir = body.sourceDir
|
|
84
|
+
// Keep the rebuild entry in sync when `load` moves it, so a later MCP
|
|
85
|
+
// reload bundles the newly loaded file, not the launch-time one.
|
|
86
|
+
if (typeof body.entry === "string") state.config.entry = body.entry
|
|
87
|
+
state.currentMap = typeof body.map === "string" ? body.map : null
|
|
83
88
|
let text = JSON.stringify(body.message)
|
|
84
89
|
if (body.latch) state.currentReload = text
|
|
85
90
|
sendTo(body.clients, text)
|
|
@@ -89,7 +94,10 @@ async function handleInternal(req: FluxRequest, server: Server, path: string): P
|
|
|
89
94
|
let body = await req.json()
|
|
90
95
|
// A broadcast stop also forgets the latched reload, so a client that
|
|
91
96
|
// connects afterwards starts clean.
|
|
92
|
-
if (!body.clients)
|
|
97
|
+
if (!body.clients) {
|
|
98
|
+
state.currentReload = null
|
|
99
|
+
state.currentMap = null
|
|
100
|
+
}
|
|
93
101
|
sendTo(body.clients, JSON.stringify({ type: "stop" }))
|
|
94
102
|
return new Response("", { status: 204 })
|
|
95
103
|
}
|
|
@@ -215,7 +223,7 @@ serve({
|
|
|
215
223
|
websocket: {
|
|
216
224
|
open(ws) {
|
|
217
225
|
let id = state.nextClientId++
|
|
218
|
-
state.clients.set(ws, { platform: "unknown", version: "unknown", id, capabilities: [] })
|
|
226
|
+
state.clients.set(ws, { platform: "unknown", version: "unknown", profile: "unknown", id, capabilities: [] })
|
|
219
227
|
console.log(`[cli] Client connected ${ws.remoteAddress ?? "unknown"}`)
|
|
220
228
|
// Advertise our real LAN address so clients dialed over a loopback hop
|
|
221
229
|
// can show/remember the directly reachable address (see connection.rs).
|
|
@@ -239,6 +247,7 @@ serve({
|
|
|
239
247
|
state.clients.set(ws, {
|
|
240
248
|
platform: data.platform ?? "unknown",
|
|
241
249
|
version: data.version ?? "unknown",
|
|
250
|
+
profile: data.profile ?? "unknown",
|
|
242
251
|
id: existing?.id ?? state.nextClientId++,
|
|
243
252
|
capabilities: Array.isArray(data.capabilities) ? data.capabilities.map(String) : [],
|
|
244
253
|
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { command } from "flux:subprocess"
|
|
2
|
+
import { state } from "./state"
|
|
3
|
+
|
|
4
|
+
// Server-owned "rebuild and push": the single place the running app is rebuilt
|
|
5
|
+
// from source on demand (an MCP reload). The srt repl still bundles in-process
|
|
6
|
+
// for its own keystroke reloads, but both routes call the same bundle-cli, so
|
|
7
|
+
// the bundling logic cannot drift. Making the server the rebuild authority is
|
|
8
|
+
// the interim step toward folding the whole CLI into flux (see
|
|
9
|
+
// okf/backlog/cli-flux-migration.md).
|
|
10
|
+
|
|
11
|
+
// Build the reload message the same way srt's buildReload does, so a
|
|
12
|
+
// server-triggered reload is indistinguishable from a repl-triggered one to
|
|
13
|
+
// clients. proxyFiles/proxyHttp are message flags, not build inputs.
|
|
14
|
+
function buildReload(code: string) {
|
|
15
|
+
let config = state.config
|
|
16
|
+
return { type: "reload", proxyFiles: config.proxyFiles, proxyHttp: config.proxyHttp, code }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Rebuild from state.config.entry via the external bundle-cli subprocess, then
|
|
20
|
+
// latch (for late-joining clients) and broadcast the reload to every connected
|
|
21
|
+
// client. Resolves with an error message on failure (no entry configured, or a
|
|
22
|
+
// build error), or null on success.
|
|
23
|
+
export async function rebuildAndBroadcast(): Promise<string | null> {
|
|
24
|
+
let config = state.config
|
|
25
|
+
if (!config.entry) {
|
|
26
|
+
return "No app entry to rebuild. Start srt with a source file (srt run src/index.tsx)."
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let params = JSON.stringify({
|
|
30
|
+
entry: config.entry,
|
|
31
|
+
devBase: state.serverUrl,
|
|
32
|
+
dev: true,
|
|
33
|
+
minify: config.minify,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
let result = await command(config.bundlerCmd[0]!, [...config.bundlerCmd.slice(1), params]).output()
|
|
37
|
+
if (!result.success) {
|
|
38
|
+
let stderr = typeof result.stderr === "string" ? result.stderr : ""
|
|
39
|
+
return `Rebuild failed:\n${stderr.trim()}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// bundle-cli writes one JSON object { code, map } to stdout.
|
|
43
|
+
let bundle: { code?: string; map?: string | null }
|
|
44
|
+
try {
|
|
45
|
+
bundle = JSON.parse(typeof result.stdout === "string" ? result.stdout : "")
|
|
46
|
+
} catch {
|
|
47
|
+
return "Rebuild failed: unreadable bundler output"
|
|
48
|
+
}
|
|
49
|
+
state.currentMap = bundle.map ?? null
|
|
50
|
+
let text = JSON.stringify(buildReload(bundle.code ?? ""))
|
|
51
|
+
state.currentReload = text
|
|
52
|
+
for (let ws of state.clients.keys()) ws.send(text)
|
|
53
|
+
return null
|
|
54
|
+
}
|
package/server/remap.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping"
|
|
2
|
+
|
|
3
|
+
// Stack-trace remapping for forwarded client logs. The runtime evaluates the
|
|
4
|
+
// bundle as module "main", so QuickJS frames cite bundle positions like
|
|
5
|
+
// "at boom (main:212:9)". With the current reload's sourcemap latched on the
|
|
6
|
+
// server (state.currentMap), those positions are rewritten to the original
|
|
7
|
+
// .tsx sources before a log entry is buffered.
|
|
8
|
+
|
|
9
|
+
// The parsed map is cached per map text; a reload swaps the text and the next
|
|
10
|
+
// lookup rebuilds the tracer.
|
|
11
|
+
let cachedText: string | null = null
|
|
12
|
+
let tracer: TraceMap | null = null
|
|
13
|
+
|
|
14
|
+
function tracerFor(map: string | null): TraceMap | null {
|
|
15
|
+
if (map !== cachedText) {
|
|
16
|
+
cachedText = map
|
|
17
|
+
tracer = null
|
|
18
|
+
if (map) {
|
|
19
|
+
try {
|
|
20
|
+
tracer = new TraceMap(JSON.parse(map))
|
|
21
|
+
} catch {
|
|
22
|
+
// A malformed map disables remapping until the next reload.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return tracer
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Rewrite every "main:LINE:COL" (or "main:LINE") position in `text` to its
|
|
31
|
+
* original source position, e.g. "src/app.tsx:42:7". Positions the map has no
|
|
32
|
+
* entry for, and all text when `map` is null, pass through unchanged.
|
|
33
|
+
* QuickJS lines and columns are 1-based; sourcemap columns are 0-based.
|
|
34
|
+
*/
|
|
35
|
+
export function remapPositions(text: string, map: string | null): string {
|
|
36
|
+
if (!map || !text.includes("main:")) return text
|
|
37
|
+
let t = tracerFor(map)
|
|
38
|
+
if (!t) return text
|
|
39
|
+
return text.replace(/\bmain:(\d+)(?::(\d+))?\b/g, (frame, line, column) => {
|
|
40
|
+
let pos = originalPositionFor(t, {
|
|
41
|
+
line: parseInt(line, 10),
|
|
42
|
+
column: column ? Math.max(parseInt(column, 10) - 1, 0) : 0,
|
|
43
|
+
})
|
|
44
|
+
if (pos.source == null || pos.line == null) return frame
|
|
45
|
+
return `${pos.source}:${pos.line}:${(pos.column ?? 0) + 1}`
|
|
46
|
+
})
|
|
47
|
+
}
|
package/server/state.ts
CHANGED
|
@@ -11,6 +11,15 @@ export type Config = {
|
|
|
11
11
|
address: string
|
|
12
12
|
proxyFiles: boolean
|
|
13
13
|
proxyHttp: boolean
|
|
14
|
+
/** The app entry (absolute .tsx/.jsx path) the server rebuilds on an
|
|
15
|
+
* MCP-triggered reload, or undefined when srt was started without a source.
|
|
16
|
+
* Moved by the repl `load` command via /__internal__/reload. */
|
|
17
|
+
entry?: string
|
|
18
|
+
/** Minify the rebuild output, mirroring the srt --minify flag. */
|
|
19
|
+
minify: boolean
|
|
20
|
+
/** How the server invokes the external bundler: [bunPath, bundleCliPath],
|
|
21
|
+
* spawned with a JSON params argument appended (see rebuild.ts). */
|
|
22
|
+
bundlerCmd: string[]
|
|
14
23
|
/** Enable the sqlite-backed proxy cache. */
|
|
15
24
|
cache: boolean
|
|
16
25
|
/** Directory holding .srt-cache.db. */
|
|
@@ -22,7 +31,7 @@ export type Config = {
|
|
|
22
31
|
tunnel: boolean
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
export type ClientInfo = { platform: string; version: string; id: number; capabilities: string[] }
|
|
34
|
+
export type ClientInfo = { platform: string; version: string; profile: string; id: number; capabilities: string[] }
|
|
26
35
|
|
|
27
36
|
export let state = {
|
|
28
37
|
config: undefined as unknown as Config,
|
|
@@ -33,6 +42,13 @@ export let state = {
|
|
|
33
42
|
* Set by /__internal__/reload posts with `latch`, cleared by a broadcast stop.
|
|
34
43
|
*/
|
|
35
44
|
currentReload: null as string | null,
|
|
45
|
+
/**
|
|
46
|
+
* The running bundle's sourcemap (JSON text, bundle -> .tsx sources), used
|
|
47
|
+
* to remap stack traces in forwarded client logs (see control.ts). Replaced
|
|
48
|
+
* on every reload; a reload without a map clears it so frames are never
|
|
49
|
+
* remapped against a stale map.
|
|
50
|
+
*/
|
|
51
|
+
currentMap: null as string | null,
|
|
36
52
|
sourceDir: "",
|
|
37
53
|
serverUrl: "",
|
|
38
54
|
stats: false,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Standalone bundler entry, spawned by the dev server (a flux process) as a
|
|
2
|
+
// Bun subprocess to rebuild the app on an MCP-triggered reload. flux cannot call
|
|
3
|
+
// Bun.build, so the server shells out to this. Params arrive as one JSON
|
|
4
|
+
// argument; one JSON object { code, map } goes to stdout and diagnostics to
|
|
5
|
+
// stderr. On a build failure it exits non-zero with an empty stdout.
|
|
6
|
+
|
|
7
|
+
import { bundleWith, type BundleOptions } from "./bundler"
|
|
8
|
+
|
|
9
|
+
let params = JSON.parse(process.argv[2] ?? "{}") as BundleOptions
|
|
10
|
+
let result = await bundleWith(params)
|
|
11
|
+
if (!result) process.exit(1)
|
|
12
|
+
process.stdout.write(JSON.stringify(result))
|
package/src/bundler.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { transformAsync } from "@babel/core"
|
|
2
2
|
import jsx from "@babel/plugin-syntax-jsx"
|
|
3
3
|
import ts from "@babel/preset-typescript"
|
|
4
|
+
import remapping from "@jridgewell/remapping"
|
|
4
5
|
import solid from "babel-preset-solid"
|
|
5
6
|
import { type BunPlugin, type BuildArtifact } from "bun"
|
|
6
7
|
import { readFileSync } from "node:fs"
|
|
@@ -53,7 +54,7 @@ function binaryImport({ types: t }: { types: any }) {
|
|
|
53
54
|
// build, skipping emitted asset outputs. Bun's file loader emits binary assets
|
|
54
55
|
// as extra outputs; the callers that flatten outputs into a single code string
|
|
55
56
|
// must not glue those raw bytes onto the program.
|
|
56
|
-
|
|
57
|
+
async function codeFromOutputs(outputs: BuildArtifact[]): Promise<string> {
|
|
57
58
|
let code = ""
|
|
58
59
|
for (let o of outputs) {
|
|
59
60
|
if (o.kind === "entry-point" || o.kind === "chunk") code += await o.text()
|
|
@@ -62,61 +63,106 @@ export async function codeFromOutputs(outputs: BuildArtifact[]): Promise<string>
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
// Bun build plugin that runs JSX/TSX through babel-preset-solid (universal
|
|
65
|
-
// generate, targeting @solidrt/core) plus the TS preset.
|
|
66
|
-
|
|
66
|
+
// generate, targeting @solidrt/core) plus the TS preset. Plain .js/.ts app
|
|
67
|
+
// modules take the same path (solid is a no-op without JSX) so binaryImport
|
|
68
|
+
// can rewrite their `with { type: "binary" }` imports too; dependency code
|
|
69
|
+
// (node_modules) skips the babel detour and keeps Bun's native loaders.
|
|
70
|
+
// With `babelMaps`, each file's transform map (original -> babel output) is
|
|
71
|
+
// collected there, keyed by absolute path, for sourcemap composition later.
|
|
72
|
+
function solidPlugin(babelMaps?: Map<string, object>): BunPlugin {
|
|
67
73
|
return {
|
|
68
74
|
name: "bun-plugin-solid",
|
|
69
75
|
setup: (build) => {
|
|
70
|
-
build.onLoad({ filter: /\.(js|ts)x
|
|
76
|
+
build.onLoad({ filter: /\.(js|ts)x?$/ }, async (args) => {
|
|
77
|
+
if (!/\.(js|ts)x$/.test(args.path) && args.path.includes("node_modules")) return
|
|
71
78
|
let file = Bun.file(args.path)
|
|
72
79
|
let code = await file.text()
|
|
73
80
|
let transforms = await transformAsync(code, {
|
|
74
81
|
filename: args.path,
|
|
82
|
+
sourceMaps: !!babelMaps,
|
|
75
83
|
presets: [[solid, { moduleName: "@solidrt/core", generate: "universal" }], [ts]],
|
|
76
84
|
plugins: [jsx, binaryImport],
|
|
77
85
|
})
|
|
86
|
+
if (babelMaps && transforms?.map) babelMaps.set(args.path, transforms.map)
|
|
78
87
|
return { contents: transforms?.code ?? "", loader: "js" }
|
|
79
88
|
})
|
|
80
89
|
},
|
|
81
90
|
}
|
|
82
91
|
}
|
|
83
92
|
|
|
84
|
-
export
|
|
85
|
-
let result = null
|
|
93
|
+
export type BundleOptions = { entry: string; devBase?: string; dev: boolean; minify: boolean }
|
|
86
94
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
export type BundleResult = {
|
|
96
|
+
code: string
|
|
97
|
+
/** Composed sourcemap JSON (bundle -> original .tsx sources), dev builds only. */
|
|
98
|
+
map: string | null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The pure bundle: every input is explicit, so it runs identically in the srt
|
|
102
|
+
// (Bun) process and in the standalone bundle-cli subprocess the dev server
|
|
103
|
+
// spawns. It never touches the ambient args/state singletons and never prints
|
|
104
|
+
// progress (callers own that), so its stdout stays clean for subprocess use.
|
|
105
|
+
export async function bundleWith(opts: BundleOptions): Promise<BundleResult | null> {
|
|
91
106
|
let define: Record<string, string> = {
|
|
92
|
-
"process.env.NODE_ENV": dev ? "development" : "production",
|
|
107
|
+
"process.env.NODE_ENV": opts.dev ? "development" : "production",
|
|
93
108
|
}
|
|
94
|
-
if (devBase) define.__SRT_DEV_BASE__ = devBase
|
|
109
|
+
if (opts.devBase) define.__SRT_DEV_BASE__ = opts.devBase
|
|
95
110
|
|
|
111
|
+
let babelMaps = opts.dev ? new Map<string, object>() : undefined
|
|
112
|
+
let result = null
|
|
96
113
|
try {
|
|
97
114
|
result = await Bun.build({
|
|
98
|
-
entrypoints: [entry
|
|
115
|
+
entrypoints: [opts.entry],
|
|
99
116
|
target: "browser",
|
|
100
117
|
format: "esm",
|
|
101
|
-
minify:
|
|
118
|
+
minify: opts.minify,
|
|
102
119
|
external: ["flux:*", "srt:*"],
|
|
103
120
|
define,
|
|
104
121
|
loader: { ".svg": "text" },
|
|
105
|
-
|
|
122
|
+
sourcemap: opts.dev ? "external" : "none",
|
|
123
|
+
plugins: [solidPlugin(babelMaps)],
|
|
106
124
|
})
|
|
107
125
|
} catch (e) {
|
|
108
126
|
console.error("[cli] compile error:\n", e)
|
|
109
127
|
return null
|
|
110
128
|
}
|
|
111
129
|
|
|
112
|
-
if (result
|
|
113
|
-
|
|
130
|
+
if (!result.success) {
|
|
131
|
+
for (let msg of result.logs) console.error(msg)
|
|
132
|
+
return null
|
|
114
133
|
}
|
|
115
134
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
135
|
+
return { code: await codeFromOutputs(result.outputs), map: await composeMap(result.outputs, babelMaps) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Compose Bun's bundle map (babel output -> bundle) with the per-file Babel
|
|
139
|
+
// maps (original source -> babel output) so positions point at the .tsx
|
|
140
|
+
// sources. Bun ignores sourcemaps in plugin onLoad contents, so this second
|
|
141
|
+
// hop has to happen here. Only a single-artifact build gets a map: code
|
|
142
|
+
// splitting is off, and concatenated artifacts would invalidate offsets.
|
|
143
|
+
async function composeMap(outputs: BuildArtifact[], babelMaps?: Map<string, object>): Promise<string | null> {
|
|
144
|
+
if (!babelMaps) return null
|
|
145
|
+
let js = outputs.filter((o) => o.kind === "entry-point" || o.kind === "chunk")
|
|
146
|
+
if (js.length !== 1 || !js[0]!.sourcemap) return null
|
|
147
|
+
let bunMap = JSON.parse(await js[0]!.sourcemap.text())
|
|
148
|
+
let composed = remapping(bunMap, (file: string) => {
|
|
149
|
+
// Bun writes cwd-relative source paths; the babel maps are keyed by the
|
|
150
|
+
// absolute path. Serve each map exactly once: remapping asks again for a
|
|
151
|
+
// served map's own original source, and that lookup must return null.
|
|
152
|
+
let abs = resolvePath(file)
|
|
153
|
+
let map = babelMaps.get(abs)
|
|
154
|
+
babelMaps.delete(abs)
|
|
155
|
+
return (map as any) ?? null
|
|
156
|
+
})
|
|
157
|
+
return composed.toString()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function bundle(entry = source) {
|
|
161
|
+
let devBase = state.serverUrl ?? undefined
|
|
162
|
+
let dev = !!devBase || values.dev
|
|
163
|
+
// Keep stdout clean when the bundle itself is written to stdout.
|
|
164
|
+
if (!values.stdout) print(`[cli] Bundling (${dev ? "development" : "production"})`)
|
|
165
|
+
return bundleWith({ entry: entry!, devBase, dev, minify: values.minify })
|
|
120
166
|
}
|
|
121
167
|
|
|
122
168
|
export async function bundleTo(outfile: string) {
|
|
@@ -125,7 +171,7 @@ export async function bundleTo(outfile: string) {
|
|
|
125
171
|
console.error("Build failed")
|
|
126
172
|
process.exit(1)
|
|
127
173
|
}
|
|
128
|
-
await Bun.write(outfile,
|
|
174
|
+
await Bun.write(outfile, result.code)
|
|
129
175
|
return result
|
|
130
176
|
}
|
|
131
177
|
|
|
@@ -153,7 +199,7 @@ export async function bundleSolid(): Promise<string> {
|
|
|
153
199
|
console.error("Build failed")
|
|
154
200
|
process.exit(1)
|
|
155
201
|
}
|
|
156
|
-
return
|
|
202
|
+
return result.code
|
|
157
203
|
}
|
|
158
204
|
|
|
159
205
|
// Compile JS source to QuickJS bytecode via the fluxc binary.
|
package/src/commands/bundle.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { values, source, isPrebuilt } from "../args"
|
|
2
|
-
import { bundle, bundleTo, bundleFlux, compileToBytecode
|
|
2
|
+
import { bundle, bundleTo, bundleFlux, compileToBytecode } from "../bundler"
|
|
3
3
|
import { resolve } from "path"
|
|
4
4
|
|
|
5
5
|
// Write to stdout and resolve only once the whole payload is flushed.
|
|
@@ -55,7 +55,7 @@ export async function runBundleCommand() {
|
|
|
55
55
|
console.error("Build failed")
|
|
56
56
|
process.exit(1)
|
|
57
57
|
}
|
|
58
|
-
await writeStdout(
|
|
58
|
+
await writeStdout(result.code)
|
|
59
59
|
process.exit()
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -65,15 +65,12 @@ export async function runBundleCommand() {
|
|
|
65
65
|
console.error("Build failed")
|
|
66
66
|
process.exit(1)
|
|
67
67
|
}
|
|
68
|
-
|
|
69
|
-
await writeBytecode(jsCode, baseName + ".srt.bin")
|
|
68
|
+
await writeBytecode(result.code, baseName + ".srt.bin")
|
|
70
69
|
process.exit()
|
|
71
70
|
}
|
|
72
71
|
|
|
73
72
|
let jsOutfile = baseName + ".srt.js"
|
|
74
73
|
let result = await bundleTo(jsOutfile)
|
|
75
|
-
|
|
76
|
-
console.log(`>> wrote ${output.size} bytes to ${jsOutfile}`)
|
|
77
|
-
}
|
|
74
|
+
console.log(`>> wrote ${result.code.length} bytes to ${jsOutfile}`)
|
|
78
75
|
process.exit()
|
|
79
76
|
}
|
package/src/commands/mcp.ts
CHANGED
|
@@ -5,16 +5,25 @@
|
|
|
5
5
|
//
|
|
6
6
|
// stdout is the JSON-RPC channel; nothing here may print to it.
|
|
7
7
|
|
|
8
|
+
import { z } from "zod"
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
11
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
|
8
12
|
import { DEV_PORT } from "../dev-server"
|
|
9
13
|
|
|
10
14
|
const CONTROL_BASE = `http://127.0.0.1:${DEV_PORT}/__control__`
|
|
11
15
|
|
|
12
16
|
type ControlResult = { ok: true; body: any } | { ok: false; message: string }
|
|
13
17
|
|
|
14
|
-
async function control(path: string): Promise<ControlResult> {
|
|
18
|
+
async function control(path: string, method: "GET" | "POST" = "GET", payload?: unknown): Promise<ControlResult> {
|
|
15
19
|
let resp
|
|
16
20
|
try {
|
|
17
|
-
|
|
21
|
+
let init: RequestInit = { method }
|
|
22
|
+
if (payload !== undefined) {
|
|
23
|
+
init.headers = { "content-type": "application/json" }
|
|
24
|
+
init.body = JSON.stringify(payload)
|
|
25
|
+
}
|
|
26
|
+
resp = await fetch(CONTROL_BASE + path, init)
|
|
18
27
|
} catch {
|
|
19
28
|
return {
|
|
20
29
|
ok: false,
|
|
@@ -29,70 +38,110 @@ async function control(path: string): Promise<ControlResult> {
|
|
|
29
38
|
return { ok: true, body }
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
let
|
|
41
|
+
let CLIENT_ARG = z
|
|
42
|
+
.number()
|
|
43
|
+
.int()
|
|
44
|
+
.describe("Client id from list_clients (default: the only connected client)")
|
|
45
|
+
.optional()
|
|
46
|
+
|
|
47
|
+
let TOOLS: { name: string; description: string; inputSchema: Record<string, z.ZodTypeAny> }[] = [
|
|
33
48
|
{
|
|
34
49
|
name: "list_clients",
|
|
35
50
|
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: {
|
|
51
|
+
"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 (git describe; a -dirty suffix means the binary was built from uncommitted engine changes), build profile (debug/release), and the capability names compiled into that client's runtime. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
|
|
52
|
+
inputSchema: {},
|
|
38
53
|
},
|
|
39
54
|
{
|
|
40
55
|
name: "get_logs",
|
|
41
56
|
description:
|
|
42
57
|
"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
58
|
inputSchema: {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
},
|
|
55
|
-
additionalProperties: false,
|
|
59
|
+
since: z
|
|
60
|
+
.number()
|
|
61
|
+
.int()
|
|
62
|
+
.describe("Only return entries with seq greater than this (default 0: the whole buffer)")
|
|
63
|
+
.optional(),
|
|
64
|
+
wait_ms: z
|
|
65
|
+
.number()
|
|
66
|
+
.int()
|
|
67
|
+
.describe("If nothing is newer than `since`, wait up to this many milliseconds for new output (max 30000)")
|
|
68
|
+
.optional(),
|
|
56
69
|
},
|
|
57
70
|
},
|
|
58
71
|
{
|
|
59
72
|
name: "get_stats",
|
|
60
73
|
description:
|
|
61
74
|
"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
|
-
},
|
|
75
|
+
inputSchema: { client: CLIENT_ARG },
|
|
69
76
|
},
|
|
70
77
|
{
|
|
71
78
|
name: "get_render_tree",
|
|
72
79
|
description:
|
|
73
80
|
"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
|
+
inputSchema: { client: CLIENT_ARG },
|
|
81
82
|
},
|
|
82
83
|
{
|
|
83
84
|
name: "get_snapshot",
|
|
84
85
|
description:
|
|
85
86
|
"Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. The node must be currently mounted and have a non-zero layout box.",
|
|
86
87
|
inputSchema: {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
nodeId: z.number().int().describe("Id of the node to capture, from get_render_tree"),
|
|
89
|
+
client: CLIENT_ARG,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "get_gpu_resources",
|
|
94
|
+
description:
|
|
95
|
+
"Inventory of a running app client's GPU resources: textures (id, size, whether a shader renders into it), vertex buffers (id, byteLength), and shader/pipeline targets (output textureId, kind, bufferId, topology, drawCount, depth, attribute layout, bound sampler texture ids, last-applied uniform values). Use it when the render tree is just a <texture> leaf and the interesting state lives behind it; follow up with get_texture or get_buffer to see contents.",
|
|
96
|
+
inputSchema: { client: CLIENT_ARG },
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "get_texture",
|
|
100
|
+
description:
|
|
101
|
+
"Read back any GPU texture from a running app client as a PNG, by texture id (from get_gpu_resources, or the id returned by createImage/createShader/createPipeline in app code). Works on sampled textures (atlases, data textures) and shader/pipeline render targets alike, without needing a frame. Pass x/y/width/height to crop, e.g. one tile of an atlas.",
|
|
102
|
+
inputSchema: {
|
|
103
|
+
id: z.number().int().describe("Texture id, from get_gpu_resources"),
|
|
104
|
+
x: z.number().int().describe("Crop rect left edge in texture pixels (requires y, width, height)").optional(),
|
|
105
|
+
y: z.number().int().describe("Crop rect top edge in texture pixels").optional(),
|
|
106
|
+
width: z.number().int().describe("Crop rect width in texture pixels").optional(),
|
|
107
|
+
height: z.number().int().describe("Crop rect height in texture pixels").optional(),
|
|
108
|
+
client: CLIENT_ARG,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "get_buffer",
|
|
113
|
+
description:
|
|
114
|
+
"Read back part of a GPU vertex buffer from a running app client, decoded to numbers. Returns values plus byteOffset/byteLength actually read and bufferByteLength. Reads are capped at 64 KiB per call; page through larger buffers with offset. Use it to verify geometry after a writeBuffer, e.g. the dynamic sprite tail of a vertex buffer.",
|
|
115
|
+
inputSchema: {
|
|
116
|
+
id: z.number().int().describe("Buffer id, from get_gpu_resources"),
|
|
117
|
+
offset: z.number().int().describe("Byte offset to start reading at (default 0)").optional(),
|
|
118
|
+
length: z.number().int().describe("Number of values to read (default: the rest of the buffer, capped)").optional(),
|
|
119
|
+
as: z.enum(["f32", "u16", "u8"]).describe("How to decode the bytes (default f32)").optional(),
|
|
120
|
+
client: CLIENT_ARG,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "list_debug",
|
|
125
|
+
description:
|
|
126
|
+
"List the debug commands the running app registered via registerDebug from srt:dev. Returns the command names; call one with call_debug. Empty when the app registered none.",
|
|
127
|
+
inputSchema: { client: CLIENT_ARG },
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "call_debug",
|
|
131
|
+
description:
|
|
132
|
+
"Call a debug command the running app registered via registerDebug from srt:dev, by name (from list_debug). `args` is passed to the command's function as its single argument; the command's return value comes back JSON-serialized (undefined as null). Commands run synchronously on the app's JS thread - use them to query app state (positions, counters, internal flags) or trigger app behavior (toggle a mode, open a door) without touching its real input handling.",
|
|
133
|
+
inputSchema: {
|
|
134
|
+
name: z.string().describe("Debug command name, from list_debug"),
|
|
135
|
+
args: z.record(z.string(), z.any()).describe("Argument object passed to the command (default: none)").optional(),
|
|
136
|
+
client: CLIENT_ARG,
|
|
94
137
|
},
|
|
95
138
|
},
|
|
139
|
+
{
|
|
140
|
+
name: "reload",
|
|
141
|
+
description:
|
|
142
|
+
"Rebuild the app from source and push it to every connected client. Call this after editing the app's .tsx/.jsx source to apply the changes: it bundles once and reloads all clients, so a burst of edits becomes a single explicit reload. Returns the number of clients reloaded, or a build error if the source failed to compile. Follow with get_logs to see runtime output from the reloaded app.",
|
|
143
|
+
inputSchema: {},
|
|
144
|
+
},
|
|
96
145
|
]
|
|
97
146
|
|
|
98
147
|
function clientParam(args: any): string {
|
|
@@ -114,43 +163,72 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
|
|
|
114
163
|
return control(`/stats${clientParam(args)}`)
|
|
115
164
|
case "get_render_tree":
|
|
116
165
|
return control(`/tree${clientParam(args)}`)
|
|
166
|
+
case "reload":
|
|
167
|
+
return control("/reload", "POST")
|
|
117
168
|
case "get_snapshot": {
|
|
118
169
|
if (typeof args?.nodeId !== "number") return { ok: false, message: "get_snapshot requires a numeric nodeId" }
|
|
119
170
|
let params = new URLSearchParams({ node: String(args.nodeId) })
|
|
120
171
|
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
121
172
|
return control(`/snapshot?${params.toString()}`)
|
|
122
173
|
}
|
|
174
|
+
case "get_gpu_resources":
|
|
175
|
+
return control(`/gpu${clientParam(args)}`)
|
|
176
|
+
case "list_debug":
|
|
177
|
+
return control(`/debug${clientParam(args)}`)
|
|
178
|
+
case "call_debug": {
|
|
179
|
+
if (typeof args?.name !== "string") return { ok: false, message: "call_debug requires a command name" }
|
|
180
|
+
let params = new URLSearchParams({ name: args.name })
|
|
181
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
182
|
+
return control(`/debug?${params.toString()}`, "POST", args?.args)
|
|
183
|
+
}
|
|
184
|
+
case "get_texture": {
|
|
185
|
+
if (typeof args?.id !== "number") return { ok: false, message: "get_texture requires a numeric id" }
|
|
186
|
+
let params = new URLSearchParams({ id: String(args.id) })
|
|
187
|
+
for (let key of ["x", "y", "width", "height"]) {
|
|
188
|
+
if (typeof args?.[key] === "number") params.set(key, String(args[key]))
|
|
189
|
+
}
|
|
190
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
191
|
+
return control(`/texture?${params.toString()}`)
|
|
192
|
+
}
|
|
193
|
+
case "get_buffer": {
|
|
194
|
+
if (typeof args?.id !== "number") return { ok: false, message: "get_buffer requires a numeric id" }
|
|
195
|
+
let params = new URLSearchParams({ id: String(args.id) })
|
|
196
|
+
if (typeof args?.offset === "number") params.set("offset", String(args.offset))
|
|
197
|
+
if (typeof args?.length === "number") params.set("length", String(args.length))
|
|
198
|
+
if (typeof args?.as === "string") params.set("as", args.as)
|
|
199
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
200
|
+
return control(`/buffer?${params.toString()}`)
|
|
201
|
+
}
|
|
123
202
|
default:
|
|
124
203
|
return { ok: false, message: `Unknown tool: ${name}` }
|
|
125
204
|
}
|
|
126
205
|
}
|
|
127
206
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
207
|
+
function toContent(name: string, result: ControlResult): CallToolResult {
|
|
208
|
+
if (!result.ok) return { content: [{ type: "text", text: result.message }], isError: true }
|
|
209
|
+
if (name === "get_snapshot" || name === "get_texture") {
|
|
210
|
+
let { pngBase64, width, height } = result.body
|
|
211
|
+
let label = name === "get_snapshot" ? "Captured node snapshot" : "Texture contents"
|
|
212
|
+
return {
|
|
213
|
+
content: [
|
|
214
|
+
{ type: "image", data: pngBase64, mimeType: "image/png" },
|
|
215
|
+
{ type: "text", text: `${label}: ${width}x${height} px` },
|
|
216
|
+
],
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { content: [{ type: "text", text: JSON.stringify(result.body, null, 2) }] }
|
|
220
|
+
}
|
|
134
221
|
|
|
135
|
-
|
|
222
|
+
export async function runMcpCommand() {
|
|
223
|
+
let server = new McpServer({ name: "solidrt", version: "0.0.0" })
|
|
136
224
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
let { pngBase64, width, height } = result.body
|
|
145
|
-
return {
|
|
146
|
-
content: [
|
|
147
|
-
{ type: "image", data: pngBase64, mimeType: "image/png" },
|
|
148
|
-
{ type: "text", text: `Captured node snapshot: ${width}x${height} px` },
|
|
149
|
-
],
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return { content: [{ type: "text", text: JSON.stringify(result.body, null, 2) }] }
|
|
153
|
-
})
|
|
225
|
+
for (let tool of TOOLS) {
|
|
226
|
+
server.registerTool(
|
|
227
|
+
tool.name,
|
|
228
|
+
{ description: tool.description, inputSchema: tool.inputSchema },
|
|
229
|
+
async (args: any) => toContent(tool.name, await callTool(tool.name, args ?? {})),
|
|
230
|
+
)
|
|
231
|
+
}
|
|
154
232
|
|
|
155
233
|
// The stdin read keeps the process alive; it exits when the agent host
|
|
156
234
|
// closes the pipe.
|
package/src/commands/server.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import pkg from "../../package.json"
|
|
2
2
|
import { source, isSource, isPrebuilt, values } from "../args"
|
|
3
3
|
import { state, shutdown } from "../util"
|
|
4
|
-
import { bundle
|
|
4
|
+
import { bundle } from "../bundler"
|
|
5
5
|
import { startServer, buildReload, sendReload, showBuildFailure } from "../dev-server"
|
|
6
6
|
import { startRepl } from "../repl"
|
|
7
7
|
import { startWatcher } from "../watcher"
|
|
@@ -27,8 +27,9 @@ export async function runServerCommand() {
|
|
|
27
27
|
if (source && isSource) {
|
|
28
28
|
let initialResult = await bundle()
|
|
29
29
|
if (initialResult) {
|
|
30
|
-
state.currentCode =
|
|
31
|
-
|
|
30
|
+
state.currentCode = initialResult.code
|
|
31
|
+
state.currentMap = initialResult.map
|
|
32
|
+
await sendReload(buildReload({ code: state.currentCode }), { latch: true, map: state.currentMap })
|
|
32
33
|
} else {
|
|
33
34
|
await showBuildFailure()
|
|
34
35
|
}
|
package/src/dev-server.ts
CHANGED
|
@@ -30,9 +30,14 @@ async function post(path: string, body: object) {
|
|
|
30
30
|
* Send a client-protocol message through the server: to the given client ids,
|
|
31
31
|
* or to every client when omitted. `latch` keeps the message for late-joining
|
|
32
32
|
* clients (code reloads latch, one-shot bytecode loads do not); `sourceDir`
|
|
33
|
-
* moves the server's file-serving root (repl `load`)
|
|
33
|
+
* moves the server's file-serving root (repl `load`); `map` is the bundle's
|
|
34
|
+
* sourcemap, kept server-side for stack-trace remapping (omitting it clears
|
|
35
|
+
* the server's map, so a mapless reload never remaps against a stale one).
|
|
34
36
|
*/
|
|
35
|
-
export async function sendReload(
|
|
37
|
+
export async function sendReload(
|
|
38
|
+
message: object,
|
|
39
|
+
opts: { clients?: number[]; latch?: boolean; sourceDir?: string; entry?: string; map?: string | null } = {},
|
|
40
|
+
) {
|
|
36
41
|
await post("/reload", { message, ...opts })
|
|
37
42
|
}
|
|
38
43
|
|
|
@@ -125,12 +130,20 @@ export async function startServer() {
|
|
|
125
130
|
// computes it and passes it down.
|
|
126
131
|
state.serverUrl = `${address}:${DEV_PORT}`
|
|
127
132
|
|
|
133
|
+
// How the server rebuilds on an MCP-triggered reload: it cannot call
|
|
134
|
+
// Bun.build itself (it is a flux process), so it spawns srt's own bun on the
|
|
135
|
+
// standalone bundle-cli entry. Both paths are known here at spawn time.
|
|
136
|
+
let bundleCli = fileURLToPath(new URL("./bundle-cli.ts", import.meta.url))
|
|
137
|
+
|
|
128
138
|
let config = {
|
|
129
139
|
port: DEV_PORT,
|
|
130
140
|
sourceDir: state.sourceDir,
|
|
131
141
|
address,
|
|
132
142
|
proxyFiles: values["proxy-files"],
|
|
133
143
|
proxyHttp: values["proxy-http"],
|
|
144
|
+
entry: state.source,
|
|
145
|
+
minify: values.minify,
|
|
146
|
+
bundlerCmd: [process.execPath, bundleCli],
|
|
134
147
|
cache: values["proxy-http"],
|
|
135
148
|
cacheDir: process.cwd(),
|
|
136
149
|
capture: state.capture,
|
package/src/repl.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { resolve, dirname } from "path"
|
|
|
3
3
|
import { readdirSync } from "node:fs"
|
|
4
4
|
import { state, print, printErr, shutdown } from "./util"
|
|
5
5
|
import { buildReload, getClients, sendReload, sendStop, sendStats, showBuildFailure } from "./dev-server"
|
|
6
|
-
import { bundle
|
|
6
|
+
import { bundle } 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,
|
|
@@ -27,6 +27,7 @@ async function cmdStop(args: string) {
|
|
|
27
27
|
if (!args) {
|
|
28
28
|
stopWatcher()
|
|
29
29
|
state.currentCode = null
|
|
30
|
+
state.currentMap = null
|
|
30
31
|
state.source = undefined
|
|
31
32
|
await sendStop()
|
|
32
33
|
print("[cli] Sent stop to all clients")
|
|
@@ -47,17 +48,18 @@ async function cmdReload(args: string) {
|
|
|
47
48
|
await showBuildFailure()
|
|
48
49
|
return
|
|
49
50
|
}
|
|
50
|
-
state.currentCode =
|
|
51
|
+
state.currentCode = result.code
|
|
52
|
+
state.currentMap = result.map
|
|
51
53
|
}
|
|
52
54
|
let msg = buildReload({ code: state.currentCode })
|
|
53
55
|
if (!args) {
|
|
54
|
-
await sendReload(msg, { latch: true })
|
|
56
|
+
await sendReload(msg, { latch: true, map: state.currentMap })
|
|
55
57
|
print("[cli] Sent reload to all clients")
|
|
56
58
|
return
|
|
57
59
|
}
|
|
58
60
|
let ids = await indexesToIds(args)
|
|
59
61
|
if (ids.length) {
|
|
60
|
-
await sendReload(msg, { clients: ids })
|
|
62
|
+
await sendReload(msg, { clients: ids, map: state.currentMap })
|
|
61
63
|
print(`[cli] Sent reload to client(s) ${ids.join(", ")}`)
|
|
62
64
|
}
|
|
63
65
|
}
|
|
@@ -102,9 +104,11 @@ async function cmdLoad(file: string) {
|
|
|
102
104
|
printErr("[cli] Build failed")
|
|
103
105
|
return
|
|
104
106
|
}
|
|
105
|
-
state.currentCode =
|
|
107
|
+
state.currentCode = result.code
|
|
108
|
+
state.currentMap = result.map
|
|
106
109
|
} else if (file.endsWith(".srt.js")) {
|
|
107
110
|
state.currentCode = await Bun.file(path).text()
|
|
111
|
+
state.currentMap = null
|
|
108
112
|
} else if (file.endsWith(".srt.bin")) {
|
|
109
113
|
let bytes = await Bun.file(path).arrayBuffer()
|
|
110
114
|
// One-shot: bytecode loads are pushed but not latched for late joiners.
|
|
@@ -118,8 +122,14 @@ async function cmdLoad(file: string) {
|
|
|
118
122
|
state.source = path
|
|
119
123
|
state.sourceDir = dirname(path)
|
|
120
124
|
startWatcher()
|
|
121
|
-
// The load also moves the server's file-serving root to the new source dir
|
|
122
|
-
|
|
125
|
+
// The load also moves the server's file-serving root to the new source dir,
|
|
126
|
+
// and its rebuild entry to the new file (for a later MCP reload).
|
|
127
|
+
await sendReload(buildReload({ code: state.currentCode }), {
|
|
128
|
+
latch: true,
|
|
129
|
+
sourceDir: state.sourceDir,
|
|
130
|
+
entry: file.endsWith(".tsx") ? path : undefined,
|
|
131
|
+
map: state.currentMap,
|
|
132
|
+
})
|
|
123
133
|
print(`[cli] Loaded ${file}`)
|
|
124
134
|
}
|
|
125
135
|
|
package/src/util.ts
CHANGED
|
@@ -8,6 +8,9 @@ export let state = {
|
|
|
8
8
|
// What srt believes the current bundle is; the server process keeps its own
|
|
9
9
|
// latched copy for late-joining clients (see packages/cli/server/).
|
|
10
10
|
currentCode: null as string | null,
|
|
11
|
+
// The bundle's composed sourcemap (dev builds), sent to the server alongside
|
|
12
|
+
// reloads so it can remap logged stack traces to .tsx positions.
|
|
13
|
+
currentMap: null as string | null,
|
|
11
14
|
source: undefined as string | undefined,
|
|
12
15
|
sourceDir: process.cwd(),
|
|
13
16
|
child: null as ReturnType<typeof Bun.spawn> | null,
|
package/src/watcher.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { watch } from "node:fs"
|
|
|
2
2
|
import { resolve, dirname } from "path"
|
|
3
3
|
import { state, print, printErr } from "./util"
|
|
4
4
|
import { buildReload, sendReload, showBuildFailure } from "./dev-server"
|
|
5
|
-
import { bundle
|
|
5
|
+
import { bundle } from "./bundler"
|
|
6
6
|
|
|
7
7
|
let currentWatcher: ReturnType<typeof watch> | null = null
|
|
8
8
|
|
|
@@ -31,7 +31,8 @@ export function startWatcher() {
|
|
|
31
31
|
await showBuildFailure()
|
|
32
32
|
return
|
|
33
33
|
}
|
|
34
|
-
state.currentCode =
|
|
35
|
-
|
|
34
|
+
state.currentCode = result.code
|
|
35
|
+
state.currentMap = result.map
|
|
36
|
+
await sendReload(buildReload({ code: state.currentCode }), { latch: true, map: state.currentMap })
|
|
36
37
|
})
|
|
37
38
|
}
|