@solidrt/cli 0.0.27 → 0.0.29

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.27",
3
+ "version": "0.0.29",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -27,16 +27,17 @@
27
27
  "zod": "^4.4.3"
28
28
  },
29
29
  "optionalDependencies": {
30
- "@solidrt/darwin-arm64": "0.0.27",
31
- "@solidrt/linux-x64-gnu": "0.0.27",
32
- "@solidrt/win32-x64-msvc": "0.0.27"
30
+ "@solidrt/darwin-arm64": "0.0.29",
31
+ "@solidrt/linux-arm64-gnu": "0.0.29",
32
+ "@solidrt/linux-x64-gnu": "0.0.29",
33
+ "@solidrt/win32-x64-msvc": "0.0.29"
33
34
  },
34
35
  "peerDependencies": {
35
- "@solidrt/core": "0.0.27",
36
+ "@solidrt/core": "0.0.29",
36
37
  "typescript": "^7"
37
38
  },
38
39
  "devDependencies": {
39
- "@solidrt/flux-types": "0.0.27",
40
+ "@solidrt/flux-types": "0.0.29",
40
41
  "@types/bun": "latest"
41
42
  }
42
43
  }
@@ -4,6 +4,23 @@ This project uses SolidRT: a custom SolidJS renderer that paints through a Rust
4
4
  runtime. No DOM, no HTML, no CSS cascade. If you are an AI assistant, read this
5
5
  before writing or editing code here.
6
6
 
7
+ ## Levels: core, and frameworks on top
8
+
9
+ - @solidrt/core is the low-level foundation: host intrinsics (`<window>`,
10
+ `<view>`, `<text>`, the detached `d-*` drawing primitives) with flat props
11
+ that feed the layout and paint engine directly. An app can be written
12
+ entirely at this level.
13
+ - Higher-level component frameworks build on core. @solidrt/components is
14
+ the first-party one: themed widgets (Window, View, Text, Button,
15
+ ScrollView, SafeArea, ...) with the `layout={{...}}`/`style={{...}}` prop
16
+ split. It is not privileged - a framework is just functions returning core
17
+ JSX, and an app can use a third-party one or grow its own.
18
+
19
+ Match the level the code you are editing already uses. package.json shows
20
+ the choice this app made: if no component framework is among the
21
+ dependencies, the app is core-only - do not add one for a change core
22
+ covers.
23
+
7
24
  Authoritative references ship inside the installed packages - read them:
8
25
  - node_modules/solid-js/CHEATSHEET.md - SolidJS 2.0 reactivity/control-flow model
9
26
  - node_modules/@solidrt/components/AGENTS.md - the component vocabulary; build UI from these
@@ -24,10 +41,10 @@ Authoritative references ship inside the installed packages - read them:
24
41
 
25
42
  1. This is SolidJS 2.0 (see CHEATSHEET.md for the reactivity/control-flow
26
43
  model), rendering through a custom Rust runtime instead of the DOM. Build
27
- UI from @solidrt/components (Window, View, Text, Image, TextInput,
28
- ScrollView, Pressable, Button, SafeArea, theme/setTheme) - it is the
29
- higher-level, batteries-included vocabulary and is where most app code
30
- should live.
44
+ UI at the level the app uses (see "Levels" above): core intrinsics
45
+ directly, or a component framework such as @solidrt/components (Window,
46
+ View, Text, Image, TextInput, ScrollView, Pressable, Button, SafeArea,
47
+ theme/setTheme) - the first-party, batteries-included one.
31
48
  2. Most components split their props into two objects: `layout={{...}}` for
32
49
  anything that feeds the layout engine (flex/grid, sizing, padding/margin,
33
50
  position; font fields for Text) and `style={{...}}` for paint-only
@@ -52,7 +69,8 @@ Authoritative references ship inside the installed packages - read them:
52
69
  (runtime-paced, auto-cleans), re-exported from @solidrt/core.
53
70
  requestAnimationFrame(t => {}) exists as a web-standard one-shot but is
54
71
  not the preferred animation driver.
55
- 8. Reach for @solidrt/core directly only for what components doesn't wrap:
72
+ 8. In a components-based app, reach for @solidrt/core directly only for
73
+ what components doesn't wrap:
56
74
  raw host intrinsics and the `d-` (detached, non-layout) primitives like
