@solidrt/cli 0.0.51 → 0.0.52

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.
Files changed (93) hide show
  1. package/AGENTS.md +68 -38
  2. package/README.md +86 -15
  3. package/agents/assets.md +19 -5
  4. package/agents/debugging.md +169 -17
  5. package/dist/console.srtapp +94123 -73
  6. package/dist/server.js +3936 -0
  7. package/package.json +10 -11
  8. package/src/android/docs.md +21 -0
  9. package/src/android/main.ts +286 -0
  10. package/src/{bundler.ts → bundle/bundler.ts} +67 -55
  11. package/src/bundle/docs.md +12 -0
  12. package/src/{commands/bundle.ts → bundle/main.ts} +67 -25
  13. package/src/check/docs.md +10 -0
  14. package/src/check/main.ts +85 -0
  15. package/src/{commands/check.ts → check/typecheck.ts} +11 -41
  16. package/src/client/docs.md +9 -0
  17. package/src/client/main.ts +33 -0
  18. package/src/console/docs.md +14 -0
  19. package/src/console/main.ts +21 -0
  20. package/src/demo/docs.md +27 -0
  21. package/src/demo/main.ts +67 -0
  22. package/src/init/docs.md +11 -0
  23. package/src/{commands/init.ts → init/main.ts} +21 -15
  24. package/src/init/scaffold/AGENTS.md +98 -0
  25. package/src/init/scaffold/package.json +23 -0
  26. package/{scaffold → src/init/scaffold}/templates/components/index.tsx +2 -3
  27. package/{scaffold → src/init/scaffold}/templates/default/index.tsx +2 -3
  28. package/src/lib/args.ts +194 -0
  29. package/src/{artifacts.ts → lib/artifacts.ts} +41 -1
  30. package/src/{dev-dir.ts → lib/dev-dir.ts} +10 -8
  31. package/src/{fonts.ts → lib/fonts.ts} +12 -26
  32. package/src/lib/mode.ts +77 -0
  33. package/src/{project.ts → lib/project.ts} +109 -61
  34. package/src/lib/registry.ts +120 -0
  35. package/src/lib/server-bundle.ts +24 -0
  36. package/src/lib/usage.ts +117 -0
  37. package/src/lib/util.ts +36 -0
  38. package/src/main.ts +109 -31
  39. package/src/mcp/docs.md +22 -0
  40. package/src/mcp/main.ts +719 -0
  41. package/src/pack/docs.md +18 -0
  42. package/src/{pack-folder.ts → pack/layout.ts} +13 -22
  43. package/src/{commands/pack.ts → pack/main.ts} +28 -17
  44. package/src/{packer.ts → pack/trailer.ts} +32 -42
  45. package/src/render/docs.md +19 -0
  46. package/src/{commands/render.ts → render/main.ts} +17 -15
  47. package/src/server/args.ts +126 -0
  48. package/src/server/binaries.ts +47 -0
  49. package/src/server/config.ts +54 -0
  50. package/{server → src/server}/control.ts +239 -81
  51. package/src/server/docs.md +51 -0
  52. package/src/server/line-editor.ts +200 -0
  53. package/src/server/main.ts +473 -0
  54. package/src/server/mode.ts +92 -0
  55. package/src/server/rebuild.ts +90 -0
  56. package/src/server/registry.ts +138 -0
  57. package/src/server/repl.ts +223 -0
  58. package/src/server/state.ts +54 -0
  59. package/{server → src/server}/tsconfig.json +1 -1
  60. package/{server → src/server}/tunnel.ts +6 -6
  61. package/src/server/watcher.ts +121 -0
  62. package/src/tool/main.ts +70 -0
  63. package/src/types/bundle.d.ts +24 -0
  64. package/src/types/control.d.ts +90 -0
  65. package/src/types/registry.d.ts +16 -0
  66. package/scaffold/AGENTS.md +0 -185
  67. package/scaffold/package.json +0 -22
  68. package/scaffold/templates/components/icon.tsx +0 -48
  69. package/scaffold/templates/default/icon.tsx +0 -48
  70. package/server/main.ts +0 -308
  71. package/server/rebuild.ts +0 -76
  72. package/server/state.ts +0 -94
  73. package/src/args.ts +0 -210
  74. package/src/bundle-cli.ts +0 -13
  75. package/src/commands/client.ts +0 -34
  76. package/src/commands/mcp.ts +0 -617
  77. package/src/commands/server.ts +0 -73
  78. package/src/dev-android.ts +0 -176
  79. package/src/dev-client.ts +0 -29
  80. package/src/dev-server.ts +0 -302
  81. package/src/repl.ts +0 -249
  82. package/src/util.ts +0 -122
  83. package/src/watcher.ts +0 -73
  84. /package/src/{untyped-deps.d.ts → bundle/untyped-deps.d.ts} +0 -0
  85. /package/src/{prompt.ts → init/prompt.ts} +0 -0
  86. /package/{scaffold → src/init/scaffold}/gitignore +0 -0
  87. /package/{scaffold → src/init/scaffold}/icon.svg +0 -0
  88. /package/{scaffold → src/init/scaffold}/mcp.json +0 -0
  89. /package/{scaffold → src/init/scaffold}/tsconfig.json +0 -0
  90. /package/{server → src/server}/cache.ts +0 -0
  91. /package/{server → src/server}/proxy.ts +0 -0
  92. /package/{server → src/server}/qr.ts +0 -0
  93. /package/{server → src/server}/remap.ts +0 -0
