@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/cli",
3
- "version": "0.0.26",
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.26",
28
- "@solidrt/linux-x64-gnu": "0.0.26",
29
- "@solidrt/win32-x64-msvc": "0.0.26"
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.26",
35
+ "@solidrt/core": "0.0.27",
33
36
  "typescript": "^7"
34
37
  },
35
38
  "devDependencies": {
36
- "@solidrt/flux-types": "0.0.26",
39
+ "@solidrt/flux-types": "0.0.27",
37
40
  "@types/bun": "latest"
38
41
  }
39
42
  }
@@ -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.
@@ -9,12 +9,12 @@
9
9
  "android": "srt client --android"
10
10
  },
11
11
  "dependencies": {
12
- "@solidrt/core": "0.0.26",
13
- "@solidrt/components": "0.0.26"
12
+ "@solidrt/core": "0.0.27",
13
+ "@solidrt/components": "0.0.27"
14
14
  },
15
15
  "devDependencies": {
16
- "@solidrt/cli": "0.0.26",
17
- "@solidrt/flux-types": "0.0.26",
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
- if (msg.error) return Response.json({ error: msg.error }, { status: 502 })
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) state.currentReload = null
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
+ }
@@ -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
- export async function codeFromOutputs(outputs: BuildArtifact[]): Promise<string> {
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
- function solidPlugin(): BunPlugin {
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$/ }, async (args) => {
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 async function bundle(entry = source) {
85
- let result = null
93
+ export type BundleOptions = { entry: string; devBase?: string; dev: boolean; minify: boolean }
86
94
 
87
- let devBase = state.serverUrl ?? undefined
88
- let dev = !!devBase || values.dev
89
- // Keep stdout clean when the bundle itself is written to stdout.
90
- if (!values.stdout) print(`[cli] Bundling (${dev ? "development" : "production"})`)
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: values.minify,
118
+ minify: opts.minify,
102
119
  external: ["flux:*", "srt:*"],
103
120
  define,
104
121
  loader: { ".svg": "text" },
105
- plugins: [solidPlugin()],
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?.success) {
113
- return result
130
+ if (!result.success) {
131
+ for (let msg of result.logs) console.error(msg)
132
+ return null
114
133
  }
115
134
 
116
- if (result) {
117
- for (let msg of result?.logs) console.error(msg)
118
- }
119
- return null
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, await codeFromOutputs(result.outputs))
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 codeFromOutputs(result.outputs)
202
+ return result.code
157
203
  }
158
204
 
159
205
  // Compile JS source to QuickJS bytecode via the fluxc binary.
@@ -1,5 +1,5 @@
1
1
  import { values, source, isPrebuilt } from "../args"
2
- import { bundle, bundleTo, bundleFlux, compileToBytecode, codeFromOutputs } from "../bundler"
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(await codeFromOutputs(result.outputs))
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
- let jsCode = await codeFromOutputs(result.outputs)
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
- for (let output of result.outputs) {
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
  }
@@ -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
- resp = await fetch(CONTROL_BASE + path)
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 TOOLS = [
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: { type: "object", properties: {}, additionalProperties: false },
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
- 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,
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
- type: "object",
88
- properties: {
89
- nodeId: { type: "integer", description: "Id of the node to capture, from get_render_tree" },
90
- client: { type: "integer", description: "Client id from list_clients (default: the only connected client)" },
91
- },
92
- required: ["nodeId"],
93
- additionalProperties: false,
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
- export async function runMcpCommand() {
129
- let { Server } = await import("@modelcontextprotocol/sdk/server/index.js")
130
- let { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js")
131
- let { ListToolsRequestSchema, CallToolRequestSchema } = await import("@modelcontextprotocol/sdk/types.js")
132
-
133
- let server = new Server({ name: "solidrt", version: "0.0.0" }, { capabilities: { tools: {} } })
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
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
222
+ export async function runMcpCommand() {
223
+ let server = new McpServer({ name: "solidrt", version: "0.0.0" })
136
224
 
137
- server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
138
- let name = request.params.name
139
- let result = await callTool(name, request.params.arguments ?? {})
140
- if (!result.ok) {
141
- return { content: [{ type: "text", text: result.message }], isError: true }
142
- }
143
- if (name === "get_snapshot") {
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.
@@ -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, codeFromOutputs } from "../bundler"
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 = await codeFromOutputs(initialResult.outputs)
31
- await sendReload(buildReload({ code: state.currentCode }), { latch: true })
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(message: object, opts: { clients?: number[]; latch?: boolean; sourceDir?: string } = {}) {
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, codeFromOutputs } from "./bundler"
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 = await codeFromOutputs(result.outputs)
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 = await codeFromOutputs(result.outputs)
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
- await sendReload(buildReload({ code: state.currentCode }), { latch: true, sourceDir: state.sourceDir })
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, codeFromOutputs } from "./bundler"
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 = await codeFromOutputs(result.outputs)
35
- await sendReload(buildReload({ code: state.currentCode }), { latch: true })
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
  }