57
75
  `d-rect`/`d-path`/`d-oval` for vector art or perf-sensitive positioned
58
76
  drawing, device/GPU subpath imports (@solidrt/core/camera, /microphone,
@@ -76,11 +94,37 @@ Authoritative references ship inside the installed packages - read them:
76
94
  `compute` is the tracked read phase; `apply(value, prev)` runs untracked
77
95
  and is where side effects/DOM-equivalent writes belong. The old
78
96
  single-arg `createEffect(fn)` form is gone - using it is an error.
97
+ 13. A scroll container (ScrollView, or anything on createScroll) needs an
98
+ explicit main-axis size - a height, or flex inside a sized parent. With
99
+ neither it resolves to 0 and its content silently vanishes; maxHeight
100
+ alone does not size it (the auto size it would clamp is already 0). The
101
+ runtime warns when this happens.
102
+ 14. Text `lineHeight` is a MULTIPLIER of fontSize (the theme uses 1.3-1.6),
103
+ not pixels. A CSS-reflex value like 22 makes each line box 22x the font
104
+ size: the text becomes blank space and the parent balloons.
105
+ 15. Signal writes flush on a microtask: a handler that sets a signal and
106
+ immediately reads it back gets the OLD value. Read the new value in an
107
+ effect, or call `flush()` (from @solidjs/signals) to force it through.
108
+ 16. Portals cannot mount during the app's initial render: a Modal (or any
109
+ createPortal content) that is visible at first mount throws "no mount
110
+ target". Gate it behind a signal that starts false and open it after
111
+ startup - overlay content is opened, not born open.
112
+ 17. An element-valued prop (children, a content/icon slot) compiles to a
113
+ getter that builds a fresh native subtree on EVERY read, and a subtree
114
+ that is never inserted is never freed - native nodes are not garbage
115
+ collected, so what is only wasted work in DOM Solid is a permanent
116
+ memory leak here. Read such props exactly once, at the place they are
117
+ mounted. To inspect children (a typeof probe, counting), resolve them
118
+ first with the children() helper (re-exported from @solidrt/core) and
119
+ probe the resolved memo - never `typeof props.children` on the raw prop.
79
120
 
80
121
  ## Run / verify
81
122
 
82
123
  - bunx srt run src/index.tsx - dev server + window (needs a display)
83
- - bunx srt bundle src/index.tsx - exit 0 means it compiles
124
+ - bunx srt check src/index.tsx - exit 0 means it compiles and the app's
125
+ types hold (dependency-internal type errors are hidden). Builds in memory:
126
+ writes nothing and never triggers a dev-server reload, so use this while
127
+ iterating - `srt bundle` writes output files and reloads connected clients
84
128
  - bunx srt render src/index.tsx --size 480x640 --duration 1 --fps 2 - headless
85
129
  render to PNG frames (proves it renders; see the cli AGENTS.md for where the
86
130
  frames land)
@@ -93,12 +137,25 @@ its tools over guessing at runtime state:
93
137
 
94
138
  - list_clients: connected app clients, their platform and runtime capabilities
95
139
  - get_logs: console output and runtime errors (seq cursor; `wait_ms` long-poll
96
- to catch output right after a reload)
140
+ to catch output right after a reload; `level`/`contains` filters; repeated
141
+ lines collapse into one entry with a `repeats` count)
97
142
  - get_render_tree: what the app actually rendered - node kinds, text, and
98
- window-relative boxes
99
- - get_stats: fps, CPU/memory, frame phase timings, setProperty rate
143
+ window-relative boxes. Whole trees get large: `query` finds nodes by
144
+ kind/text, then `root` + `depth` inspect just that region
145
+ - client ids and log cursors die with the dev server: list_clients and
146
+ get_logs responses carry `generation`, and a changed generation means
147
+ re-fetch ids and restart cursors
148
+ - get_stats: fps, CPU/memory, frame phase timings, setProperty rate, plus
149
+ layout-activity counters for the last rebuild (nodes, measureCalls,
150
+ paraShapes, dirtiedNodes, cacheGets/cacheHits) - when layoutMs looks
151
+ wrong, these say whether the cost is text shaping, invalidation breadth,
152
+ or a defeated layout cache (healthy incremental rebuilds show a near-100%
153
+ cacheHits rate)
100
154
  - get_snapshot: PNG capture of any render-tree node's pixels (get node ids