@@ -1,8 +1,20 @@
1
- import { file } from "flux:fs"
2
1
  import { state } from "./state"
3
2
  import { rebuildAndBroadcast } from "./rebuild"
4
3
  import { remapPositions } from "./remap"
4
+ import { ENTRY_EXTENSIONS, absolute, dirname } from "./mode"
5
+ import { file, realpath } from "flux:fs"
5
6
  import type { ServerWebSocket } from "flux:http"
7
+ import type {
8
+ ClientEntry,
9
+ ClientsResponse,
10
+ LoadResponse,
11
+ LogEntry,
12
+ LogsResponse,
13
+ MuteResponse,
14
+ ReloadResponse,
15
+ StatsResponse,
16
+ WatchResponse,
17
+ } from "../types/control"
6
18
 
7
19
  // The control API under /__control__/: read-only introspection of connected
8
20
  // app clients, served next to the file routes. The MCP bridge (srt mcp) is the
@@ -10,8 +22,6 @@ import type { ServerWebSocket } from "flux:http"
10
22
  // logs) and queries forwarded to a client over its websocket and correlated
11
23
  // back by id (tree, stats).
12
24
 
13
- export type LogEntry = { seq: number; at: number; client: number; level: string; text: string }
14
-
15
25
  // Ring buffer of forwarded client logs. Capped so a chatty app cannot grow the
16
26
  // server without bound; readers page through it with the `since` cursor.
17
27
  const LOG_CAP = 2000
