@solidrt/cli 0.0.25 → 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.25",
3
+ "version": "0.0.27",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "bin/",
12
12
  "src/",
13
+ "server/",
13
14
  "scaffold/",
14
15
  "AGENTS.md"
15
16
  ],
@@ -17,21 +18,25 @@
17
18
  "@babel/core": "^7.0.0",
18
19
  "@babel/plugin-syntax-jsx": "^7.0.0",
19
20
  "@babel/preset-typescript": "^7.0.0",
21
+ "@jridgewell/remapping": "^2.3.0",
22
+ "@jridgewell/trace-mapping": "^0.3.25",
20
23
  "@modelcontextprotocol/sdk": "^1.29.0",
21
- "babel-preset-solid": "2.0.0-beta.15",
24
+ "babel-preset-solid": "2.0.0-beta.17",
22
25
  "bonjour-service": "^1.4.0",
23
- "qrcode-generator": "^2.0.4"
26
+ "qrcode-generator": "^2.0.4",
27
+ "zod": "^4.4.3"
24
28
  },
25
29
  "optionalDependencies": {
26
- "@solidrt/darwin-arm64": "0.0.25",
27
- "@solidrt/linux-x64-gnu": "0.0.25",
28
- "@solidrt/win32-x64-msvc": "0.0.25"
30
+ "@solidrt/darwin-arm64": "0.0.27",
31
+ "@solidrt/linux-x64-gnu": "0.0.27",
32
+ "@solidrt/win32-x64-msvc": "0.0.27"
29
33
  },
30
34
  "peerDependencies": {
31
- "@solidrt/core": "0.0.25",
32
- "typescript": "^6"
35
+ "@solidrt/core": "0.0.27",
36
+ "typescript": "^7"
33
37
  },
34
38
  "devDependencies": {
39
+ "@solidrt/flux-types": "0.0.27",
35
40
  "@types/bun": "latest"
36
41
  }
37
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.25",
13
- "@solidrt/components": "0.0.25"
12
+ "@solidrt/core": "0.0.27",
13
+ "@solidrt/components": "0.0.27"
14
14
  },
15
15
  "devDependencies": {
16
- "@solidrt/cli": "0.0.25",
17
- "@solidrt/flux-types": "0.0.25",
18
- "typescript": "^6"
16
+ "@solidrt/cli": "0.0.27",
17
+ "@solidrt/flux-types": "0.0.27",
18
+ "typescript": "^7"
19
19
  }
20
20
  }
@@ -1,15 +1,16 @@
1
1
  // SQLite-backed HTTP response cache for the dev server's /__proxy__ endpoint.
2
2
  //
3
- // Project-local: stored at <cwd>/.srt-cache.db. Opt-in via the --cache
4
- // flag. Entries live forever; delete .srt-cache.db to drop them.
3
+ // Project-local: stored at <dir>/.srt-cache.db. Entries live forever; delete
4
+ // .srt-cache.db to drop them.
5
5
  //
6
6
  // Cached: GET (and HEAD) 2xx responses with no Authorization on the request
7
7
  // and no Cache-Control: no-store on either side. The cache key is
8
- // sha256(method + "\n" + url); headers are intentionally not part of the key.
8
+ // method + "\n" + url, stored raw; headers are intentionally not part of the
9
+ // key. (The Bun predecessor hashed the key with sha256, which was cosmetic:
10
+ // old hashed entries simply miss and re-populate.)
9
11
 
10
- import { Database } from "bun:sqlite"
11
- import { resolve } from "path"
12
- import { createHash } from "node:crypto"
12
+ import { Database } from "flux:sqlite"
13
+ import { join } from "flux:path"
13
14
 
14
15
  const CACHE_FILE = ".srt-cache.db"
15
16
 