101
- from get_render_tree; the window node captures everything)
155
+ from get_render_tree; the window node captures everything). Pass `save_to`
156
+ on get_snapshot or get_texture to also write the PNG to a file - the image
157
+ in the tool result cannot be saved afterwards, so decide before capturing
158
+ (e.g. keep a before/after pair to diff)
102
159
  - get_gpu_resources: inventory of GPU state - textures (size, render target
103
160
  or not), vertex buffers (byteLength), pipelines (draw count, attribute
104
161
  layout, bound textures, last-applied uniform values)
@@ -111,11 +168,39 @@ its tools over guessing at runtime state:
111
168
  from pixels
112
169
  - reload: rebuild from source and push to every client - THE dev loop is
113
170
  edit -> reload -> get_logs -> get_snapshot. reload surfaces build errors
114
- but not type errors; run the typecheck separately.
171
+ but not type errors; run `bunx srt check` for those.
172
+ - load: bundle a given source file and push it to every client, replacing
173
+ the running app; later reloads rebuild that entry. Use it when the dev
174
+ server has no app loaded yet, or to switch apps without restarting srt.
175
+ - watch: pause (enabled: false) or resume the automatic reload-on-save.
176
+ Pause BEFORE creating or editing source files so half-finished work is
177
+ not pushed to the user's screens mid-burst; a successful reload or load
178
+ resumes it, so pause again before the next burst. Never leave it paused
179
+ when you stop working - the user's own saves rely on it.
115
180
 
116
181
  The tools need a running app: if list_clients is empty, ask the user to start
117
182
  `bunx srt run src/index.tsx`.
118
183
 
184
+ - Permission prompts: agents typically ask approval per MCP tool. All of
185
+ these tools only talk to the local dev server the user started with
186
+ `bunx srt run` - nothing leaves the machine - so approving the server as
187
+ a whole is a reasonable default. If repeated prompts get in the way, do
188
+ not work around them; tell the user they can pre-approve the server in
189
+ their agent's settings (most agents have a per-server trust or allowlist
190
+ setting - in Claude Code, add "mcp__solidrt" to `permissions.allow` in
191
+ ~/.claude/settings.json to cover every solidrt project). This is the
192
+ user's call to make, once, in their own tooling.
193
+ - Multiple clients: several clients may be attached (desktop window,
194
+ phone, tablet) with different sizes, display scales, and safe areas.
195
+ reload pushes to all of them, but call_debug / get_snapshot / log
196
+ cursors are per client, and interactive state does NOT sync - a flow
197
+ driven on one client leaves the others sitting on the initial screen,
198
+ which reads as a crash to a human holding that device. So: when driving
199
+ state via call_debug, send the same call to every client (or say which
200
+ client you are using); and before calling a visual change done,
201
+ snapshot each distinct form factor at least once - a layout that fits
202
+ one window can clip or overflow another.
203
+
119
204
  ## Debugging a running app (lessons that cost real time)
120
205
 
121
206
  - console.log + get_logs is your primary probe into runtime state. For state
@@ -125,10 +210,10 @@ The tools need a running app: if list_clients is empty, ask the user to start
125
210
  setFocus(node.id) from the window's ref or onKeyDown never fires. This
126
211
  runtime names arrow keys "Left"/"Right"/"Up"/"Down", not "ArrowLeft".
127
212
  - 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.
213
+ change, so measure performance while uniforms are actually changing.
214
+ get_snapshot works on an idle client (it requests its own frame); a
215
+ timeout means the JS thread is busy or wedged. get_texture on a pipeline's
216
+ render target reads the last-drawn frame without needing a new one.
132
217
  - When a human reports a visual bug: capture a snapshot and SAY WHAT YOU SEE
133
218
  in it before investigating, so you agree on the symptom. If you cannot see
134
219
  the problem in the capture, say that instead of guessing.
