@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.
- package/AGENTS.md +68 -38
- package/README.md +86 -15
- package/agents/assets.md +19 -5
- package/agents/debugging.md +169 -17
- package/dist/console.srtapp +94123 -73
- package/dist/server.js +3936 -0
- package/package.json +10 -11
- package/src/android/docs.md +21 -0
- package/src/android/main.ts +286 -0
- package/src/{bundler.ts → bundle/bundler.ts} +67 -55
- package/src/bundle/docs.md +12 -0
- package/src/{commands/bundle.ts → bundle/main.ts} +67 -25
- package/src/check/docs.md +10 -0
- package/src/check/main.ts +85 -0
- package/src/{commands/check.ts → check/typecheck.ts} +11 -41
- package/src/client/docs.md +9 -0
- package/src/client/main.ts +33 -0
- package/src/console/docs.md +14 -0
- package/src/console/main.ts +21 -0
- package/src/demo/docs.md +27 -0
- package/src/demo/main.ts +67 -0
- package/src/init/docs.md +11 -0
- package/src/{commands/init.ts → init/main.ts} +21 -15
- package/src/init/scaffold/AGENTS.md +98 -0
- package/src/init/scaffold/package.json +23 -0
- package/{scaffold → src/init/scaffold}/templates/components/index.tsx +2 -3
- package/{scaffold → src/init/scaffold}/templates/default/index.tsx +2 -3
- package/src/lib/args.ts +194 -0
- package/src/{artifacts.ts → lib/artifacts.ts} +41 -1
- package/src/{dev-dir.ts → lib/dev-dir.ts} +10 -8
- package/src/{fonts.ts → lib/fonts.ts} +12 -26
- package/src/lib/mode.ts +77 -0
- package/src/{project.ts → lib/project.ts} +109 -61
- package/src/lib/registry.ts +120 -0
- package/src/lib/server-bundle.ts +24 -0
- package/src/lib/usage.ts +117 -0
- package/src/lib/util.ts +36 -0
- package/src/main.ts +109 -31
- package/src/mcp/docs.md +22 -0
- package/src/mcp/main.ts +719 -0
- package/src/pack/docs.md +18 -0
- package/src/{pack-folder.ts → pack/layout.ts} +13 -22
- package/src/{commands/pack.ts → pack/main.ts} +28 -17
- package/src/{packer.ts → pack/trailer.ts} +32 -42
- package/src/render/docs.md +19 -0
- package/src/{commands/render.ts → render/main.ts} +17 -15
- package/src/server/args.ts +126 -0
- package/src/server/binaries.ts +47 -0
- package/src/server/config.ts +54 -0
- package/{server → src/server}/control.ts +239 -81
- package/src/server/docs.md +51 -0
- package/src/server/line-editor.ts +200 -0
- package/src/server/main.ts +473 -0
- package/src/server/mode.ts +92 -0
- package/src/server/rebuild.ts +90 -0
- package/src/server/registry.ts +138 -0
- package/src/server/repl.ts +223 -0
- package/src/server/state.ts +54 -0
- package/{server → src/server}/tsconfig.json +1 -1
- package/{server → src/server}/tunnel.ts +6 -6
- package/src/server/watcher.ts +121 -0
- package/src/tool/main.ts +70 -0
- package/src/types/bundle.d.ts +24 -0
- package/src/types/control.d.ts +90 -0
- package/src/types/registry.d.ts +16 -0
- package/scaffold/AGENTS.md +0 -185
- package/scaffold/package.json +0 -22
- package/scaffold/templates/components/icon.tsx +0 -48
- package/scaffold/templates/default/icon.tsx +0 -48
- package/server/main.ts +0 -308
- package/server/rebuild.ts +0 -76
- package/server/state.ts +0 -94
- package/src/args.ts +0 -210
- package/src/bundle-cli.ts +0 -13
- package/src/commands/client.ts +0 -34
- package/src/commands/mcp.ts +0 -617
- package/src/commands/server.ts +0 -73
- package/src/dev-android.ts +0 -176
- package/src/dev-client.ts +0 -29
- package/src/dev-server.ts +0 -302
- package/src/repl.ts +0 -249
- package/src/util.ts +0 -122
- package/src/watcher.ts +0 -73
- /package/src/{untyped-deps.d.ts → bundle/untyped-deps.d.ts} +0 -0
- /package/src/{prompt.ts → init/prompt.ts} +0 -0
- /package/{scaffold → src/init/scaffold}/gitignore +0 -0
- /package/{scaffold → src/init/scaffold}/icon.svg +0 -0
- /package/{scaffold → src/init/scaffold}/mcp.json +0 -0
- /package/{scaffold → src/init/scaffold}/tsconfig.json +0 -0
- /package/{server → src/server}/cache.ts +0 -0
- /package/{server → src/server}/proxy.ts +0 -0
- /package/{server → src/server}/qr.ts +0 -0
- /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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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)
|
|
142
|
-
|
|
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:
|
|
164
|
-
// seq > since, plus the latest seq as the next cursor and the
|
|
165
|
-
// generation. `
|
|
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
|
-
|
|
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
|
|
207
|
-
//
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
343
|
-
//
|
|
344
|
-
// on
|
|
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
|
-
|
|
349
|
-
return Response.json(
|
|
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
|
|
359
|
-
if (typeof
|
|
360
|
-
return Response.json({ error: "Load requires { entry: <
|
|
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
|
|
376
|
-
if (
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
400
|
-
if (
|
|
401
|
-
return Response.json({ error: "Watch requires
|
|
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
|
-
|
|
404
|
-
|
|
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.
|