@@ -27,9 +28,9 @@ export type Entry = {
27
28
  let db: Database | null = null
28
29
  let enabled = false
29
30
 
30
- export function initCache(opts: { dir: string }) {
31
- let d = new Database(resolve(opts.dir, CACHE_FILE), { create: true })
32
- d.run(`CREATE TABLE IF NOT EXISTS entries (
31
+ export async function initCache(opts: { dir: string }) {
32
+ let d = await Database.connect(join(opts.dir, CACHE_FILE), "rw+")
33
+ await d.exec(`CREATE TABLE IF NOT EXISTS entries (
33
34
  key TEXT PRIMARY KEY,
34
35
  method TEXT NOT NULL,
35
36
  url TEXT NOT NULL,
@@ -46,28 +47,8 @@ export function isEnabled(): boolean {
46
47
  return enabled
47
48
  }
48
49
 
49
- // Strings returned by bun:sqlite carry an internal representation that
50
- // Headers.set() rejects, even when value-identical to an acceptable string
51
- // (Bun bug oven-sh/bun#28266, present through 1.3.14). Rebuilding each char
52
- // yields a clean string.
53
- function reflatten(s: string): string {
54
- return Array.from(s, (c) => String.fromCharCode(c.charCodeAt(0))).join("")
55
- }
56
-
57
- function reflattenHeaders(obj: Record<string, string>): Record<string, string> {
58
- let out: Record<string, string> = {}
59
- for (let key in obj) {
60
- out[reflatten(key)] = reflatten(obj[key]!)
61
- }
62
- return out
63
- }
64
-
65
50
  function keyFor(method: string, url: string): string {
66
- let h = createHash("sha256")
67
- h.update(method)
68
- h.update("\n")
69
- h.update(url)
70
- return h.digest("hex")
51
+ return method + "\n" + url
71
52
  }
72
53
 
73
54
  function cacheableMethod(method: string): boolean {
@@ -98,32 +79,23 @@ export function isBypass(reqHeaders: Headers): boolean {
98
79
  return false
99
80
  }
100
81
 
101
- export function get(method: string, url: string): Entry | null {
82
+ export async function get(method: string, url: string): Promise<Entry | null> {
102
83
  if (!db || !enabled) return null
103
- let row = db
84
+ let row = await db
104
85
  .query("SELECT method, url, status, headers, body, cached_at FROM entries WHERE key = ?")
105
- .get(keyFor(method, url)) as
106
- | {
107
- method: string
108
- url: string
109
- status: number
110
- headers: string
111
- body: Uint8Array
112
- cached_at: number
113
- }
114
- | null
86
+ .get([keyFor(method, url)])
115
87
  if (!row) return null
116
88
  return {
117
- method: row.method,
118
- url: row.url,
119
- status: row.status,
120
- headers: reflattenHeaders(JSON.parse(row.headers)),
121
- body: row.body,
122
- cachedAt: row.cached_at,
89
+ method: row.method as string,
90
+ url: row.url as string,
91
+ status: row.status as number,
92
+ headers: JSON.parse(row.headers as string),
93
+ body: row.body as Uint8Array,
94
+ cachedAt: row.cached_at as number,
123
95
  }
124
96
  }
125
97
 
126
- export function put(
98
+ export async function put(
127
99
  method: string,
128
100
  url: string,
129
101
  status: number,
@@ -133,10 +105,10 @@ export function put(
133
105
  if (!db || !enabled) return
134
106
  if (status < 200 || status >= 300) return
135
107
  if (hasNoStore(headers["cache-control"] ?? null)) return
136
- db.run(
108
+ await db.run(
137
109
  `INSERT OR REPLACE INTO entries
138
110
  (key, method, url, status, headers, body, cached_at)
139
111
  VALUES (?, ?, ?, ?, ?, ?, ?)`,
140
112
  [keyFor(method, url), method, url, status, JSON.stringify(headers), body, Date.now()],
141
113
  )
142
- }
114
+ }
@@ -0,0 +1,187 @@
1
+ import { state } from "./state"
2
+ import { rebuildAndBroadcast } from "./rebuild"
3
+ import { remapPositions } from "./remap"
4
+ import type { ServerWebSocket } from "flux:http"
5
+
6
+ // The control API under /__control__/: read-only introspection of connected
7
+ // app clients, served next to the file routes. The MCP bridge (srt mcp) is the
8
+ // primary consumer. Two shapes: server-held data answered directly (clients,
9
+ // logs) and queries forwarded to a client over its websocket and correlated
10
+ // back by id (tree, stats).
11
+
12
+ export type LogEntry = { seq: number; at: number; client: number; level: string; text: string }
13
+
14
+ // Ring buffer of forwarded client logs. Capped so a chatty app cannot grow the
15
+ // server without bound; readers page through it with the `since` cursor.
16
+ const LOG_CAP = 2000
17
+ const QUERY_TIMEOUT_MS = 5000
18
+ const MAX_WAIT_MS = 30000
19
+
20
+ let logs: LogEntry[] = []
21
+ let logSeq = 0
22
+ // Pending long-poll wakeups (see handleLogs). Flushed on every append; a
23
+ // waiter that already timed out resolves again harmlessly.
24
+ let logWaiters: Array<() => void> = []
25
+
26
+ let nextQueryId = 1
27
+ let pendingQueries = new Map<number, (msg: any) => void>()
28
+
29
+ function sleep(ms: number): Promise<void> {
30
+ return new Promise((resolve) => setTimeout(resolve, ms))
31
+ }
32
+
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.
35
+ export function appendLog(client: number, level: string, text: string) {
36
+ logs.push({ seq: ++logSeq, at: Date.now(), client, level, text: remapPositions(text, state.currentMap) })
37
+ if (logs.length > LOG_CAP) logs.splice(0, logs.length - LOG_CAP)
38
+ let waiters = logWaiters
39
+ logWaiters = []
40
+ for (let wake of waiters) wake()
41
+ }
42
+
43
+ /// A `result` message arrived from a client: hand it to the awaiting query.
44
+ export function resolveQuery(msg: { id?: number }) {
45
+ if (typeof msg.id !== "number") return
46
+ let resolve = pendingQueries.get(msg.id)
47
+ if (resolve) {
48
+ pendingQueries.delete(msg.id)
49
+ resolve(msg)
50
+ }
51
+ }
52
+
53
+ // The connected-client list. `withAddress` adds each socket's peer address for
54
+ // the internal API (the repl `list` display); the public control shape stays
55
+ // without it.
56
+ export function clientList(withAddress = false) {
57
+ return [...state.clients.entries()].map(([ws, info]) => ({
58
+ id: info.id,
59
+ platform: info.platform,
60
+ version: info.version,
61
+ profile: info.profile,
62
+ capabilities: info.capabilities,
63
+ ...(withAddress ? { address: ws.remoteAddress ?? null } : {}),
64
+ }))
65
+ }
66
+
67
+ // Resolve the target client for a query: an explicit ?client=<id>, or the only
68
+ // connected client when the parameter is omitted.
69
+ function findClient(param: string | undefined): { ws: ServerWebSocket } | { error: Response } {
70
+ let entries = [...state.clients.entries()]
71
+ if (param === undefined) {
72
+ if (entries.length === 1) return { ws: entries[0]![0] }
73
+ if (entries.length === 0) return { error: Response.json({ error: "No connected clients" }, { status: 503 }) }
74
+ return { error: Response.json({ error: "Multiple clients connected; pass ?client=<id>" }, { status: 400 }) }
75
+ }
76
+ let id = parseInt(param, 10)
77
+ let entry = entries.find(([, info]) => info.id === id)
78
+ if (!entry) return { error: Response.json({ error: `No client with id ${param}` }, { status: 404 }) }
79
+ return { ws: entry[0] }
80
+ }
81
+
82
+ async function handleQuery(query: Map<string, string>, kind: string, extra?: Record<string, unknown>): Promise<Response> {
83
+ let target = findClient(query.get("client"))
84
+ if ("error" in target) return target.error
85
+ let id = nextQueryId++
86
+ let reply = new Promise<any>((resolve) => {
87
+ pendingQueries.set(id, resolve)
88
+ })
89
+ target.ws.send(JSON.stringify({ type: "query", kind, id, ...extra }))
90
+ let msg = await Promise.race([reply, sleep(QUERY_TIMEOUT_MS)])
91
+ pendingQueries.delete(id)
92
+ if (!msg) return Response.json({ error: "Query timed out" }, { status: 504 })
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 })
96
+ return Response.json(msg.data)
97
+ }
98
+
99
+ // GET /__control__/logs?since=N&wait=MS: entries with seq > since, plus the
100
+ // latest seq as the next cursor. With `wait`, holds the response until a new
101
+ // entry arrives or the timeout passes (long-poll), so a caller can follow the
102
+ // stream without tight polling.
103
+ async function handleLogs(query: Map<string, string>): Promise<Response> {
104
+ let since = parseInt(query.get("since") ?? "0", 10) || 0
105
+ let wait = Math.min(parseInt(query.get("wait") ?? "0", 10) || 0, MAX_WAIT_MS)
106
+ let entries = logs.filter((e) => e.seq > since)
107
+ if (entries.length === 0 && wait > 0) {
108
+ await new Promise<void>((resolve) => {
109
+ let timer = setTimeout(resolve, wait)
110
+ logWaiters.push(() => {
111
+ clearTimeout(timer)
112
+ resolve()
113
+ })
114
+ })
115
+ entries = logs.filter((e) => e.seq > since)
116
+ }
117
+ return Response.json({ entries, latest: logSeq })
118
+ }
119
+
120
+ export async function handleControl(req: Request, path: string, query: Map<string, string>): Promise<Response> {
121
+ switch (path) {
122
+ case "/__control__/clients":
123
+ return Response.json(clientList())
124
+ case "/__control__/logs":
125
+ return handleLogs(query)
126
+ case "/__control__/tree":
127
+ return handleQuery(query, "tree")
128
+ case "/__control__/stats":
129
+ return handleQuery(query, "stats")
130
+ case "/__control__/snapshot": {
131
+ let nodeId = parseInt(query.get("node") ?? "", 10)
132
+ if (!Number.isFinite(nodeId)) return Response.json({ error: "Snapshot requires ?node=<id>" }, { status: 400 })
133
+ return handleQuery(query, "snapshot", { nodeId })
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
+ }
184
+ default:
185
+ return Response.json({ error: "Unknown control endpoint" }, { status: 404 })
186
+ }
187
+ }