@@ -160,3 +245,23 @@ The tools need a running app: if list_clients is empty, ask the user to start
160
245
  a second one - self-running animation (game clocks, shader-driven
161
246
  effects) must make one state change at startup to prime the loop; after
162
247
  that its own writes keep it awake.
248
+ - Layout is incremental: a change re-solves only the dirty path, and clean
249
+ subtrees answer from a per-node cache, so long lists no longer cap layout
250
+ (a thousand-node tree relays out in well under a millisecond). If layoutMs
251
+ still grows with tree size, read the get_stats counters - a low
252
+ cacheHits/cacheGets ratio means the layout cache is being defeated, high
253
+ paraShapes means text is actually reshaping. Very long lists still pay
254
+ for the initial mount and for memory, so windowing stays sensible at the
255
+ thousands-of-rows scale.
256
+ - Remote images: createImage (and Image) dedupes repeated URLs, caches the
257
+ bytes on disk, and the runtime rate-limits concurrent asset fetches per
258
+ host - do not build your own promise cache around it. Images are fetched
259
+ with no freshness check (an already-cached URL is never re-checked), so
260
+ use versioned URLs for content that changes. Use Image's `fallback` prop
261
+ (an image source) for the broken-image case instead of catching errors
262
+ yourself.
263
+ - fetch() never caches by default and ignores server cache headers. Caching
264
+ is explicit and per call: `fetch(url, { cache: "force-cache" })` for
265
+ assets (serve from disk or fetch-and-store, no freshness),
266
+ `{ cache: "reload" }` to refresh an entry. Image/createImage already do
267
+ this for you.
@@ -9,12 +9,12 @@
9
9
  "android": "srt client --android"
10
10
  },
11
11
  "dependencies": {
12
- "@solidrt/core": "0.0.27",
13
- "@solidrt/components": "0.0.27"
12
+ "@solidrt/core": "0.0.29",
13
+ "@solidrt/components": "0.0.29"
14
14
  },
15
15
  "devDependencies": {
16
- "@solidrt/cli": "0.0.27",
17
- "@solidrt/flux-types": "0.0.27",
16
+ "@solidrt/cli": "0.0.29",
17
+ "@solidrt/flux-types": "0.0.29",
18
18
  "typescript": "^7"
19
19
  }
20
20
  }
@@ -0,0 +1,15 @@
1
+ // Components template: built with the @solidrt/components framework.
2
+ import { render } from "@solidrt/core"
3
+ import { Window, SafeArea, Text } from "@solidrt/components"
4
+
5
+ function App() {
6
+ return (
7
+ <Window>
8
+ <SafeArea>
9
+ <Text>Hello, World!</Text>
10
+ </SafeArea>
11
+ </Window>
12
+ )
13
+ }
14
+
15
+ render(() => <App />)
@@ -0,0 +1,4 @@
1
+ {
2
+ "level": "components",
3
+ "description": "components, blank start"
4
+ }
@@ -1,5 +1,5 @@
1
+ // Core-only template: built from @solidrt/core primitives.
1
2
  import { render, For, onFrame, createSignal, createLinearGradient } from "@solidrt/core"
2
- import { View, Window } from "@solidrt/components"
3
3
 
4
4
  const FADE = 360
5
5
 
@@ -13,7 +13,7 @@ let SEGMENTS = [
13
13
  { base: 540, light: "#7ea9ea", dark: "#5681c1", d: "M100.000 50.000 L75.000 75.000 L75.000 65.830 C73.810 65.830 72.711 65.195 72.116 64.165 C71.521 63.135 71.521 61.865 72.116 60.835 C72.711 59.805 73.810 59.170 75.000 59.170 L75.000 50.000 L75.000 40.830 C73.810 40.830 72.711 40.195 72.116 39.165 C71.521 38.135 71.521 36.865 72.116 35.835 C72.711 34.805 73.810 34.170 75.000 34.170 L75.000 25.000 L100.000 50.000 Z" },
14
14
  ]
15
15
 
16
- let LAST = SEGMENTS[SEGMENTS.length - 1].base
16
+ let LAST = SEGMENTS[SEGMENTS.length - 1]!.base
17
17
  let IN_DONE = LAST + FADE
18
18
  let CYCLE = IN_DONE + LAST + FADE
19
19
 