@@ -54,7 +64,7 @@ export function resolveQuery(msg: { id?: number }) {
54
64
  // The connected-client list. `withAddress` adds each socket's peer address for
55
65
  // the internal API (the repl `list` display); the public control shape stays
56
66
  // without it.
57
- export function clientList(withAddress = false) {
67
+ export function clientList(withAddress = false): (ClientEntry & { address?: string | null })[] {
58
68
  return [...state.clients.entries()].map(([ws, info]) => ({
59
69
  id: info.id,
60
70
  platform: info.platform,
@@ -62,6 +72,16 @@ export function clientList(withAddress = false) {
62
72
  profile: info.profile,
63
73
  capabilities: info.capabilities,
64
74
  queries: info.queries,
75
+ stats: info.stats,
76
+ timeScale: info.timeScale,
77
+ clientDir: info.clientDir,
78
+ pid: info.pid,
79
+ execPath: info.execPath,
80
+ host: info.host,
81
+ os: info.os,
82
+ kernel: info.kernel,
83
+ videoDriver: info.videoDriver,
84
+ gpu: info.gpu,
65
85
  ...(withAddress ? { address: ws.remoteAddr ?? null } : {}),
66
86
  }))
67
87
  }
@@ -105,6 +125,17 @@ function parseRect(query: Map<string, string>): { x: number; y: number; width: n
105
125
  return { x: x!, y: y!, width: width!, height: height! }
106
126
  }
107
127
 
128
+ // Optional output format shared by /snapshot and /texture: "png" (default,
129
+ // the reply carries pngBase64) or "raw" (rgbaBase64: RGBA8 bytes, rows
130
+ // top-down, no decoder needed for pixel assertions). Returns undefined for
131
+ // the default, or a 400 Response.
132
+ function parseFormat(query: Map<string, string>): "raw" | undefined | Response {
133
+ let param = query.get("format")
134
+ if (param === undefined || param === "png") return undefined
135
+ if (param === "raw") return "raw"
136
+ return Response.json({ error: 'Format must be "png" or "raw"' }, { status: 400 })
137
+ }
138
+
108
139
  // Optional integer magnification shared by /snapshot and /texture. Returns
109
140
  // undefined when absent or 1, a 400 Response when out of range.
110
141
  function parseScale(query: Map<string, string>): number | Response | undefined {
@@ -116,30 +147,46 @@ function parseScale(query: Map<string, string>): number | Response | undefined {
116
147
  return scale === 1 ? undefined : scale
117
148
  }
118
149
 
119
- async function handleQuery(
120
- query: Map<string, string>,
150
+ // Forward a query to one client and wait for its answer: the data it
151
+ // replied with, or the error Response to send instead (no such client, a
152
+ // timeout, or the client's own error).
153
+ async function queryClient(
154
+ ws: ServerWebSocket,
121
155
  kind: string,
122
156
  extra?: Record<string, unknown>,
123
157
  timeoutMs: number = QUERY_TIMEOUT_MS,
124
- ): Promise<Response> {
125
- let target = findClient(query.get("client"))
126
- if ("error" in target) return target.error
158
+ ): Promise<{ data: any } | { error: Response }> {
127
159
  let id = nextQueryId++
128
160
  let reply = new Promise<any>((resolve) => {
129
161
  pendingQueries.set(id, resolve)
130
162
  })
131
- target.ws.send(JSON.stringify({ type: "query", kind, id, ...extra }))
163
+ ws.send(JSON.stringify({ type: "query", kind, id, ...extra }))
132
164
  let msg = await Promise.race([reply, sleep(timeoutMs)])
133
165
  pendingQueries.delete(id)
134
166
  if (!msg)
135
- return Response.json(
136
- { error: "Query timed out: the client is connected but did not answer (JS thread busy or app wedged?)" },
137
- { status: 504 },
138
- )
167
+ return {
168
+ error: Response.json(
169
+ { error: "Query timed out: the client is connected but did not answer (JS thread busy or app wedged?)" },
170
+ { status: 504 },
171
+ ),
172
+ }
139
173
  // Error strings may carry stack traces (e.g. a debug command threw); remap
140
174
  // bundle positions to .tsx sources like appendLog does for forwarded logs.
141
- if (msg.error) return Response.json({ error: remapPositions(String(msg.error), state.currentMaps) }, { status: 502 })
142
- return Response.json(msg.data)
175
+ if (msg.error)
176
+ return { error: Response.json({ error: remapPositions(String(msg.error), state.currentMaps) }, { status: 502 }) }
177
+ return { data: msg.data }
178
+ }
179
+
180
+ async function handleQuery(
181
+ query: Map<string, string>,
182
+ kind: string,
183
+ extra?: Record<string, unknown>,
184
+ timeoutMs: number = QUERY_TIMEOUT_MS,
185
+ ): Promise<Response> {
186
+ let target = findClient(query.get("client"))
187
+ if ("error" in target) return target.error
188
+ let result = await queryClient(target.ws, kind, extra, timeoutMs)
189
+ return "error" in result ? result.error : Response.json(result.data)
143
190
  }
144
191
 
145
192
  // Merge runs of consecutive identical entries (same client, level, text) into
@@ -160,9 +207,10 @@ function collapseRepeats(entries: LogEntry[]): (LogEntry & { repeats?: number })
160
207
  return out
161
208
  }
162
209
 
163
- // GET /__control__/logs?since=N&wait=MS&level=L1,L2&contains=TEXT: entries with
164
- // seq > since, plus the latest seq as the next cursor and the server
165
- // generation. `level` keeps only the listed levels; `contains` keeps entries
210
+ // GET /__control__/logs?since=N&wait=MS&level=L1,L2&contains=TEXT&client=ID:
211
+ // entries with seq > since, plus the latest seq as the next cursor and the
212
+ // server generation. `client` keeps one client's entries (every client's
213
+ // without); `level` keeps only the listed levels; `contains` keeps entries
166
214
  // whose text has the substring (case-insensitive). Consecutive identical
167
215
  // entries come back collapsed with a `repeats` count. With `wait`, holds the
168
216
  // response until an entry passes the filters or the timeout expires
@@ -176,10 +224,20 @@ async function handleLogs(query: Map<string, string>): Promise<Response> {
176
224
  .map((l) => l.trim())
177
225
  .filter(Boolean)
178
226
  let contains = query.get("contains")?.toLowerCase()
227
+ // The client filter takes any id, connected or not: a client that crashed
228
+ // is gone from /clients but its output is what the caller wants to read.
229
+ // Only a malformed id is refused, instead of silently disabling the filter.
230
+ let client: number | undefined
231
+ let clientParam = query.get("client")
232
+ if (clientParam !== undefined) {
233
+ client = parseInt(clientParam, 10)
234
+ if (!Number.isFinite(client)) return Response.json({ error: "Client must be a numeric id" }, { status: 400 })
235
+ }
179
236
  let select = () =>
180
237
  logs.filter(
181
238
  (e) =>
182
239
  e.seq > since &&
240
+ (client === undefined || e.client === client) &&
183
241
  (!levels || levels.length === 0 || levels.includes(e.level)) &&
184
242
  (!contains || e.text.toLowerCase().includes(contains)),
185
243
  )
@@ -197,21 +255,108 @@ async function handleLogs(query: Map<string, string>): Promise<Response> {
197
255
  })
198
256
  entries = select()
199
257
  }
200
- return Response.json({ entries: collapseRepeats(entries), latest: logSeq, generation: state.generation })
258
+ let body: LogsResponse = { entries: collapseRepeats(entries), latest: logSeq, generation: state.generation }
259
+ return Response.json(body)
260
+ }
261
+
262
+ // Whether `path` lies under `root` (both canonical), never equal to it.
263
+ function isUnder(path: string, root: string): boolean {
264
+ return path.length > root.length && path.startsWith(root) && (path[root.length] === "/" || path[root.length] === "\\")
265
+ }
266
+
267
+ // Load (or switch) the app entry and push it: the /load route (srt mcp's
268
+ // load tool) and the repl's `load`. Moves the rebuild entry, then reuses the
269
+ // reload path, so later reloads rebuild the new file. A project server stays
270
+ // inside its project (the bundle resolves the project's dependencies and
271
+ // assets, and the key keeps naming the project); a file server takes any
272
+ // file, moving the file routes and the bundler's cwd along with the entry.
273
+ // The key never moves: it names what the server was started for, and
274
+ // /clients reports the entry next to it. `status` is the HTTP status the
275
+ // error maps to (400 for a bad request, 502 for a failed build).
276
+ export async function loadEntry(requested: string): Promise<{ entry: string } | { error: string; status: number }> {
277
+ if (!ENTRY_EXTENSIONS.some((ext) => requested.endsWith(ext))) {
278
+ return { error: `Not an app entry: ${requested} (expected .tsx, .jsx, .ts, .js or .srt.js)`, status: 400 }
279
+ }
280
+ let config = state.config
281
+ let path = absolute(requested, config.projectDir ?? config.sourceDir)
282
+ if (!(await file(path).exists())) return { error: `Entry not found: ${path}`, status: 400 }
283
+ let entry = await realpath(path)
284
+ if (config.projectDir && !isUnder(entry, config.projectDir)) {
285
+ return {
286
+ error: `Entry is outside the project: ${entry} is not under ${config.projectDir}. A project server only bundles sources inside its project; start srt for that file on its own.`,
287
+ status: 400,
288
+ }
289
+ }
290
+ config.entry = entry
291
+ config.entryArgs[0] = entry
292
+ if (!config.projectDir) {
293
+ config.sourceDir = dirname(entry)
294
+ config.cwd = config.sourceDir
295
+ }
296
+ console.log(`[cli] Loading ${entry}`)
297
+ let error = await rebuildAndBroadcast()
298
+ if (error) return { error, status: 502 }
299
+ return { entry }
300
+ }
301
+
302
+ // Mute or unmute the user's own input on every client (srt mcp's
303
+ // mute_user_input/unmute_user_input, the repl's `mute`): while muted, a
304
+ // measurement or an interaction test is not disturbed by a stray click;
305
+ // synthetic /input still goes through. Latched for clients joining while
306
+ // muted (the welcome message) and broadcast to the connected ones; no ack,
307
+ // the mute takes effect on arrival.
308
+ export function setUserInputMuted(on: boolean) {
309
+ if (on !== state.userInputMuted) {
310
+ console.log(on ? "[cli] User input muted on every client" : "[cli] User input unmuted")
311
+ }
312
+ state.userInputMuted = on
313
+ let text = JSON.stringify({ type: "mute", active: on })
314
+ for (let ws of state.clients.keys()) ws.send(text)
315
+ }
316
+
317
+ // Pause or resume reload-on-save (srt mcp's pause_watch/resume_watch, the
318
+ // repl's `watch`): paused, an agent's saves are not pushed while it edits;
319
+ // its explicit /reload is. Changes made while paused are not replayed on
320
+ // resume.
321
+ export function setWatchActive(on: boolean) {
322
+ if (on === state.watchPaused) {
323
+ console.log(on ? "[cli] Reload on save resumed" : "[cli] Reload on save paused")
324
+ }
325
+ state.watchPaused = !on
326
+ }
327
+
328
+ // Toggle the stats overlay on one client, or on every client (the repl's
329
+ // `stats`) - then it is also the server's setting, which the welcome message
330
+ // carries to clients joining later. Each client's entry remembers its own
331
+ // state, so /clients reports what is actually drawn where.
332
+ export function setStats(on: boolean, ws?: ServerWebSocket) {
333
+ let text = JSON.stringify({ type: "stats", stats: on })
334
+ let targets = ws ? [ws] : [...state.clients.keys()]
335
+ if (!ws) state.stats = on
336
+ for (let target of targets) {
337
+ let info = state.clients.get(target)
338
+ if (info) info.stats = on
339
+ target.send(text)
340
+ }
341
+ return targets.length
201
342
  }
202
343
 
203
344
  export async function handleControl(req: Request, path: string, query: Map<string, string>): Promise<Response> {
204
345
  switch (path) {
205
346
  case "/__control__/clients":
206
- // `entry`/`projectDir` identify which app this server is serving: the
207
- // fixed dev port means an agent can reach a different project's server
208
- // than it thinks, and a repl/MCP `load` moves the entry mid-session.
209
- return Response.json({
347
+ // `key`/`mode`/`entry` identify what this server serves, for a caller
348
+ // that resolved it from the registry and wants to confirm the match.
349
+ let body: ClientsResponse = {
210
350
  generation: state.generation,
211
- entry: state.config.entry ?? null,
212
- projectDir: state.projectDir,
351
+ key: state.config.key,
352
+ mode: state.config.mode,
353
+ entry: state.config.entry,
354
+ projectDir: state.config.projectDir,
355
+ userInputMuted: state.userInputMuted,
356
+ watchPaused: state.watchPaused,
213
357
  clients: clientList(),
214
- })
358
+ }
359
+ return Response.json(body)
215
360
  case "/__control__/logs":
216
361
  return handleLogs(query)
217
362
  case "/__control__/tree": {
@@ -226,6 +371,24 @@ export async function handleControl(req: Request, path: string, query: Map<strin
226
371
  return handleQuery(query, "tree", extra)
227
372
  }
228
373
  case "/__control__/stats": {
374
+ // GET reads one client's statistics; POST switches the overlay, on one
375
+ // client with ?client=<id> or on all of them without.
376
+ if (req.method === "POST") {
377
+ let active = query.get("active")
378
+ if (active !== "true" && active !== "false") {
379
+ return Response.json({ error: "Stats requires ?active=true or ?active=false" }, { status: 400 })
380
+ }
381
+ let on = active === "true"
382
+ let target = query.get("client")
383
+ let ws: ServerWebSocket | undefined
384
+ if (target !== undefined) {
385
+ let found = findClient(target)
386
+ if ("error" in found) return found.error
387
+ ws = found.ws
388
+ }
389
+ let body: StatsResponse = { ok: true, active: on, clients: setStats(on, ws) }
390
+ return Response.json(body)
391
+ }
229
392
  let extra: Record<string, unknown> = {}
230
393
  let windowMs = parseInt(query.get("window") ?? "", 10)
231
394
  if (Number.isFinite(windowMs)) extra.windowMs = windowMs
@@ -241,10 +404,16 @@ export async function handleControl(req: Request, path: string, query: Map<strin
241
404
  let scale = parseScale(query)
242
405
  if (scale instanceof Response) return scale
243
406
  if (scale) extra.scale = scale
407
+ let format = parseFormat(query)
408
+ if (format instanceof Response) return format
409
+ if (format) extra.format = format
244
410
  return handleQuery(query, "snapshot", extra)
245
411
  }
246
- case "/__control__/gpu":
247
- return handleQuery(query, "gpu")
412
+ case "/__control__/gpu": {
413
+ // ?label=<text> keeps only resources created with exactly that label.
414
+ let label = query.get("label")
415
+ return handleQuery(query, "gpu", label === undefined ? undefined : { label })
416
+ }
248
417
  case "/__control__/debug": {
249
418
  // GET lists the app's registered debug commands; POST calls one, with
250
419
  // an optional JSON body as its args.
@@ -267,6 +436,9 @@ export async function handleControl(req: Request, path: string, query: Map<strin
267
436
  let scale = parseScale(query)
268
437
  if (scale instanceof Response) return scale
269
438
  if (scale) extra.scale = scale
439
+ let format = parseFormat(query)
440
+ if (format instanceof Response) return format
441
+ if (format) extra.format = format
270
442
  return handleQuery(query, "texture", extra)
271
443
  }
272
444
  case "/__control__/clock": {
@@ -291,7 +463,15 @@ export async function handleControl(req: Request, path: string, query: Map<strin
291
463
  }
292
464
  if (!("scale" in extra) && !("step" in extra))
293
465
  return Response.json({ error: "Clock requires ?scale=<x> or ?step=<n>" }, { status: 400 })
294
- return handleQuery(query, "clock", extra)
466
+ // The reply is the clock as the client now has it; its scale is kept
467
+ // on the client's entry so /clients reports it (a push resets it).
468
+ let target = findClient(query.get("client"))
469
+ if ("error" in target) return target.error
470
+ let result = await queryClient(target.ws, "clock", extra)
471
+ if ("error" in result) return result.error
472
+ let info = state.clients.get(target.ws)
473
+ if (info && typeof result.data?.scale === "number") info.timeScale = result.data.scale
474
+ return Response.json(result.data)
295
475
  }
296
476
  case "/__control__/input": {
297
477
  // Synthetic input injection: POST {events: [...]} forwards a timed
@@ -339,69 +519,47 @@ export async function handleControl(req: Request, path: string, query: Map<strin
339
519
  return handleQuery(query, "buffer", extra)
340
520
  }
341
521
  case "/__control__/reload": {
342
- // Explicit rebuild-and-push, the primary way a coding agent applies its
343
- // edits (srt mcp's reload tool). Unlike the repl's file watcher this is
344
- // on demand, so a burst of edits collapses into one reload.
522
+ // Explicit rebuild-and-push, the way a coding agent applies its edits
523
+ // (srt mcp's reload tool): a burst of edits collapses into one reload,
524
+ // with reload-on-save paused meanwhile (/watch).
345
525
  if (req.method !== "POST") return Response.json({ error: "Reload requires POST" }, { status: 405 })
346
526
  let error = await rebuildAndBroadcast()
347
527
  if (error) return Response.json({ error }, { status: 502 })
348
- state.watch = true
349
- return Response.json({ ok: true, clients: state.clients.size })
528
+ let body: ReloadResponse = { ok: true, clients: state.clients.size }
529
+ return Response.json(body)
350
530
  }
351
531
  case "/__control__/load": {
352
- // Load (or switch) the app entry and push it: srt mcp's load tool.
353
- // Moves the rebuild entry and the file-serving root like the repl's
354
- // `load` command, then reuses the reload path, so a later /reload
355
- // rebuilds the newly loaded file. The srt process is not told: a
356
- // watcher started on the launch-time source keeps watching that file.
357
532
  if (req.method !== "POST") return Response.json({ error: "Load requires POST" }, { status: 405 })
358
- let entry = (await req.json().catch(() => null))?.entry
359
- if (typeof entry !== "string" || !entry) {
360
- return Response.json({ error: "Load requires { entry: <absolute source path> }" }, { status: 400 })
361
- }
362
- if (!(await file(entry).exists())) {
363
- return Response.json({ error: `Entry not found: ${entry}` }, { status: 400 })
364
- }
365
- // An entry outside the project root cannot resolve the project's
366
- // dependencies, so the bundler would fail with misleading "bun install"
367
- // advice; name the real constraint instead.
368
- // Windows paths are case-insensitive and the same drive shows up as both
369
- // `c:` and `C:` (an editor-spawned bridge keeps its parent's spelling), so a
370
- // drive-letter path folds case; a POSIX path stays exact.
371
- let norm = (p: string) => {
372
- let s = p.replace(/\\/g, "/")
373
- return /^[a-zA-Z]:\//.test(s) ? s.toLowerCase() : s
533
+ let requested = (await req.json().catch(() => null))?.entry
534
+ if (typeof requested !== "string" || !requested) {
535
+ return Response.json({ error: "Load requires { entry: <source path> }" }, { status: 400 })
374
536
  }
375
- let root = norm(state.projectDir).replace(/\/+$/, "") + "/"
376
- if (!norm(entry).startsWith(root)) {
377
- return Response.json(
378
- {
379
- error: `Entry is outside the project root: ${entry} is not under ${state.projectDir}. The dev server can only bundle sources inside the project it was started in - move the file into the project or start srt there.`,
380
- },
381
- { status: 400 },
382
- )
537
+ let result = await loadEntry(requested)
538
+ if ("error" in result) return Response.json({ error: result.error }, { status: result.status })
539
+ let body: LoadResponse = { ok: true, entry: result.entry, clients: state.clients.size }
540
+ return Response.json(body)
541
+ }
542
+ case "/__control__/mute": {
543
+ if (req.method !== "POST") return Response.json({ error: "Mute requires POST" }, { status: 405 })
544
+ let active = query.get("active")
545
+ if (active !== "true" && active !== "false") {
546
+ return Response.json({ error: "Mute requires ?active=true or ?active=false" }, { status: 400 })
383
547
  }
384
- state.config.entry = entry
385
- let cut = Math.max(entry.lastIndexOf("/"), entry.lastIndexOf("\\"))
386
- if (cut > 0) state.sourceDir = entry.slice(0, cut)
387
- let error = await rebuildAndBroadcast()
388
- if (error) return Response.json({ error }, { status: 502 })
389
- state.watch = true
390
- return Response.json({ ok: true, entry, clients: state.clients.size })
548
+ let on = active === "true"
549
+ setUserInputMuted(on)
550
+ let body: MuteResponse = { ok: true, active: on, clients: state.clients.size }
551
+ return Response.json(body)
391
552
  }
392
553
  case "/__control__/watch": {
393
- // Pause/resume srt's auto-reload-on-save: the MCP watch tool. Latched
394
- // here because the watcher lives in the srt process; it reads the flag
395
- // via /__internal__/watch before acting on a change event. An agent
396
- // pauses while creating or editing files so half-finished work is not
397
- // pushed; a successful /reload or /load turns it back on.
398
554
  if (req.method !== "POST") return Response.json({ error: "Watch requires POST" }, { status: 405 })
399
- let enabled = (await req.json().catch(() => null))?.enabled
400
- if (typeof enabled !== "boolean") {
401
- return Response.json({ error: "Watch requires { enabled: <boolean> }" }, { status: 400 })
555
+ let active = query.get("active")
556
+ if (active !== "true" && active !== "false") {
557
+ return Response.json({ error: "Watch requires ?active=true or ?active=false" }, { status: 400 })
402
558
  }
403
- state.watch = enabled
404
- return Response.json({ ok: true, enabled })
559
+ let on = active === "true"
560
+ setWatchActive(on)
561
+ let body: WatchResponse = { ok: true, active: on }
562
+ return Response.json(body)
405
563
  }
406
564
  default:
407
565
  return Response.json({ error: "Unknown control endpoint" }, { status: 404 })
@@ -0,0 +1,51 @@
1
+ # srt run and srt server
2
+
3
+ `run` is the everyday command: it starts the dev server and a local client
4
+ window together, and it is what `bun run dev` calls in a scaffolded project.
5
+
6
+ {{ usage run }}
7
+
8
+ Run from the project root to serve the project (the entry is
9
+ `solidrt.entry` in package.json, default `src/index.tsx`); `srt run <file>`
10
+ serves a single file outside a project. In a project root a file argument
11
+ is ambiguous, so it takes `--project` (the project, with this entry) or
12
+ `--file` (the file alone). Nothing searches upward for a package.json.
13
+
14
+ The server pushes the bundle to every connected client, so one server can
15
+ drive a desktop window and a phone at the same time. A save reaches them on
16
+ its own: the server watches every file the running bundle was built from
17
+ (the app's modules, the dependencies it bundles in, inlined files,
18
+ package.json and tsconfig.json) plus the `assets/` tree, so a file the app
19
+ does not import never triggers a rebuild, while an edit to a workspace
20
+ package it does import does. A coding agent pauses this while it edits
21
+ (`pause_watch`) and pushes with the MCP `reload` tool instead.
22
+
23
+ `server` is the same without the local client, for clients on other devices
24
+ (see [srt client](../client/docs.md)):
25
+
26
+ {{ usage server }}
27
+
28
+ One server per project or file. It keeps the port it had last time, else
29
+ takes the first free one from 34884 up, and prints it, so projects run side
30
+ by side without any numbering; `--port N` pins it. Loopback only unless
31
+ `--lan`, which is what phones and other devices need; `--tunnel` accepts
32
+ clients over a peer-to-peer connection instead, with no network setup.
33
+ `--proxy-http` routes the app's `fetch` calls through the dev server, cached
34
+ in `.srt-data/http-cache.db` in the project root (delete the file to
35
+ clear), for clients on other devices that need your machine's data.
36
+ `-- <args>` hands the app its own arguments (`flux:process` argv) on every
37
+ client.
38
+
39
+ Dev state lives in `~/.solidrt/`: `servers/<key hash>/` holds each server's
40
+ registry record, remembered port and tunnel key; `clients/client<N>/` the
41
+ data tree of a locally spawned client (`-c <N>` picks it, default 0;
42
+ storage is per app inside a tree, so two projects share client 0).
43
+
44
+ With a terminal on stdin, `run` and `server` take commands at the `srt>`
45
+ prompt: `reload` and `stop` (every client, or the ids `list` prints),
46
+ `load <file>` (switch the entry, same rules as the MCP `load` tool),
47
+ `stats`, `watch on|off` (reload on save), `mute on|off` (the user's own
48
+ input on the clients), `quit`. Tab completes commands and `load` paths,
49
+ Up/Down recall earlier lines, Ctrl-C (or Ctrl-D on an empty line) quits.
50
+ Without a terminal (a supervisor, the console, a background `&`) the
51
+ server runs without the prompt and stops on a signal.