@solidrt/cli 0.0.26 → 0.0.28
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 +10 -7
- package/scaffold/AGENTS.md +138 -9
- package/scaffold/package.json +4 -4
- package/scaffold/templates/components/index.tsx +15 -0
- package/scaffold/templates/components/template.json +4 -0
- package/scaffold/templates/default/index.tsx +7 -9
- package/scaffold/templates/default/template.json +4 -0
- package/scaffold/templates/gallery/template.json +4 -0
- package/scaffold/templates/minimal/index.tsx +5 -2
- package/scaffold/templates/minimal/template.json +4 -0
- package/scaffold/tsconfig.json +1 -0
- package/server/control.ts +132 -16
- package/server/main.ts +13 -4
- package/server/rebuild.ts +54 -0
- package/server/remap.ts +47 -0
- package/server/state.ts +24 -1
- package/src/args.ts +4 -0
- package/src/bundle-cli.ts +12 -0
- package/src/bundler.ts +69 -23
- package/src/commands/bundle.ts +4 -7
- package/src/commands/check.ts +94 -0
- package/src/commands/init.ts +53 -14
- package/src/commands/mcp.ts +205 -66
- package/src/commands/server.ts +4 -3
- package/src/dev-server.ts +15 -2
- package/src/main.ts +3 -0
- package/src/prompt.ts +19 -11
- package/src/repl.ts +17 -7
- package/src/util.ts +3 -0
- package/src/watcher.ts +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.28",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Antoine van Wel",
|
|
6
6
|
"type": "module",
|
|
@@ -18,22 +18,25 @@
|
|
|
18
18
|
"@babel/core": "^7.0.0",
|
|
19
19
|
"@babel/plugin-syntax-jsx": "^7.0.0",
|
|
20
20
|
"@babel/preset-typescript": "^7.0.0",
|
|
21
|
+
"@jridgewell/remapping": "^2.3.0",
|
|
22
|
+
"@jridgewell/trace-mapping": "^0.3.25",
|
|
21
23
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
22
24
|
"babel-preset-solid": "2.0.0-beta.17",
|
|
23
25
|
"bonjour-service": "^1.4.0",
|
|
24
|
-
"qrcode-generator": "^2.0.4"
|
|
26
|
+
"qrcode-generator": "^2.0.4",
|
|
27
|
+
"zod": "^4.4.3"
|
|
25
28
|
},
|
|
26
29
|
"optionalDependencies": {
|
|
27
|
-
"@solidrt/darwin-arm64": "0.0.
|
|
28
|
-
"@solidrt/linux-x64-gnu": "0.0.
|
|
29
|
-
"@solidrt/win32-x64-msvc": "0.0.
|
|
30
|
+
"@solidrt/darwin-arm64": "0.0.28",
|
|
31
|
+
"@solidrt/linux-x64-gnu": "0.0.28",
|
|
32
|
+
"@solidrt/win32-x64-msvc": "0.0.28"
|
|
30
33
|
},
|
|
31
34
|
"peerDependencies": {
|
|
32
|
-
"@solidrt/core": "0.0.
|
|
35
|
+
"@solidrt/core": "0.0.28",
|
|
33
36
|
"typescript": "^7"
|
|
34
37
|
},
|
|
35
38
|
"devDependencies": {
|
|
36
|
-
"@solidrt/flux-types": "0.0.
|
|
39
|
+
"@solidrt/flux-types": "0.0.28",
|
|
37
40
|
"@types/bun": "latest"
|
|
38
41
|
}
|
|
39
42
|
}
|
package/scaffold/AGENTS.md
CHANGED
|
@@ -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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
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,29 @@ 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.
|
|
79
112
|
|
|
80
113
|
## Run / verify
|
|
81
114
|
|
|
82
115
|
- bunx srt run src/index.tsx - dev server + window (needs a display)
|
|
83
|
-
- bunx srt
|
|
116
|
+
- bunx srt check src/index.tsx - exit 0 means it compiles and the app's
|
|
117
|
+
types hold (dependency-internal type errors are hidden). Builds in memory:
|
|
118
|
+
writes nothing and never triggers a dev-server reload, so use this while
|
|
119
|
+
iterating - `srt bundle` writes output files and reloads connected clients
|
|
84
120
|
- bunx srt render src/index.tsx --size 480x640 --duration 1 --fps 2 - headless
|
|
85
121
|
render to PNG frames (proves it renders; see the cli AGENTS.md for where the
|
|
86
122
|
frames land)
|
|
@@ -93,10 +129,103 @@ its tools over guessing at runtime state:
|
|
|
93
129
|
|
|
94
130
|
- list_clients: connected app clients, their platform and runtime capabilities
|
|
95
131
|
- get_logs: console output and runtime errors (seq cursor; `wait_ms` long-poll
|
|
96
|
-
to catch output right after a reload
|
|
132
|
+
to catch output right after a reload; `level`/`contains` filters; repeated
|
|
133
|
+
lines collapse into one entry with a `repeats` count)
|
|
97
134
|
- get_render_tree: what the app actually rendered - node kinds, text, and
|
|
98
|
-
window-relative boxes
|
|
99
|
-
|
|
135
|
+
window-relative boxes. Whole trees get large: `query` finds nodes by
|
|
136
|
+
kind/text, then `root` + `depth` inspect just that region
|
|
137
|
+
- client ids and log cursors die with the dev server: list_clients and
|
|
138
|
+
get_logs responses carry `generation`, and a changed generation means
|
|
139
|
+
re-fetch ids and restart cursors
|
|
140
|
+
- get_stats: fps, CPU/memory, frame phase timings, setProperty rate, plus
|
|
141
|
+
layout-activity counters for the last rebuild (nodes, measureCalls,
|
|
142
|
+
paraShapes, dirtiedNodes, cacheGets/cacheHits) - when layoutMs looks
|
|
143
|
+
wrong, these say whether the cost is text shaping, invalidation breadth,
|
|
144
|
+
or a defeated layout cache (healthy incremental rebuilds show a near-100%
|
|
145
|
+
cacheHits rate)
|
|
146
|
+
- get_snapshot: PNG capture of any render-tree node's pixels (get node ids
|
|
147
|
+
from get_render_tree; the window node captures everything). Pass `save_to`
|
|
148
|
+
on get_snapshot or get_texture to also write the PNG to a file - the image
|
|
149
|
+
in the tool result cannot be saved afterwards, so decide before capturing
|
|
150
|
+
(e.g. keep a before/after pair to diff)
|
|
151
|
+
- get_gpu_resources: inventory of GPU state - textures (size, render target
|
|
152
|
+
or not), vertex buffers (byteLength), pipelines (draw count, attribute
|
|
153
|
+
layout, bound textures, last-applied uniform values)
|
|
154
|
+
- get_texture: any GPU texture read back as a PNG by id - atlases, data
|
|
155
|
+
textures, and shader/pipeline render targets alike (a render target is
|
|
156
|
+
"what this pipeline last drew", no frame or snapshot needed); crop with
|
|
157
|
+
x/y/width/height
|
|
158
|
+
- get_buffer: a vertex-buffer range decoded to numbers (f32/u16/u8, 64 KiB
|
|
159
|
+
per call) - verify geometry after a writeBuffer instead of inferring it
|
|
160
|
+
from pixels
|
|
161
|
+
- reload: rebuild from source and push to every client - THE dev loop is
|
|
162
|
+
edit -> reload -> get_logs -> get_snapshot. reload surfaces build errors
|
|
163
|
+
but not type errors; run `bunx srt check` for those.
|
|
100
164
|
|
|
101
165
|
The tools need a running app: if list_clients is empty, ask the user to start
|
|
102
166
|
`bunx srt run src/index.tsx`.
|
|
167
|
+
|
|
168
|
+
## Debugging a running app (lessons that cost real time)
|
|
169
|
+
|
|
170
|
+
- console.log + get_logs is your primary probe into runtime state. For state
|
|
171
|
+
you will want repeatedly (a pose, a mode, a counter), bind a debug key that
|
|
172
|
+
logs it and read it back via get_logs.
|
|
173
|
+
- Key events are delivered ONLY to the focused node (no bubbling): call
|
|
174
|
+
setFocus(node.id) from the window's ref or onKeyDown never fires. This
|
|
175
|
+
runtime names arrow keys "Left"/"Right"/"Up"/"Down", not "ArrowLeft".
|
|
176
|
+
- Idle frames skip work: shaders/pipelines only re-render when their params
|
|
177
|
+
change, so measure performance while uniforms are actually changing.
|
|
178
|
+
get_snapshot works on an idle client (it requests its own frame); a
|
|
179
|
+
timeout means the JS thread is busy or wedged. get_texture on a pipeline's
|
|
180
|
+
render target reads the last-drawn frame without needing a new one.
|
|
181
|
+
- When a human reports a visual bug: capture a snapshot and SAY WHAT YOU SEE
|
|
182
|
+
in it before investigating, so you agree on the symptom. If you cannot see
|
|
183
|
+
the problem in the capture, say that instead of guessing.
|
|
184
|
+
- GPU/geometry bugs: inspect the actual GPU data FIRST - get_gpu_resources
|
|
185
|
+
for draw counts/uniforms/sizes, get_texture for atlas or data-texture
|
|
186
|
+
contents ("is this tile blank?" is a ten-second question), get_buffer for
|
|
187
|
+
vertex data. The pixels only tell you THAT something is wrong; the
|
|
188
|
+
resources tell you WHERE the data stops being right. In a one-big-pipeline
|
|
189
|
+
app the render tree is a single <texture> leaf and tells you nothing -
|
|
190
|
+
these tools are the visibility layer behind it. Only when the GPU data is
|
|
191
|
+
all correct (so the bug is in producing it, or in the shader), reproduce
|
|
192
|
+
the math CPU-side in a scratch bun script against the app's real data and
|
|
193
|
+
print values.
|
|
194
|
+
- Validate assets at load time and log anomalies (missing lumps/files,
|
|
195
|
+
fully-transparent composites, zero-sized images). Silent fallbacks hide
|
|
196
|
+
bugs for days; a one-line warning surfaces them the first run.
|
|
197
|
+
- After every reload the app restarts from its initial state. If reaching
|
|
198
|
+
the bug site takes navigation, add a dev shortcut (teleport key, noclip,
|
|
199
|
+
initial-state override) before iterating - the round trips add up fast.
|
|
200
|
+
- Clamp onFrame time deltas to [0, cap], not just capped: across a hot
|
|
201
|
+
reload the runtime's tick counter resets AFTER the new instance's first
|
|
202
|
+
frame, so the second frame computes a hugely NEGATIVE delta.
|
|
203
|
+
Math.min(dt, cap) lets it through, and one bad frame can corrupt anything
|
|
204
|
+
integrated from dt (positions fly off, accumulators go so negative they
|
|
205
|
+
never recover). Math.max(0, Math.min(dt, cap)) costs nothing.
|
|
206
|
+
- Frames are demand-gated: JS frame callbacks only run when the previous
|
|
207
|
+
frame changed something (input, signal write, GPU upload). An app whose
|
|
208
|
+
onFrame returns early without side effects on its first frame never gets
|
|
209
|
+
a second one - self-running animation (game clocks, shader-driven
|
|
210
|
+
effects) must make one state change at startup to prime the loop; after
|
|
211
|
+
that its own writes keep it awake.
|
|
212
|
+
- Layout is incremental: a change re-solves only the dirty path, and clean
|
|
213
|
+
subtrees answer from a per-node cache, so long lists no longer cap layout
|
|
214
|
+
(a thousand-node tree relays out in well under a millisecond). If layoutMs
|
|
215
|
+
still grows with tree size, read the get_stats counters - a low
|
|
216
|
+
cacheHits/cacheGets ratio means the layout cache is being defeated, high
|
|
217
|
+
paraShapes means text is actually reshaping. Very long lists still pay
|
|
218
|
+
for the initial mount and for memory, so windowing stays sensible at the
|
|
219
|
+
thousands-of-rows scale.
|
|
220
|
+
- Remote images: createImage (and Image) dedupes repeated URLs, caches the
|
|
221
|
+
bytes on disk, and the runtime rate-limits concurrent asset fetches per
|
|
222
|
+
host - do not build your own promise cache around it. Images are fetched
|
|
223
|
+
with no freshness check (an already-cached URL is never re-checked), so
|
|
224
|
+
use versioned URLs for content that changes. Use Image's `fallback` prop
|
|
225
|
+
(an image source) for the broken-image case instead of catching errors
|
|
226
|
+
yourself.
|
|
227
|
+
- fetch() never caches by default and ignores server cache headers. Caching
|
|
228
|
+
is explicit and per call: `fetch(url, { cache: "force-cache" })` for
|
|
229
|
+
assets (serve from disk or fetch-and-store, no freshness),
|
|
230
|
+
`{ cache: "reload" }` to refresh an entry. Image/createImage already do
|
|
231
|
+
this for you.
|
package/scaffold/package.json
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
"android": "srt client --android"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@solidrt/core": "0.0.
|
|
13
|
-
"@solidrt/components": "0.0.
|
|
12
|
+
"@solidrt/core": "0.0.28",
|
|
13
|
+
"@solidrt/components": "0.0.28"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@solidrt/cli": "0.0.
|
|
17
|
-
"@solidrt/flux-types": "0.0.
|
|
16
|
+
"@solidrt/cli": "0.0.28",
|
|
17
|
+
"@solidrt/flux-types": "0.0.28",
|
|
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 />)
|
|
@@ -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]
|
|
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
|
-
<
|
|
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
|
-
</
|
|
46
|
+
</view>
|
|
47
47
|
)
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -54,12 +54,10 @@ function App() {
|
|
|
54
54
|
])
|
|
55
55
|
|
|
56
56
|
return (
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
style={{ backgroundColor }}
|
|
60
|
-
>
|
|
57
|
+
<window alignItems="center" justifyContent="center">
|
|
58
|
+
<d-rect color={backgroundColor} />
|
|
61
59
|
<Icon />
|
|
62
|
-
</
|
|
60
|
+
</window>
|
|
63
61
|
)
|
|
64
62
|
}
|
|
65
63
|
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
|
|
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
|
-
<
|
|
7
|
+
<view flex={1} paddingTop={safeArea().top} paddingBottom={safeArea().bottom}>
|
|
8
|
+
<text>Hello, World!</text>
|
|
9
|
+
</view>
|
|
7
10
|
</window>
|
|
8
11
|
)
|
|
9
12
|
}
|
package/scaffold/tsconfig.json
CHANGED
package/server/control.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { state } from "./state"
|
|
2
|
+
import { rebuildAndBroadcast } from "./rebuild"
|
|
3
|
+
import { remapPositions } from "./remap"
|
|
2
4
|
import type { ServerWebSocket } from "flux:http"
|
|
3
5
|
|
|
4
6
|
// The control API under /__control__/: read-only introspection of connected
|
|
@@ -29,8 +31,9 @@ function sleep(ms: number): Promise<void> {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/// A `log` message arrived from a client: buffer it and wake long-polls.
|
|
34
|
+
/// Bundle positions in stack traces are remapped to .tsx sources on the way in.
|
|
32
35
|
export function appendLog(client: number, level: string, text: string) {
|
|
33
|
-
logs.push({ seq: ++logSeq, at: Date.now(), client, level, text })
|
|
36
|
+
logs.push({ seq: ++logSeq, at: Date.now(), client, level, text: remapPositions(text, state.currentMap) })
|
|
34
37
|
if (logs.length > LOG_CAP) logs.splice(0, logs.length - LOG_CAP)
|
|
35
38
|
let waiters = logWaiters
|
|
36
39
|
logWaiters = []
|
|
@@ -55,6 +58,7 @@ export function clientList(withAddress = false) {
|
|
|
55
58
|
id: info.id,
|
|
56
59
|
platform: info.platform,
|
|
57
60
|
version: info.version,
|
|
61
|
+
profile: info.profile,
|
|
58
62
|
capabilities: info.capabilities,
|
|
59
63
|
...(withAddress ? { address: ws.remoteAddress ?? null } : {}),
|
|
60
64
|
}))
|
|
@@ -71,7 +75,19 @@ function findClient(param: string | undefined): { ws: ServerWebSocket } | { erro
|
|
|
71
75
|
}
|
|
72
76
|
let id = parseInt(param, 10)
|
|
73
77
|
let entry = entries.find(([, info]) => info.id === id)
|
|
74
|
-
if (!entry)
|
|
78
|
+
if (!entry) {
|
|
79
|
+
let ids = entries.map(([, info]) => info.id)
|
|
80
|
+
return {
|
|
81
|
+
error: Response.json(
|
|
82
|
+
{
|
|
83
|
+
error:
|
|
84
|
+
`Client ${param} is gone (connected ids: ${ids.length ? ids.join(", ") : "none"}). ` +
|
|
85
|
+
"Ids reset when the dev server restarts; call list_clients for current ones.",
|
|
86
|
+
},
|
|
87
|
+
{ status: 404 },
|
|
88
|
+
),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
75
91
|
return { ws: entry[0] }
|
|
76
92
|
}
|
|
77
93
|
|
|
@@ -85,40 +101,91 @@ async function handleQuery(query: Map<string, string>, kind: string, extra?: Rec
|
|
|
85
101
|
target.ws.send(JSON.stringify({ type: "query", kind, id, ...extra }))
|
|
86
102
|
let msg = await Promise.race([reply, sleep(QUERY_TIMEOUT_MS)])
|
|
87
103
|
pendingQueries.delete(id)
|
|
88
|
-
if (!msg)
|
|
89
|
-
|
|
104
|
+
if (!msg)
|
|
105
|
+
return Response.json(
|
|
106
|
+
{ error: "Query timed out: the client is connected but did not answer (JS thread busy or app wedged?)" },
|
|
107
|
+
{ status: 504 },
|
|
108
|
+
)
|
|
109
|
+
// Error strings may carry stack traces (e.g. a debug command threw); remap
|
|
110
|
+
// bundle positions to .tsx sources like appendLog does for forwarded logs.
|
|
111
|
+
if (msg.error) return Response.json({ error: remapPositions(String(msg.error), state.currentMap) }, { status: 502 })
|
|
90
112
|
return Response.json(msg.data)
|
|
91
113
|
}
|
|
92
114
|
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
115
|
+
// Merge runs of consecutive identical entries (same client, level, text) into
|
|
116
|
+
// one entry carrying `repeats` and the run's last seq/at, so 176 copies of one
|
|
117
|
+
// error read as a single line and a `since` cursor still skips the whole run.
|
|
118
|
+
function collapseRepeats(entries: LogEntry[]): (LogEntry & { repeats?: number })[] {
|
|
119
|
+
let out: (LogEntry & { repeats?: number })[] = []
|
|
120
|
+
for (let e of entries) {
|
|
121
|
+
let last = out[out.length - 1]
|
|
122
|
+
if (last && last.client === e.client && last.level === e.level && last.text === e.text) {
|
|
123
|
+
last.repeats = (last.repeats ?? 1) + 1
|
|
124
|
+
last.seq = e.seq
|
|
125
|
+
last.at = e.at
|
|
126
|
+
} else {
|
|
127
|
+
out.push({ ...e })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// GET /__control__/logs?since=N&wait=MS&level=L1,L2&contains=TEXT: entries with
|
|
134
|
+
// seq > since, plus the latest seq as the next cursor and the server
|
|
135
|
+
// generation. `level` keeps only the listed levels; `contains` keeps entries
|
|
136
|
+
// whose text has the substring (case-insensitive). Consecutive identical
|
|
137
|
+
// entries come back collapsed with a `repeats` count. With `wait`, holds the
|
|
138
|
+
// response until an entry passes the filters or the timeout expires
|
|
139
|
+
// (long-poll), so a caller can follow the stream without tight polling.
|
|
97
140
|
async function handleLogs(query: Map<string, string>): Promise<Response> {
|
|
98
141
|
let since = parseInt(query.get("since") ?? "0", 10) || 0
|
|
99
142
|
let wait = Math.min(parseInt(query.get("wait") ?? "0", 10) || 0, MAX_WAIT_MS)
|
|
100
|
-
let
|
|
101
|
-
|
|
143
|
+
let levels = query
|
|
144
|
+
.get("level")
|
|
145
|
+
?.split(",")
|
|
146
|
+
.map((l) => l.trim())
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
let contains = query.get("contains")?.toLowerCase()
|
|
149
|
+
let select = () =>
|
|
150
|
+
logs.filter(
|
|
151
|
+
(e) =>
|
|
152
|
+
e.seq > since &&
|
|
153
|
+
(!levels || levels.length === 0 || levels.includes(e.level)) &&
|
|
154
|
+
(!contains || e.text.toLowerCase().includes(contains)),
|
|
155
|
+
)
|
|
156
|
+
let entries = select()
|
|
157
|
+
// Filtered long-poll: an append may not pass the filters, so keep waiting
|
|
158
|
+
// until one does or the deadline runs out.
|
|
159
|
+
let deadline = Date.now() + wait
|
|
160
|
+
while (entries.length === 0 && Date.now() < deadline) {
|
|
102
161
|
await new Promise<void>((resolve) => {
|
|
103
|
-
let timer = setTimeout(resolve,
|
|
162
|
+
let timer = setTimeout(resolve, deadline - Date.now())
|
|
104
163
|
logWaiters.push(() => {
|
|
105
164
|
clearTimeout(timer)
|
|
106
165
|
resolve()
|
|
107
166
|
})
|
|
108
167
|
})
|
|
109
|
-
entries =
|
|
168
|
+
entries = select()
|
|
110
169
|
}
|
|
111
|
-
return Response.json({ entries, latest: logSeq })
|
|
170
|
+
return Response.json({ entries: collapseRepeats(entries), latest: logSeq, generation: state.generation })
|
|
112
171
|
}
|
|
113
172
|
|
|
114
173
|
export async function handleControl(req: Request, path: string, query: Map<string, string>): Promise<Response> {
|
|
115
174
|
switch (path) {
|
|
116
175
|
case "/__control__/clients":
|
|
117
|
-
return Response.json(clientList())
|
|
176
|
+
return Response.json({ generation: state.generation, clients: clientList() })
|
|
118
177
|
case "/__control__/logs":
|
|
119
178
|
return handleLogs(query)
|
|
120
|
-
case "/__control__/tree":
|
|
121
|
-
|
|
179
|
+
case "/__control__/tree": {
|
|
180
|
+
let extra: Record<string, unknown> = {}
|
|
181
|
+
let root = parseInt(query.get("root") ?? "", 10)
|
|
182
|
+
if (Number.isFinite(root)) extra.root = root
|
|
183
|
+
let depth = parseInt(query.get("depth") ?? "", 10)
|
|
184
|
+
if (Number.isFinite(depth)) extra.depth = depth
|
|
185
|
+
let q = query.get("query")
|
|
186
|
+
if (q) extra.query = q
|
|
187
|
+
return handleQuery(query, "tree", extra)
|
|
188
|
+
}
|
|
122
189
|
case "/__control__/stats":
|
|
123
190
|
return handleQuery(query, "stats")
|
|
124
191
|
case "/__control__/snapshot": {
|
|
@@ -126,6 +193,55 @@ export async function handleControl(req: Request, path: string, query: Map<strin
|
|
|
126
193
|
if (!Number.isFinite(nodeId)) return Response.json({ error: "Snapshot requires ?node=<id>" }, { status: 400 })
|
|
127
194
|
return handleQuery(query, "snapshot", { nodeId })
|
|
128
195
|
}
|
|
196
|
+
case "/__control__/gpu":
|
|
197
|
+
return handleQuery(query, "gpu")
|
|
198
|
+
case "/__control__/debug": {
|
|
199
|
+
// GET lists the app's registered debug commands; POST calls one, with
|
|
200
|
+
// an optional JSON body as its args.
|
|
201
|
+
if (req.method !== "POST") return handleQuery(query, "debug_list")
|
|
202
|
+
let name = query.get("name")
|
|
203
|
+
if (!name) return Response.json({ error: "Debug call requires ?name=<command>" }, { status: 400 })
|
|
204
|
+
let args: unknown = null
|
|
205
|
+
try {
|
|
206
|
+
args = await req.json()
|
|
207
|
+
} catch {}
|
|
208
|
+
return handleQuery(query, "debug_call", { name, args })
|
|
209
|
+
}
|
|
210
|
+
case "/__control__/texture": {
|
|
211
|
+
let textureId = parseInt(query.get("id") ?? "", 10)
|
|
212
|
+
if (!Number.isFinite(textureId)) return Response.json({ error: "Texture requires ?id=<textureId>" }, { status: 400 })
|
|
213
|
+
// Optional crop: all four of x/y/width/height, in texture pixels.
|
|
214
|
+
let rectParams = ["x", "y", "width", "height"].map((k) => query.get(k))
|
|
215
|
+
let extra: Record<string, unknown> = { textureId }
|
|
216
|
+
if (rectParams.some((v) => v !== undefined)) {
|
|
217
|
+
let [x, y, width, height] = rectParams.map((v) => parseInt(v ?? "", 10))
|
|
218
|
+
if (![x, y, width, height].every(Number.isFinite))
|
|
219
|
+
return Response.json({ error: "Texture rect requires all of x, y, width, height" }, { status: 400 })
|
|
220
|
+
extra.rect = { x, y, width, height }
|
|
221
|
+
}
|
|
222
|
+
return handleQuery(query, "texture", extra)
|
|
223
|
+
}
|
|
224
|
+
case "/__control__/buffer": {
|
|
225
|
+
let bufferId = parseInt(query.get("id") ?? "", 10)
|
|
226
|
+
if (!Number.isFinite(bufferId)) return Response.json({ error: "Buffer requires ?id=<bufferId>" }, { status: 400 })
|
|
227
|
+
let extra: Record<string, unknown> = { bufferId }
|
|
228
|
+
let byteOffset = parseInt(query.get("offset") ?? "", 10)
|
|
229
|
+
if (Number.isFinite(byteOffset)) extra.byteOffset = byteOffset
|
|
230
|
+
let length = parseInt(query.get("length") ?? "", 10)
|
|
231
|
+
if (Number.isFinite(length)) extra.length = length
|
|
232
|
+
let as = query.get("as")
|
|
233
|
+
if (as !== undefined) extra.as = as
|
|
234
|
+
return handleQuery(query, "buffer", extra)
|
|
235
|
+
}
|
|
236
|
+
case "/__control__/reload": {
|
|
237
|
+
// Explicit rebuild-and-push, the primary way a coding agent applies its
|
|
238
|
+
// edits (srt mcp's reload tool). Unlike the repl's file watcher this is
|
|
239
|
+
// on demand, so a burst of edits collapses into one reload.
|
|
240
|
+
if (req.method !== "POST") return Response.json({ error: "Reload requires POST" }, { status: 405 })
|
|
241
|
+
let error = await rebuildAndBroadcast()
|
|
242
|
+
if (error) return Response.json({ error }, { status: 502 })
|
|
243
|
+
return Response.json({ ok: true, clients: state.clients.size })
|
|
244
|
+
}
|
|
129
245
|
default:
|
|
130
246
|
return Response.json({ error: "Unknown control endpoint" }, { status: 404 })
|
|
131
247
|
}
|
package/server/main.ts
CHANGED
|
@@ -73,13 +73,18 @@ async function handleInternal(req: FluxRequest, server: Server, path: string): P
|
|
|
73
73
|
|
|
74
74
|
switch (path) {
|
|
75
75
|
case "/__internal__/reload": {
|
|
76
|
-
// { message, clients?, latch?, sourceDir? }: send `message` (a full
|
|
76
|
+
// { message, clients?, latch?, sourceDir?, map? }: send `message` (a full
|
|
77
77
|
// client-protocol message, built by srt) to the listed client ids, or to
|
|
78
78
|
// all when omitted. `latch` keeps it for late-joining clients (code
|
|
79
79
|
// reloads latch, one-shot bytecode loads do not); `sourceDir` moves the
|
|
80
|
-
// file-serving root (repl `load`)
|
|
80
|
+
// file-serving root (repl `load`); `map` is the bundle's sourcemap for
|
|
81
|
+
// log remapping, replaced on every reload (absent means none).
|
|
81
82
|
let body = await req.json()
|
|
82
83
|
if (typeof body.sourceDir === "string") state.sourceDir = body.sourceDir
|
|
84
|
+
// Keep the rebuild entry in sync when `load` moves it, so a later MCP
|
|
85
|
+
// reload bundles the newly loaded file, not the launch-time one.
|
|
86
|
+
if (typeof body.entry === "string") state.config.entry = body.entry
|
|
87
|
+
state.currentMap = typeof body.map === "string" ? body.map : null
|
|
83
88
|
let text = JSON.stringify(body.message)
|
|
84
89
|
if (body.latch) state.currentReload = text
|
|
85
90
|
sendTo(body.clients, text)
|
|
@@ -89,7 +94,10 @@ async function handleInternal(req: FluxRequest, server: Server, path: string): P
|
|
|
89
94
|
let body = await req.json()
|
|
90
95
|
// A broadcast stop also forgets the latched reload, so a client that
|
|
91
96
|
// connects afterwards starts clean.
|
|
92
|
-
if (!body.clients)
|
|
97
|
+
if (!body.clients) {
|
|
98
|
+
state.currentReload = null
|
|
99
|
+
state.currentMap = null
|
|
100
|
+
}
|
|
93
101
|
sendTo(body.clients, JSON.stringify({ type: "stop" }))
|
|
94
102
|
return new Response("", { status: 204 })
|
|
95
103
|
}
|
|
@@ -215,7 +223,7 @@ serve({
|
|
|
215
223
|
websocket: {
|
|
216
224
|
open(ws) {
|
|
217
225
|
let id = state.nextClientId++
|
|
218
|
-
state.clients.set(ws, { platform: "unknown", version: "unknown", id, capabilities: [] })
|
|
226
|
+
state.clients.set(ws, { platform: "unknown", version: "unknown", profile: "unknown", id, capabilities: [] })
|
|
219
227
|
console.log(`[cli] Client connected ${ws.remoteAddress ?? "unknown"}`)
|
|
220
228
|
// Advertise our real LAN address so clients dialed over a loopback hop
|
|
221
229
|
// can show/remember the directly reachable address (see connection.rs).
|
|
@@ -239,6 +247,7 @@ serve({
|
|
|
239
247
|
state.clients.set(ws, {
|
|
240
248
|
platform: data.platform ?? "unknown",
|
|
241
249
|
version: data.version ?? "unknown",
|
|
250
|
+
profile: data.profile ?? "unknown",
|
|
242
251
|
id: existing?.id ?? state.nextClientId++,
|
|
243
252
|
capabilities: Array.isArray(data.capabilities) ? data.capabilities.map(String) : [],
|
|
244
253
|
})
|