@@ -32,7 +32,7 @@ function Icon() {
32
32
  let start = -1
33
33
  onFrame((t) => (start < 0 && (start = t), setClock((t - start) % CYCLE)))
34
34
  return (
35
- <View layout={{ width: 100, height: 100 }} style={{ scale: 3 }}>
35
+ <view width={100} height={100} scale={3}>
36
36
  <For each={SEGMENTS}>
37
37
  {(seg) => {
38
38
  let a = () => {
@@ -43,7 +43,7 @@ function Icon() {
43
43
  return <d-path d={seg.d} color={fill(seg, a())} />
44
44
  }}
45
45
  </For>
46
- </View>
46
+ </view>
47
47
  )
48
48
  }
49
49
 
@@ -54,12 +54,10 @@ function App() {
54
54
  ])
55
55
 
56
56
  return (
57
- <Window
58
- layout={{ alignItems: "center", justifyContent: "center" }}
59
- style={{ backgroundColor }}
60
- >
57
+ <window alignItems="center" justifyContent="center">
58
+ <d-rect color={backgroundColor} />
61
59
  <Icon />
62
- </Window>
60
+ </window>
63
61
  )
64
62
  }
65
63
 
@@ -0,0 +1,4 @@
1
+ {
2
+ "level": "core",
3
+ "description": "core, animated logo"
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "level": "components",
3
+ "description": "components, widget tour"
4
+ }
@@ -1,9 +1,12 @@
1
- import { render } from "@solidrt/core"
1
+ // Core-only template: built from @solidrt/core primitives.
2
+ import { render, safeArea } from "@solidrt/core"
2
3
 
3
4
  function App() {
4
5
  return (
5
6
  <window>
6
- <text>Hello, World!</text>
7
+ <view flex={1} paddingTop={safeArea().top} paddingBottom={safeArea().bottom}>
8
+ <text>Hello, World!</text>
9
+ </view>
7
10
  </window>
8
11
  )
9
12
  }
@@ -0,0 +1,4 @@
1
+ {
2
+ "level": "core",
3
+ "description": "core, blank start"
4
+ }
@@ -6,6 +6,7 @@
6
6
  "jsxImportSource": "@solidrt/core",
7
7
  "moduleResolution": "bundler",
8
8
  "strict": true,
9
+ "skipLibCheck": true,
9
10
  "types": ["@solidrt/flux-types"]
10
11
  },
11
12
  "include": ["src"]
package/server/control.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { file } from "flux:fs"
1
2
  import { state } from "./state"
2
3
  import { rebuildAndBroadcast } from "./rebuild"
3
4
  import { remapPositions } from "./remap"
@@ -75,7 +76,19 @@ function findClient(param: string | undefined): { ws: ServerWebSocket } | { erro
75
76
  }
76
77
  let id = parseInt(param, 10)
77
78
  let entry = entries.find(([, info]) => info.id === id)
78
- if (!entry) return { error: Response.json({ error: `No client with id ${param}` }, { status: 404 }) }
79
+ if (!entry) {
80
+ let ids = entries.map(([, info]) => info.id)
81
+ return {
82
+ error: Response.json(
83
+ {
84
+ error:
85
+ `Client ${param} is gone (connected ids: ${ids.length ? ids.join(", ") : "none"}). ` +
86
+ "Ids reset when the dev server restarts; call list_clients for current ones.",
87
+ },
88
+ { status: 404 },
89
+ ),
90
+ }
91
+ }
79
92
  return { ws: entry[0] }
80
93
  }
81
94
 
@@ -89,42 +102,91 @@ async function handleQuery(query: Map<string, string>, kind: string, extra?: Rec
89
102
  target.ws.send(JSON.stringify({ type: "query", kind, id, ...extra }))
90
103
  let msg = await Promise.race([reply, sleep(QUERY_TIMEOUT_MS)])
91
104
  pendingQueries.delete(id)
92
- if (!msg) return Response.json({ error: "Query timed out" }, { status: 504 })
105
+ if (!msg)
106
+ return Response.json(
107
+ { error: "Query timed out: the client is connected but did not answer (JS thread busy or app wedged?)" },
108
+ { status: 504 },
109
+ )
93
110
  // Error strings may carry stack traces (e.g. a debug command threw); remap
94
111
  // bundle positions to .tsx sources like appendLog does for forwarded logs.
95
112
  if (msg.error) return Response.json({ error: remapPositions(String(msg.error), state.currentMap) }, { status: 502 })
96
113
  return Response.json(msg.data)
97
114
  }
98
115
 
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.
116
+ // Merge runs of consecutive identical entries (same client, level, text) into
117
+ // one entry carrying `repeats` and the run's last seq/at, so 176 copies of one
118
+ // error read as a single line and a `since` cursor still skips the whole run.
119
+ function collapseRepeats(entries: LogEntry[]): (LogEntry & { repeats?: number })[] {
120
+ let out: (LogEntry & { repeats?: number })[] = []
121
+ for (let e of entries) {
122
+ let last = out[out.length - 1]
123
+ if (last && last.client === e.client && last.level === e.level && last.text === e.text) {
124
+ last.repeats = (last.repeats ?? 1) + 1
125
+ last.seq = e.seq
126
+ last.at = e.at
127
+ } else {
128
+ out.push({ ...e })
129
+ }
130
+ }
131
+ return out
132
+ }
133
+
134
+ // GET /__control__/logs?since=N&wait=MS&level=L1,L2&contains=TEXT: entries with
135
+ // seq > since, plus the latest seq as the next cursor and the server
136
+ // generation. `level` keeps only the listed levels; `contains` keeps entries
137
+ // whose text has the substring (case-insensitive). Consecutive identical
138
+ // entries come back collapsed with a `repeats` count. With `wait`, holds the
139
+ // response until an entry passes the filters or the timeout expires
140
+ // (long-poll), so a caller can follow the stream without tight polling.
103
141
  async function handleLogs(query: Map<string, string>): Promise<Response> {
104
142
  let since = parseInt(query.get("since") ?? "0", 10) || 0
105
143
  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) {
144
+ let levels = query
145
+ .get("level")
146
+ ?.split(",")
147
+ .map((l) => l.trim())
148
+ .filter(Boolean)
149
+ let contains = query.get("contains")?.toLowerCase()
150
+ let select = () =>
151
+ logs.filter(
152
+ (e) =>
153
+ e.seq > since &&
154
+ (!levels || levels.length === 0 || levels.includes(e.level)) &&
155
+ (!contains || e.text.toLowerCase().includes(contains)),
156
+ )
157
+ let entries = select()
158
+ // Filtered long-poll: an append may not pass the filters, so keep waiting
159
+ // until one does or the deadline runs out.
160
+ let deadline = Date.now() + wait
161
+ while (entries.length === 0 && Date.now() < deadline) {
108
162
  await new Promise<void>((resolve) => {
109
- let timer = setTimeout(resolve, wait)
163
+ let timer = setTimeout(resolve, deadline - Date.now())
110
164
  logWaiters.push(() => {
111
165
  clearTimeout(timer)
112
166
  resolve()
113
167
  })
114
168
  })
115
- entries = logs.filter((e) => e.seq > since)
169
+ entries = select()
116
170
  }
117
- return Response.json({ entries, latest: logSeq })
171
+ return Response.json({ entries: collapseRepeats(entries), latest: logSeq, generation: state.generation })
118
172
  }
119
173
 
120
174
  export async function handleControl(req: Request, path: string, query: Map<string, string>): Promise<Response> {
121
175
  switch (path) {
122
176
  case "/__control__/clients":
123
- return Response.json(clientList())
177
+ return Response.json({ generation: state.generation, clients: clientList() })
124
178
  case "/__control__/logs":
125
179
  return handleLogs(query)
126
- case "/__control__/tree":
127
- return handleQuery(query, "tree")
180
+ case "/__control__/tree": {
181
+ let extra: Record<string, unknown> = {}
182
+ let root = parseInt(query.get("root") ?? "", 10)
183
+ if (Number.isFinite(root)) extra.root = root
184
+ let depth = parseInt(query.get("depth") ?? "", 10)
185
+ if (Number.isFinite(depth)) extra.depth = depth
186
+ let q = query.get("query")
187
+ if (q) extra.query = q
188
+ return handleQuery(query, "tree", extra)
189
+ }
128
190
  case "/__control__/stats":
129
191
  return handleQuery(query, "stats")
130
192
  case "/__control__/snapshot": {
@@ -179,8 +241,45 @@ export async function handleControl(req: Request, path: string, query: Map<strin
179
241
  if (req.method !== "POST") return Response.json({ error: "Reload requires POST" }, { status: 405 })
180
242
  let error = await rebuildAndBroadcast()
181
243
  if (error) return Response.json({ error }, { status: 502 })
244
+ state.watch = true
182
245
  return Response.json({ ok: true, clients: state.clients.size })
183
246
  }
247
+ case "/__control__/load": {
248
+ // Load (or switch) the app entry and push it: srt mcp's load tool.
249
+ // Moves the rebuild entry and the file-serving root like the repl's
250
+ // `load` command, then reuses the reload path, so a later /reload
251
+ // rebuilds the newly loaded file. The srt process is not told: a
252
+ // watcher started on the launch-time source keeps watching that file.
253
+ if (req.method !== "POST") return Response.json({ error: "Load requires POST" }, { status: 405 })
254
+ let entry = (await req.json().catch(() => null))?.entry
255
+ if (typeof entry !== "string" || !entry) {
256
+ return Response.json({ error: "Load requires { entry: <absolute source path> }" }, { status: 400 })
257
+ }
258
+ if (!(await file(entry).exists())) {
259
+ return Response.json({ error: `Entry not found: ${entry}` }, { status: 400 })
260
+ }
261
+ state.config.entry = entry
262
+ let cut = Math.max(entry.lastIndexOf("/"), entry.lastIndexOf("\\"))
263
+ if (cut > 0) state.sourceDir = entry.slice(0, cut)
264
+ let error = await rebuildAndBroadcast()
265
+ if (error) return Response.json({ error }, { status: 502 })
266
+ state.watch = true
267
+ return Response.json({ ok: true, entry, clients: state.clients.size })
268
+ }
269
+ case "/__control__/watch": {
270
+ // Pause/resume srt's auto-reload-on-save: the MCP watch tool. Latched
271
+ // here because the watcher lives in the srt process; it reads the flag
272
+ // via /__internal__/watch before acting on a change event. An agent
273
+ // pauses while creating or editing files so half-finished work is not
274
+ // pushed; a successful /reload or /load turns it back on.
275
+ if (req.method !== "POST") return Response.json({ error: "Watch requires POST" }, { status: 405 })
276
+ let enabled = (await req.json().catch(() => null))?.enabled
277
+ if (typeof enabled !== "boolean") {
278
+ return Response.json({ error: "Watch requires { enabled: <boolean> }" }, { status: 400 })
279
+ }
280
+ state.watch = enabled
281
+ return Response.json({ ok: true, enabled })
282
+ }
184
283
  default:
185
284
  return Response.json({ error: "Unknown control endpoint" }, { status: 404 })
186
285
  }
package/server/main.ts CHANGED
@@ -69,6 +69,7 @@ async function handleInternal(req: FluxRequest, server: Server, path: string): P
69
69
  if (!loopback) return new Response("Forbidden", { status: 403 })
70
70
 
71
71
  if (path === "/__internal__/clients") return Response.json(clientList(true))
72
+ if (path === "/__internal__/watch" && req.method === "GET") return Response.json({ enabled: state.watch })
72
73
  if (req.method !== "POST") return new Response("Method not allowed", { status: 405 })
73
74
 
74
75
  switch (path) {
@@ -101,6 +102,12 @@ async function handleInternal(req: FluxRequest, server: Server, path: string): P
101
102
  sendTo(body.clients, JSON.stringify({ type: "stop" }))
102
103
  return new Response("", { status: 204 })
103
104
  }
105
+ case "/__internal__/watch": {
106
+ // The repl's `watch on|off`; agents use /__control__/watch instead.
107
+ let body = await req.json()
108
+ state.watch = !!body.enabled
109
+ return new Response("", { status: 204 })
110
+ }
104
111
  case "/__internal__/stats": {
105
112
  let body = await req.json()
106
113
  state.stats = !!body.stats
package/server/state.ts CHANGED
@@ -37,6 +37,13 @@ export let state = {
37
37
  config: undefined as unknown as Config,
38
38
  clients: new Map<ServerWebSocket, ClientInfo>(),
39
39
  nextClientId: 0,
40
+ /**
41
+ * Identity of this server run, included in control responses that carry
42
+ * cross-call state (client ids, log seq cursors). Both reset on restart, so
43
+ * a consumer that sees the generation change knows its ids and cursors are
44
+ * from a dead server and must be re-fetched.
45
+ */
46
+ generation: Date.now(),
40
47
  /**
41
48
  * The latched reload message (JSON text), replayed to late-joining clients.
42
49
  * Set by /__internal__/reload posts with `latch`, cleared by a broadcast stop.
@@ -52,6 +59,13 @@ export let state = {
52
59
  sourceDir: "",
53
60
  serverUrl: "",
54
61
  stats: false,
62
+ /**
63
+ * Whether srt's file watcher may auto-reload on source changes. Agents
64
+ * pause it (MCP watch tool -> /__control__/watch) while creating or
65
+ * editing files; a successful /reload or /load re-enables it. srt reads
66
+ * it via /__internal__/watch before acting on a change event.
67
+ */
68
+ watch: true,
55
69
  // Capture events from all connected clients share one clock (captureStartMs,
56
70
  // integer milliseconds) so they merge into one coherent timeline, tagged by
57
71
  // `device`. Streamed to disk as JSON Lines - see main.ts's "capture" handling.
package/src/args.ts CHANGED
@@ -46,6 +46,9 @@ export function validateArgs() {
46
46
  usage("srt bundle [options] <entry.[tsx|jsx|ts|js|srt.js|srt.bin]>")
47
47
  }
48
48
  break
49
+ case "check":
50
+ if (!source || !isSource) usage("srt check <entry.[tsx|jsx|ts|js]>")
51
+ break
49
52
  case "render":
50
53
  if (!source || !isTsx) usage("srt render <entry.[tsx|jsx]>")
51
54
  break
@@ -74,6 +77,7 @@ Commands:
74
77
  server [file] Start dev server only
75
78
  client Start solidrt-go client only
76
79
  bundle <file> Transpile TS/JS/TSX/JSX to JS or bytecode
80
+ check <file> Verify the app builds and typechecks, without writing anything
77
81
  render <file.tsx|jsx> Replay a script (optional) and render frames for video generation
78
82
  pack <file> Bundle + compile to a standalone executable (experimental)
79
83
  mcp MCP server (stdio) exposing the running dev server to coding agents
package/src/artifacts.ts CHANGED
@@ -7,12 +7,14 @@ let require = createRequire(import.meta.url)
7
7
 
8
8
  let TRIPLE_MAP: Record<string, string> = {
9
9
  "linux-x64": "linux-x64-gnu",
10
+ "linux-arm64": "linux-arm64-gnu",
10
11
  "darwin-arm64": "darwin-arm64",
11
12
  "win32-x64": "win32-x64-msvc",
12
13
  }
13
14
 
14
15
  let PKG_MAP: Record<string, string> = {
15
16
  "linux-x64": "@solidrt/linux-x64-gnu",
17
+ "linux-arm64": "@solidrt/linux-arm64-gnu",
16
18
  "darwin-arm64": "@solidrt/darwin-arm64",
17
19
  "win32-x64": "@solidrt/win32-x64-msvc",
18
20
  }
package/src/bundler.ts CHANGED
@@ -103,8 +103,10 @@ export type BundleResult = {
103
103
  // spawns. It never touches the ambient args/state singletons and never prints
104
104
  // progress (callers own that), so its stdout stays clean for subprocess use.
105
105
  export async function bundleWith(opts: BundleOptions): Promise<BundleResult | null> {
106
+ // Define values are parsed as expressions, so string values need embedded
107
+ // quotes - a bare word substitutes as an identifier and crashes at runtime.
106
108
  let define: Record<string, string> = {
107
- "process.env.NODE_ENV": opts.dev ? "development" : "production",
109
+ "process.env.NODE_ENV": opts.dev ? '"development"' : '"production"',
108
110
  }
109
111
  if (opts.devBase) define.__SRT_DEV_BASE__ = opts.devBase
110
112