@solidrt/cli 0.0.38 → 0.0.40
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 +8 -8
- package/scaffold/AGENTS.md +95 -19
- package/scaffold/icon.svg +11 -0
- package/scaffold/package.json +4 -4
- package/src/args.ts +11 -0
- package/src/bundler.ts +28 -20
- package/src/commands/check.ts +13 -9
- package/src/commands/init.ts +4 -2
- package/src/commands/mcp.ts +8 -5
- package/src/dev-server.ts +32 -1
- package/src/pack-folder.ts +2 -1
- package/src/project.ts +41 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.40",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Antoine van Wel",
|
|
6
6
|
"type": "module",
|
|
@@ -22,23 +22,23 @@
|
|
|
22
22
|
"@jridgewell/remapping": "^2.3.0",
|
|
23
23
|
"@jridgewell/trace-mapping": "^0.3.25",
|
|
24
24
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
25
|
-
"babel-preset-solid": "2.0.0-beta.
|
|
25
|
+
"babel-preset-solid": "2.0.0-beta.26",
|
|
26
26
|
"bonjour-service": "^1.4.0",
|
|
27
27
|
"qrcode-generator": "^2.0.4",
|
|
28
28
|
"zod": "^4.4.3"
|
|
29
29
|
},
|
|
30
30
|
"optionalDependencies": {
|
|
31
|
-
"@solidrt/darwin-arm64": "0.0.
|
|
32
|
-
"@solidrt/linux-arm64-gnu": "0.0.
|
|
33
|
-
"@solidrt/linux-x64-gnu": "0.0.
|
|
34
|
-
"@solidrt/win32-x64-msvc": "0.0.
|
|
31
|
+
"@solidrt/darwin-arm64": "0.0.40",
|
|
32
|
+
"@solidrt/linux-arm64-gnu": "0.0.40",
|
|
33
|
+
"@solidrt/linux-x64-gnu": "0.0.40",
|
|
34
|
+
"@solidrt/win32-x64-msvc": "0.0.40"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@solidrt/core": "0.0.
|
|
37
|
+
"@solidrt/core": "0.0.40",
|
|
38
38
|
"typescript": "^7"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@solidrt/flux-types": "0.0.
|
|
41
|
+
"@solidrt/flux-types": "0.0.40",
|
|
42
42
|
"@types/babel__core": "^7.20.5",
|
|
43
43
|
"@types/bun": "latest"
|
|
44
44
|
}
|
package/scaffold/AGENTS.md
CHANGED
|
@@ -117,7 +117,15 @@ Authoritative references ship inside the installed packages - read them:
|
|
|
117
117
|
mounted. To inspect children (a typeof probe, counting), resolve them
|
|
118
118
|
first with the children() helper (re-exported from @solidrt/core) and
|
|
119
119
|
probe the resolved memo - never `typeof props.children` on the raw prop.
|
|
120
|
-
18.
|
|
120
|
+
18. Writing a signal or store from inside an owned scope - a component body, a
|
|
121
|
+
`createMemo`, an effect's compute phase - throws
|
|
122
|
+
`REACTIVE_WRITE_IN_OWNED_SCOPE` in dev. Calling a loader/init function in
|
|
123
|
+
the component body that sets state is the classic React / Solid 1.x
|
|
124
|
+
reflex and hits this every time. Move the write into an event handler, an
|
|
125
|
+
effect's apply phase, or `onSettled`; opt in narrowly with
|
|
126
|
+
`createSignal(v, { ownedWrite: true })` for a signal that genuinely is
|
|
127
|
+
internal state.
|
|
128
|
+
19. Cover/contain images: give `Image` a `fit` prop ("fill" | "cover" |
|
|
121
129
|
"contain" | "none" | "scale-down", CSS object-fit semantics, centered)
|
|
122
130
|
plus a box via `layout` in any form - numbers, pct(), flex. Without
|
|
123
131
|
`fit`, only NUMERIC layout sizes reach the image; `width: pct(100)`
|
|
@@ -128,14 +136,34 @@ Authoritative references ship inside the installed packages - read them:
|
|
|
128
136
|
|
|
129
137
|
The JS engine is interpreted and every property write crosses an FFI boundary
|
|
130
138
|
into the runtime, so per-frame JS work is the expensive path while GPU work is
|
|
131
|
-
nearly free.
|
|
139
|
+
nearly free. That holds on desktop and on current mobile hardware; "Where GPU
|
|
140
|
+
work stops being free" below is where it does not. Rules, in order of leverage:
|
|
132
141
|
|
|
133
142
|
1. Continuous effects (snow, particles, animated backgrounds) belong in a
|
|
134
143
|
fragment shader: createShader (from @solidrt/core/gpu) + `<texture
|
|
135
144
|
params={{ iTime }}>`. The whole effect then costs one setProperty per
|
|
136
145
|
frame - the iTime write - regardless of visual complexity. Shader output
|
|
137
146
|
must be premultiplied alpha (white flakes are `vec4(vec3(a), a)`);
|
|
138
|
-
straight alpha (`vec4(1,1,1,a)`) composites as opaque white.
|
|
147
|
+
straight alpha (`vec4(1,1,1,a)`) composites as opaque white. A source that
|
|
148
|
+
starts with `#version 300 es` is compiled exactly as written - no preamble
|
|
149
|
+
is injected, though the built-in vertex stage still supplies `vUV` - so a
|
|
150
|
+
shader ported from elsewhere keeps its own uniform names without dropping
|
|
151
|
+
to compileShader/linkProgram. Params drive any uniform type: a number
|
|
152
|
+
fills a `float`/`int` scalar, a flat number array fills `vec2`/`vec3`/
|
|
153
|
+
`vec4` (2/3/4 numbers) or `mat4` (16, column-major), dispatched by the
|
|
154
|
+
shader's own declaration - a ported shader's `vec2 uCenter` or Shadertoy's
|
|
155
|
+
`vec3 iResolution` needs no splitting into scalars. To combine several
|
|
156
|
+
GPU passes, stack `<texture>` elements and set `blendMode` (e.g. a base
|
|
157
|
+
pass plus an additive `blendMode="plus"` pass) rather than writing a
|
|
158
|
+
compositing shader. Within one pipeline draw, createPipeline's
|
|
159
|
+
`blend: "add"` accumulates overlapping geometry additively (soft point
|
|
160
|
+
splats, glow) - pair it with `depthWrite: false` when depth-tested;
|
|
161
|
+
neither option implies the other. Sampling is a create-time option on
|
|
162
|
+
every texture: `{ filter: "nearest" }` for hard-pixel upscaling (render a
|
|
163
|
+
small target, display it big - the retro/pixel-art path) and
|
|
164
|
+
`{ wrap: "repeat" }` to tile outside 0..1 in shaders; the defaults are
|
|
165
|
+
linear and clamp, and the choice applies both on screen and to shaders
|
|
166
|
+
sampling the texture.
|
|
139
167
|
2. Reduce setProperty calls wherever possible: one path string rebuilt per
|
|
140
168
|
frame beats N elements with N animated positions; a shader beats the path
|
|
141
169
|
string. get_stats' setPropsPerFrame is the counter to watch.
|
|
@@ -159,6 +187,44 @@ nearly free. Rules, in order of leverage:
|
|
|
159
187
|
creating many at once (dealing a board of 64 sprites) is a visible
|
|
160
188
|
one-frame hiccup - pool or pre-warm if that moment matters.
|
|
161
189
|
|
|
190
|
+
### Where GPU work stops being free
|
|
191
|
+
|
|
192
|
+
"GPU work is nearly free" is a property of the hardware, not of the engine, and
|
|
193
|
+
the spread is wide enough to design against rather than discover late. The same
|
|
194
|
+
app - two point-cloud pipelines, 233,600 vertices, one params write each per
|
|
195
|
+
onFrame, i.e. exactly what rule 1 recommends - measured 16.7 ms/frame (60 fps,
|
|
196
|
+
vsync-locked) on both desktop and a mid-range 2020 tablet, and 120 ms/frame
|
|
197
|
+
(8.3 fps) on a 2017 Android TV. Roughly 8x for identical work, with the tablet
|
|
198
|
+
indistinguishable from desktop. Measure on a target device if it matters; do
|
|
199
|
+
not infer it from the desktop number.
|
|
200
|
+
|
|
201
|
+
- **On a tiled GPU the budget is primitive count, not pixels.** Every point or
|
|
202
|
+
triangle costs the tiler regardless of how few pixels it covers. On that TV,
|
|
203
|
+
frame time against total vertices with a trivial vertex shader: 20k -> 80 ms,
|
|
204
|
+
35k -> 100 ms, 100k -> 380 ms. Meanwhile `gl_PointSize = 3.0` - nine times
|
|
205
|
+
the fill - measured within one vsync of 1.0, and rendering into a
|
|
206
|
+
quarter-size target measured identical to full size. So for a heavy pass the
|
|
207
|
+
lever is fewer primitives; shrinking the target or the splat usually is not,
|
|
208
|
+
and coverage is far cheaper bought with point size than with more points.
|
|
209
|
+
- **A device's compositor can set the frame budget outright**, in which case
|
|
210
|
+
none of the above moves. That TV never presents faster than every 80 ms -
|
|
211
|
+
four refresh periods blocked inside `eglSwapBuffers` - even for a near-empty
|
|
212
|
+
scene, so its ceiling is ~12 fps whatever you draw. Recognise it by a
|
|
213
|
+
content-independent floor: if a trivial scene and a heavy one present at
|
|
214
|
+
nearly the same rate, you are compositor-bound and tuning the scene is
|
|
215
|
+
wasted effort.
|
|
216
|
+
- **Per-frame writes are gated on the raster thread, not on vsync**, so a pass
|
|
217
|
+
that costs more than a refresh period does not silently pile up. If
|
|
218
|
+
`rasterQueue` sits persistently above 0 the raster thread is behind; if
|
|
219
|
+
`fenceTimeouts` climbs, the GPU is over its pacing budget.
|
|
220
|
+
|
|
221
|
+
Finding your own numbers: `get_stats` gives fps, frameMs, setPropsPerFrame,
|
|
222
|
+
rasterQueue and fenceTimeouts. When those disagree with what the screen is
|
|
223
|
+
visibly doing, ground truth on Android is
|
|
224
|
+
`adb shell dumpsys SurfaceFlinger --latency <layer>` for real present
|
|
225
|
+
timestamps - engine-reported phase timings can each be honest and still not add
|
|
226
|
+
up to the frame period, because work outside the frame call is not in them.
|
|
227
|
+
|
|
162
228
|
## Assets and app identity
|
|
163
229
|
|
|
164
230
|
- Everything under `assets/` ships with the app: the folder is collected
|
|
@@ -167,10 +233,13 @@ nearly free. Rules, in order of leverage:
|
|
|
167
233
|
from `flux:fs` - and treat them as read-only at runtime; writes belong in
|
|
168
234
|
plain relative paths, which land in the app's private data dir.
|
|
169
235
|
- Small text-like assets (SVG documents, shaders) can instead be inlined via
|
|
170
|
-
imports
|
|
171
|
-
`with { type: "
|
|
172
|
-
|
|
173
|
-
|
|
236
|
+
imports. An import attribute picks the form and works on any extension:
|
|
237
|
+
`import src from "./effect.glsl" with { type: "text" }` yields the file's
|
|
238
|
+
contents as a string, `with { type: "binary" }` yields a Uint8Array. `.svg`
|
|
239
|
+
is text-loaded with no attribute needed. Shader sources (`.glsl`/`.vert`/
|
|
240
|
+
`.frag`) are declared as text modules out of the box, so they typecheck
|
|
241
|
+
without setup. Inlining trades update granularity for zero I/O - keep big or
|
|
242
|
+
streamable files (audio, images) in `assets/`.
|
|
174
243
|
- Custom fonts go in `assets/fonts/` and are declared in the `solidrt.fonts`
|
|
175
244
|
map in package.json (alias -> file path; role aliases `sans`/`serif`/`mono`
|
|
176
245
|
replace the built-in defaults, `false` drops one, other keys add fonts
|
|
@@ -225,11 +294,12 @@ its tools over guessing at runtime state:
|
|
|
225
294
|
(e.g. keep a before/after pair to diff)
|
|
226
295
|
- get_gpu_resources: inventory of GPU state - textures (size, render target
|
|
227
296
|
or not), vertex buffers (byteLength), pipelines (draw count, attribute
|
|
228
|
-
layout, bound textures,
|
|
297
|
+
layout, bound textures, current uniform values - the most recent writes,
|
|
298
|
+
which the next frame or readback draws with)
|
|
229
299
|
- get_texture: any GPU texture read back as a PNG by id - atlases, data
|
|
230
|
-
textures, and shader/pipeline render targets alike (a render target
|
|
231
|
-
|
|
232
|
-
x/y/width/height
|
|
300
|
+
textures, and shader/pipeline render targets alike (a render target reads
|
|
301
|
+
as its current output, pending writes included, with no frame or snapshot
|
|
302
|
+
needed); crop with x/y/width/height
|
|
233
303
|
- get_buffer: a vertex-buffer range decoded to numbers (f32/u16/u8, 64 KiB
|
|
234
304
|
per call) - verify geometry after a writeBuffer instead of inferring it
|
|
235
305
|
from pixels
|
|
@@ -246,7 +316,9 @@ its tools over guessing at runtime state:
|
|
|
246
316
|
when you stop working - the user's own saves rely on it.
|
|
247
317
|
|
|
248
318
|
The tools need a running app: if list_clients is empty, ask the user to start
|
|
249
|
-
`bunx srt run src/index.tsx`.
|
|
319
|
+
`bunx srt run src/index.tsx`. The bridge dials the dev server's default port
|
|
320
|
+
(34884), so if the user started it with `--port N`, .mcp.json needs the same
|
|
321
|
+
flag: `"args": [..., "mcp", "--port", "N"]`.
|
|
250
322
|
|
|
251
323
|
- Permission prompts: agents typically ask approval per MCP tool. All of
|
|
252
324
|
these tools only talk to the local dev server the user started with
|
|
@@ -274,13 +346,17 @@ The tools need a running app: if list_clients is empty, ask the user to start
|
|
|
274
346
|
you will want repeatedly (a pose, a mode, a counter), bind a debug key that
|
|
275
347
|
logs it and read it back via get_logs.
|
|
276
348
|
- Key events are delivered ONLY to the focused node (no bubbling): call
|
|
277
|
-
setFocus(node.id) from the window's ref or onKeyDown never fires.
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
349
|
+
setFocus(node.id) from the window's ref or onKeyDown never fires. `key` and
|
|
350
|
+
`code` are W3C KeyboardEvent values, so arrow keys arrive as "ArrowLeft"/
|
|
351
|
+
"ArrowRight"/"ArrowUp"/"ArrowDown" (not "Left"), alongside "Enter",
|
|
352
|
+
"Escape", "a".
|
|
353
|
+
- Idle frames skip work: shaders/pipelines only re-render when an input
|
|
354
|
+
changes - their own params/geometry, or a sampled texture (a data upload,
|
|
355
|
+
or a sampled target re-rendering; chains propagate automatically). Measure
|
|
356
|
+
performance while inputs are actually changing. get_snapshot works on an
|
|
357
|
+
idle client (it requests its own frame); a timeout means the JS thread is
|
|
358
|
+
busy or wedged. get_texture on a pipeline's render target reads the
|
|
359
|
+
current output, pending writes included, without needing a new frame.
|
|
284
360
|
- When a human reports a visual bug: capture a snapshot and SAY WHAT YOU SEE
|
|
285
361
|
in it before investigating, so you agree on the symptom. If you cannot see
|
|
286
362
|
the problem in the capture, say that instead of guessing.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#5b7cfa"/>
|
|
5
|
+
<stop offset="1" stop-color="#8a5cf6"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect width="96" height="96" rx="22" fill="url(#bg)"/>
|
|
9
|
+
<circle cx="48" cy="48" r="21" fill="none" stroke="#ffffff" stroke-opacity="0.9" stroke-width="6"/>
|
|
10
|
+
<circle cx="48" cy="48" r="7" fill="#ffffff"/>
|
|
11
|
+
</svg>
|
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.40",
|
|
13
|
+
"@solidrt/components": "0.0.40"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@solidrt/cli": "0.0.
|
|
17
|
-
"@solidrt/flux-types": "0.0.
|
|
16
|
+
"@solidrt/cli": "0.0.40",
|
|
17
|
+
"@solidrt/flux-types": "0.0.40",
|
|
18
18
|
"typescript": "^7"
|
|
19
19
|
}
|
|
20
20
|
}
|
package/src/args.ts
CHANGED
|
@@ -21,6 +21,7 @@ export let { values, positionals } = parseArgs({
|
|
|
21
21
|
"data-root": { type: "string" },
|
|
22
22
|
client: { type: "string" },
|
|
23
23
|
server: { type: "string" },
|
|
24
|
+
port: { type: "string" },
|
|
24
25
|
android: { type: "boolean", default: false },
|
|
25
26
|
device: { type: "string" },
|
|
26
27
|
template: { type: "string", short: "t" },
|
|
@@ -95,6 +96,12 @@ export function validateArgs() {
|
|
|
95
96
|
if (values.server && command !== "client") {
|
|
96
97
|
usage("srt client --server <host[:port]> (--server is only valid with the client command)")
|
|
97
98
|
}
|
|
99
|
+
// --port moves the dev server off its default port, so it belongs to the
|
|
100
|
+
// commands that start one (`run`, `server`) or attach to one (`mcp`). A
|
|
101
|
+
// standalone client carries the port in --server <host:port> instead.
|
|
102
|
+
if (values.port !== undefined && command !== "run" && command !== "server" && command !== "mcp") {
|
|
103
|
+
usage("srt <run|server|mcp> --port <N> (--port is only valid with the run, server and mcp commands)")
|
|
104
|
+
}
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
export function printUsage() {
|
|
@@ -115,6 +122,7 @@ init options:
|
|
|
115
122
|
-t, --template <name> Start from a named template (skips the interactive picker)
|
|
116
123
|
|
|
117
124
|
run/server options:
|
|
125
|
+
--port <N> Dev server port (default: 34884)
|
|
118
126
|
--proxy-http Route fetch calls through the dev server (HTTP cache enabled)
|
|
119
127
|
--capture <file> Record connected clients' key events to a script file
|
|
120
128
|
--tunnel Accept ticket-paired clients through the p2p tunnel
|
|
@@ -130,6 +138,9 @@ client options:
|
|
|
130
138
|
--android Install and launch the client on a connected Android device
|
|
131
139
|
--device <serial> Target a specific adb device by serial or unique prefix
|
|
132
140
|
|
|
141
|
+
mcp options:
|
|
142
|
+
--port <N> Port of the dev server to attach to (default: 34884)
|
|
143
|
+
|
|
133
144
|
bundle options:
|
|
134
145
|
-f, --flux Bundle for the bare Flux runtime, without SolidJS (entry must be .ts|.js)
|
|
135
146
|
-d, --dev Use development build of SolidJS (default: production)
|
package/src/bundler.ts
CHANGED
|
@@ -11,40 +11,48 @@ import { state, print, requireBinary } from "./util"
|
|
|
11
11
|
import { buildManifest } from "./project"
|
|
12
12
|
|
|
13
13
|
// Babel plugin: rewrite `import data from "./x" with { type: "binary" }` into an
|
|
14
|
-
// inline Uint8Array of the file's bytes
|
|
14
|
+
// inline Uint8Array of the file's bytes, and `with { type: "text" }` into an
|
|
15
|
+
// inline string of its UTF-8 contents. The import attribute is invisible to
|
|
15
16
|
// Bun's bundler and its plugins in this Bun version, so we handle it here in the
|
|
16
17
|
// transform where the AST still carries it. Inlining (rather than emitting a
|
|
17
18
|
// separate asset) keeps a single bundle output and hands JS a Uint8Array, which
|
|
18
|
-
// is what createImage and friends expect.
|
|
19
|
-
// atob; for ASCII-extension files (.jpg/.png/...) we may add an
|
|
20
|
-
// path later.
|
|
21
|
-
|
|
19
|
+
// is what createImage and friends expect. Binary is decoded at runtime via the
|
|
20
|
+
// global atob; for ASCII-extension files (.jpg/.png/...) we may add an
|
|
21
|
+
// attribute-free path later. Both attributes work on any extension, so shader
|
|
22
|
+
// and other text sources inline by attribute the same way bytes do; `.svg` is
|
|
23
|
+
// additionally text-loaded without an attribute (see Bun's `loader` below).
|
|
24
|
+
function inlineImport({ types: t }: { types: any }) {
|
|
22
25
|
return {
|
|
23
26
|
visitor: {
|
|
24
27
|
ImportDeclaration(path: any, pluginState: any) {
|
|
25
28
|
let attrs = path.node.attributes ?? path.node.assertions
|
|
26
|
-
let
|
|
27
|
-
if (
|
|
29
|
+
let kind = attrs?.find((a: any) => a.key.name === "type")?.value.value
|
|
30
|
+
if (kind !== "binary" && kind !== "text") return
|
|
28
31
|
|
|
29
32
|
let def = path.node.specifiers.find((s: any) => s.type === "ImportDefaultSpecifier")
|
|
30
33
|
if (!def) {
|
|
31
34
|
throw path.buildCodeFrameError(
|
|
32
|
-
|
|
35
|
+
`A ${kind} import needs a default import: import data from "./file" with { type: "${kind}" }`,
|
|
33
36
|
)
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
let importer = pluginState.file.opts.filename as string
|
|
37
40
|
let abs = resolvePath(dirname(importer), path.node.source.value)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// var <local> = Uint8Array.from(atob("<b64>"), c => c.charCodeAt(0))
|
|
41
|
-
let expr =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
|
|
42
|
+
// text: var <local> = "<contents>"
|
|
43
|
+
// binary: var <local> = Uint8Array.from(atob("<b64>"), c => c.charCodeAt(0))
|
|
44
|
+
let expr =
|
|
45
|
+
kind === "text"
|
|
46
|
+
? t.stringLiteral(readFileSync(abs, "utf8"))
|
|
47
|
+
: t.callExpression(t.memberExpression(t.identifier("Uint8Array"), t.identifier("from")), [
|
|
48
|
+
t.callExpression(t.identifier("atob"), [t.stringLiteral(readFileSync(abs).toString("base64"))]),
|
|
49
|
+
t.arrowFunctionExpression(
|
|
50
|
+
[t.identifier("c")],
|
|
51
|
+
t.callExpression(t.memberExpression(t.identifier("c"), t.identifier("charCodeAt")), [
|
|
52
|
+
t.numericLiteral(0),
|
|
53
|
+
]),
|
|
54
|
+
),
|
|
55
|
+
])
|
|
48
56
|
path.replaceWith(t.variableDeclaration("var", [t.variableDeclarator(t.identifier(def.local.name), expr)]))
|
|
49
57
|
},
|
|
50
58
|
},
|
|
@@ -65,7 +73,7 @@ async function codeFromOutputs(outputs: BuildArtifact[]): Promise<string> {
|
|
|
65
73
|
|
|
66
74
|
// Bun build plugin that runs JSX/TSX through babel-preset-solid (universal
|
|
67
75
|
// generate, targeting @solidrt/core) plus the TS preset. Plain .js/.ts app
|
|
68
|
-
// modules take the same path (solid is a no-op without JSX) so
|
|
76
|
+
// modules take the same path (solid is a no-op without JSX) so inlineImport
|
|
69
77
|
// can rewrite their `with { type: "binary" }` imports too; dependency code
|
|
70
78
|
// (node_modules) skips the babel detour and keeps Bun's native loaders.
|
|
71
79
|
// With `babelMaps`, each file's transform map (original -> babel output) is
|
|
@@ -82,7 +90,7 @@ function solidPlugin(babelMaps?: Map<string, object>): BunPlugin {
|
|
|
82
90
|
filename: args.path,
|
|
83
91
|
sourceMaps: !!babelMaps,
|
|
84
92
|
presets: [[solid, { moduleName: "@solidrt/core", generate: "universal" }], [ts]],
|
|
85
|
-
plugins: [jsx,
|
|
93
|
+
plugins: [jsx, inlineImport],
|
|
86
94
|
})
|
|
87
95
|
if (babelMaps && transforms?.map) babelMaps.set(args.path, transforms.map)
|
|
88
96
|
return { contents: transforms?.code ?? "", loader: "js" }
|
package/src/commands/check.ts
CHANGED
|
@@ -60,11 +60,11 @@ function findTsc(fromDir: string): string | null {
|
|
|
60
60
|
|
|
61
61
|
// Typecheck the entry's program, not the enclosing project: a transient
|
|
62
62
|
// config extends the project's tsconfig and roots the program at the entry
|
|
63
|
-
// alone, so tsc checks exactly the
|
|
64
|
-
// are excluded by construction. The
|
|
65
|
-
// .srt-data (the dev-artifact dir; absolute
|
|
66
|
-
// only matters for type-package resolution,
|
|
67
|
-
// node_modules from there).
|
|
63
|
+
// alone (plus the project's ambient declarations), so tsc checks exactly the
|
|
64
|
+
// entry's import closure - unrelated files are excluded by construction. The
|
|
65
|
+
// config lives in the project-local .srt-data (the dev-artifact dir; absolute
|
|
66
|
+
// paths inside, so its location only matters for type-package resolution,
|
|
67
|
+
// which walks up to the project's node_modules from there).
|
|
68
68
|
export async function typecheck(root: string, entry: string): Promise<{ app: Diagnostic[]; hidden: number } | null> {
|
|
69
69
|
let tsconfig = join(root, "tsconfig.json")
|
|
70
70
|
if (!existsSync(tsconfig)) {
|
|
@@ -79,10 +79,14 @@ export async function typecheck(root: string, entry: string): Promise<{ app: Dia
|
|
|
79
79
|
let dataDir = join(root, ".srt-data")
|
|
80
80
|
mkdirSync(dataDir, { recursive: true })
|
|
81
81
|
let config = join(dataDir, `typecheck-${process.pid}.tsconfig.json`)
|
|
82
|
-
// include
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
82
|
+
// The include narrows the inherited one (files and include are unioned, so
|
|
83
|
+
// without it a base config's include would drag the whole project back into
|
|
84
|
+
// the program) down to declaration files only. Those are the one thing the
|
|
85
|
+
// entry's import closure cannot reach: an ambient `declare module "*.glsl"`
|
|
86
|
+
// applies precisely because nothing imports it, so entry-only rooting would
|
|
87
|
+
// silently drop it and every asset import would fail with TS2307. The
|
|
88
|
+
// pattern is relative to this config, which sits one level under the root.
|
|
89
|
+
await Bun.write(config, JSON.stringify({ extends: tsconfig, include: ["../**/*.d.ts"], files: [resolve(entry)] }))
|
|
86
90
|
try {
|
|
87
91
|
let proc = Bun.spawn([tsc, "-p", config, "--noEmit", "--pretty", "false"], {
|
|
88
92
|
cwd: root,
|
package/src/commands/init.ts
CHANGED
|
@@ -141,9 +141,11 @@ export async function runInitCommand() {
|
|
|
141
141
|
|
|
142
142
|
// The assets/ convention folder, created up front: everything in it ships
|
|
143
143
|
// with the app, and the dev watcher only picks up an assets/ folder that
|
|
144
|
-
// exists when it starts.
|
|
144
|
+
// exists when it starts. It starts with a placeholder app icon (picked up
|
|
145
|
+
// through the assets/icon.svg convention) for the author to replace.
|
|
145
146
|
await mkdir(join(dir, "assets"), { recursive: true })
|
|
146
|
-
|
|
147
|
+
await writeFile(join(dir, "assets", "icon.svg"), await readFile(join(SCAFFOLD_DIR, "icon.svg")))
|
|
148
|
+
console.log(" Write assets/icon.svg")
|
|
147
149
|
|
|
148
150
|
// The scaffold package.json carries a placeholder name; set it from the
|
|
149
151
|
// target folder. A core-level app gets no component framework dependency.
|
package/src/commands/mcp.ts
CHANGED
|
@@ -103,7 +103,7 @@ let TOOLS: {
|
|
|
103
103
|
name: "get_stats",
|
|
104
104
|
readOnly: true,
|
|
105
105
|
description:
|
|
106
|
-
"Performance statistics from a running app client: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count. Layout-activity counters cover the last full rebuild, raw: nodes (live node count, mounted AND detached), mountedNodes/orphanNodes (live at query time: nodes reachable from the root vs not - orphans growing at a stable tree shape mean an unmount leak; absent when no engine is running), measureCalls (text measures; mostly cache hits, cheap), paraShapes (paragraphs actually shaped; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), dirtiedNodes (layout caches cleared by property writes since the previous rebuild; how much of the tree a write burst invalidated), cacheGets/cacheHits (layout-cache lookups during the rebuild; a hit on a container skips its whole subtree, so a healthy incremental rebuild shows a near-100% hit rate - a low rate at scale means the layout cache is being defeated).",
|
|
106
|
+
"Performance statistics from a running app client: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count. Layout-activity counters cover the last full rebuild, raw: nodes (live node count, mounted AND detached), mountedNodes/orphanNodes (live at query time: nodes reachable from the root vs not - orphans growing at a stable tree shape mean an unmount leak; absent when no engine is running), measureCalls (text measures; mostly cache hits, cheap), paraShapes (paragraphs actually shaped; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), dirtiedNodes (layout caches cleared by property writes since the previous rebuild; how much of the tree a write burst invalidated), cacheGets/cacheHits (layout-cache lookups during the rebuild; a hit on a container skips its whole subtree, so a healthy incremental rebuild shows a near-100% hit rate - a low rate at scale means the layout cache is being defeated). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed; stuck nonzero means the raster thread is backlogged - the state where fps and frameMs go blind because no frames complete), idleTicks (cumulative idle frame signals emitted while the GPU had nothing queued; idleTicks racing while rasterQueue sits nonzero would mean the idle-tick gate is broken), fenceTimeouts (cumulative present-fence waits that expired instead of signaling - each one is a frame where the GPU was over budget for 100ms+ and one-frame-in-flight pacing was lost; zero on a healthy machine, climbing means the GPU is the bottleneck right now).",
|
|
107
107
|
inputSchema: { client: CLIENT_ARG },
|
|
108
108
|
},
|
|
109
109
|
{
|
|
@@ -137,9 +137,12 @@ let TOOLS: {
|
|
|
137
137
|
name: "get_snapshot",
|
|
138
138
|
readOnly: true,
|
|
139
139
|
description:
|
|
140
|
-
"Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. The node must be currently mounted and
|
|
140
|
+
"Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. Capture the smallest node that contains what you are checking (e.g. the <texture> leaf itself) - that is exactly the content at its own pixel size; the window root is mostly empty layout around it and orders of magnitude more pixels. Reserve root captures for when layout/positioning itself is the question. The node must be currently mounted and paint a non-zero box. Detached (`d-*`) nodes capture their painted box: their own `w`/`h` when set, else the box inherited from the nearest laid-out ancestor (the same box get_render_tree reports for them). Works on an idle client (the capture requests its own frame); a timeout means the client's JS thread is busy or wedged, not that the app is idle.",
|
|
141
141
|
inputSchema: {
|
|
142
|
-
nodeId: z
|
|
142
|
+
nodeId: z
|
|
143
|
+
.number()
|
|
144
|
+
.int()
|
|
145
|
+
.describe("Id of the node to capture, from get_render_tree; prefer the smallest relevant node over the root"),
|
|
143
146
|
save_to: SAVE_TO_ARG,
|
|
144
147
|
client: CLIENT_ARG,
|
|
145
148
|
},
|
|
@@ -148,14 +151,14 @@ let TOOLS: {
|
|
|
148
151
|
name: "get_gpu_resources",
|
|
149
152
|
readOnly: true,
|
|
150
153
|
description:
|
|
151
|
-
"Inventory of a running app client's GPU resources: textures (id, size, whether a shader renders into it), vertex buffers (id, byteLength), and shader/pipeline targets (output textureId, kind, bufferId, topology, drawCount, depth, attribute layout, bound sampler texture ids,
|
|
154
|
+
"Inventory of a running app client's GPU resources: textures (id, size, whether a shader renders into it), vertex buffers (id, byteLength), and shader/pipeline targets (output textureId, kind, bufferId, topology, drawCount, depth, attribute layout, bound sampler texture ids, current uniform values - the most recent writes, which the next frame or readback draws with). Use it when the render tree is just a <texture> leaf and the interesting state lives behind it; follow up with get_texture or get_buffer to see contents.",
|
|
152
155
|
inputSchema: { client: CLIENT_ARG },
|
|
153
156
|
},
|
|
154
157
|
{
|
|
155
158
|
name: "get_texture",
|
|
156
159
|
readOnly: true,
|
|
157
160
|
description:
|
|
158
|
-
"Read back any GPU texture from a running app client as a PNG, by texture id (from get_gpu_resources, or the id returned by createImage/createShader/createPipeline in app code). Works on sampled textures (atlases, data textures) and shader/pipeline render targets alike, without needing a frame. Pass x/y/width/height to crop, e.g. one tile of an atlas.",
|
|
161
|
+
"Read back any GPU texture from a running app client as a PNG, by texture id (from get_gpu_resources, or the id returned by createImage/createShader/createPipeline in app code). Works on sampled textures (atlases, data textures) and shader/pipeline render targets alike, without needing a frame: a render target reads as its current output, with any pending params, geometry or sampled-input changes resolved first. Pass x/y/width/height to crop, e.g. one tile of an atlas.",
|
|
159
162
|
inputSchema: {
|
|
160
163
|
id: z.number().int().describe("Texture id, from get_gpu_resources"),
|
|
161
164
|
x: z.number().int().describe("Crop rect left edge in texture pixels (requires y, width, height)").optional(),
|
package/src/dev-server.ts
CHANGED
|
@@ -5,7 +5,23 @@ import { state, print, printErr, requireBinary, pipeAbovePrompt, shutdown } from
|
|
|
5
5
|
import { values } from "./args"
|
|
6
6
|
|
|
7
7
|
export const DEV_HOST = "127.0.0.1"
|
|
8
|
-
export const
|
|
8
|
+
export const DEFAULT_DEV_PORT = 0x8844
|
|
9
|
+
|
|
10
|
+
// The port every dev-server consumer dials: the spawned server, the local and
|
|
11
|
+
// Android clients' --dev-server address, and the MCP bridge's control base.
|
|
12
|
+
// Resolved once here, so --port needs no threading through those call sites.
|
|
13
|
+
function resolveDevPort(): number {
|
|
14
|
+
let raw = values.port
|
|
15
|
+
if (raw === undefined) return DEFAULT_DEV_PORT
|
|
16
|
+
let port = Number(raw)
|
|
17
|
+
if (!/^\d+$/.test(raw) || port < 1 || port > 65535) {
|
|
18
|
+
console.error(`Invalid --port value "${raw}": expected a port number between 1 and 65535`)
|
|
19
|
+
process.exit(1)
|
|
20
|
+
}
|
|
21
|
+
return port
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export let DEV_PORT = resolveDevPort()
|
|
9
25
|
|
|
10
26
|
// The dev server itself is a flux script (packages/cli/server/), spawned by
|
|
11
27
|
// srt: bundling, file watching, and the repl stay here and drive the server
|
|
@@ -157,8 +173,23 @@ async function bundleServer(): Promise<string> {
|
|
|
157
173
|
return outfile
|
|
158
174
|
}
|
|
159
175
|
|
|
176
|
+
// The server runs as a separate flux process, so a port clash surfaces there as
|
|
177
|
+
// a bare non-zero exit ("Dev server exited unexpectedly (1)"). Claim the port
|
|
178
|
+
// here first to turn that into the actual reason, and into the fix.
|
|
179
|
+
function requireFreePort(port: number) {
|
|
180
|
+
try {
|
|
181
|
+
// Default hostname: the server binds every interface, so probe the same way.
|
|
182
|
+
let probe = Bun.serve({ port, fetch: () => new Response() })
|
|
183
|
+
probe.stop(true)
|
|
184
|
+
} catch {
|
|
185
|
+
printErr(`[cli] Port ${port} is already in use; start on another port with --port <N>`)
|
|
186
|
+
process.exit(1)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
160
190
|
export async function startServer() {
|
|
161
191
|
let flux = requireBinary("flux")
|
|
192
|
+
requireFreePort(DEV_PORT)
|
|
162
193
|
let script = await bundleServer()
|
|
163
194
|
|
|
164
195
|
let lanAddress = Object.values(networkInterfaces())
|
package/src/pack-folder.ts
CHANGED
|
@@ -37,7 +37,7 @@ export type PackFolder = {
|
|
|
37
37
|
export function buildPackFolder(entry: string, bytecode: Buffer): PackFolder {
|
|
38
38
|
let identity = loadAppIdentity(entry)
|
|
39
39
|
let projectDir = projectDirFor(resolve(entry))
|
|
40
|
-
let { assets } = collectAssets(entry)
|
|
40
|
+
let { assets, icon } = collectAssets(entry)
|
|
41
41
|
let copies = assets.map((a) => ({ from: join(projectDir, a.path), to: a.path }))
|
|
42
42
|
|
|
43
43
|
// The full resolved font set: custom fonts are already collected assets;
|
|
@@ -70,6 +70,7 @@ export function buildPackFolder(entry: string, bytecode: Buffer): PackFolder {
|
|
|
70
70
|
appId: identity.appId,
|
|
71
71
|
org: identity.org,
|
|
72
72
|
displayName: identity.displayName,
|
|
73
|
+
...(icon ? { icon } : {}),
|
|
73
74
|
runtimeVersion: RUNTIME_VERSION,
|
|
74
75
|
solidrtVersion: SOLIDRT_VERSION,
|
|
75
76
|
bundle: { path: "bundle.bin", sha256: hashHex(bytecode), size: bytecode.length },
|
package/src/project.ts
CHANGED
|
@@ -8,7 +8,10 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
|
|
|
8
8
|
// "appId": "com.example.app", // stable identity: storage dir, Android package id
|
|
9
9
|
// "org": "Example", // optional display metadata (publisher)
|
|
10
10
|
// "displayName": "Example App", // optional display metadata (launcher/window)
|
|
11
|
-
// "fonts": { ... }
|
|
11
|
+
// "fonts": { ... }, // see fonts.ts
|
|
12
|
+
// "icon": "./assets/icon.svg" // optional app icon (SVG, under assets/);
|
|
13
|
+
// // an undeclared assets/icon.svg is picked
|
|
14
|
+
// // up by convention
|
|
12
15
|
// }
|
|
13
16
|
//
|
|
14
17
|
// Everything defaults from the package name (or the entry filename when there
|
|
@@ -71,11 +74,13 @@ export const SOLIDRT_VERSION: string = pkgVersion === "0.0.0" ? "unknown" : pkgV
|
|
|
71
74
|
export function buildManifest(code: string, entry: string): string {
|
|
72
75
|
let identity = loadAppIdentity(entry)
|
|
73
76
|
let sha256 = new Bun.CryptoHasher("sha256").update(code).digest("hex")
|
|
74
|
-
let { assets, fonts } = collectAssets(entry)
|
|
77
|
+
let { assets, fonts, icon } = collectAssets(entry)
|
|
75
78
|
return JSON.stringify({
|
|
76
79
|
appId: identity.appId,
|
|
80
|
+
displayName: identity.displayName,
|
|
77
81
|
runtimeVersion: RUNTIME_VERSION,
|
|
78
82
|
solidrtVersion: SOLIDRT_VERSION,
|
|
83
|
+
...(icon ? { icon } : {}),
|
|
79
84
|
bundle: { path: "bundle.js", sha256, size: Buffer.byteLength(code, "utf8") },
|
|
80
85
|
...(assets.length ? { assets } : {}),
|
|
81
86
|
...(fonts.length ? { fonts } : {}),
|
|
@@ -116,11 +121,19 @@ function walkAssets(assetsDir: string, dir: string, out: ManifestAsset[]) {
|
|
|
116
121
|
|
|
117
122
|
// The convention-first asset set: everything under the project's assets/
|
|
118
123
|
// folder (next to package.json), collected wholesale in sorted order so the
|
|
119
|
-
// manifest bytes are deterministic. Fonts
|
|
120
|
-
// set: `solidrt.fonts` path entries
|
|
121
|
-
//
|
|
122
|
-
// have no manifest
|
|
123
|
-
|
|
124
|
+
// manifest bytes are deterministic. Fonts and the icon are annotations
|
|
125
|
+
// pointing into that set: `solidrt.fonts` path entries and `solidrt.icon`
|
|
126
|
+
// must live under assets/ so they reach dev clients and the version store
|
|
127
|
+
// (`false` font entries only drop pack defaults and have no manifest
|
|
128
|
+
// presence). The icon is SVG-only for now: the launcher renders SVG natively,
|
|
129
|
+
// and the raster surfaces (window icon, OS embedding) come with later stages
|
|
130
|
+
// (okf/backlog/app-icons.md). An undeclared assets/icon.svg is picked up by
|
|
131
|
+
// convention.
|
|
132
|
+
export function collectAssets(entry: string): {
|
|
133
|
+
assets: ManifestAsset[]
|
|
134
|
+
fonts: ManifestFont[]
|
|
135
|
+
icon: string | null
|
|
136
|
+
} {
|
|
124
137
|
let project = findProjectPackage(entry)
|
|
125
138
|
let projectDir = projectDirFor(entry)
|
|
126
139
|
let assetsDir = resolve(projectDir, "assets")
|
|
@@ -146,7 +159,27 @@ export function collectAssets(entry: string): { assets: ManifestAsset[]; fonts:
|
|
|
146
159
|
fonts.push({ path, alias })
|
|
147
160
|
}
|
|
148
161
|
}
|
|
149
|
-
|
|
162
|
+
|
|
163
|
+
let icon: string | null = null
|
|
164
|
+
let declared = project?.pkg.solidrt?.icon
|
|
165
|
+
if (declared !== undefined) {
|
|
166
|
+
if (typeof declared !== "string") fail('"solidrt": "icon" must be a string path')
|
|
167
|
+
let path = assetPathFor(projectDir, resolve(projectDir, declared))
|
|
168
|
+
if (!path) {
|
|
169
|
+
fail(`"solidrt.icon": ${declared} must live under assets/ (the icon ships as a version asset)`)
|
|
170
|
+
}
|
|
171
|
+
if (!path.toLowerCase().endsWith(".svg")) {
|
|
172
|
+
fail(`"solidrt.icon": ${declared} must be an .svg file`)
|
|
173
|
+
}
|
|
174
|
+
if (!assets.some((a) => a.path === path)) {
|
|
175
|
+
fail(`"solidrt.icon": no such file: ${resolve(projectDir, declared)}`)
|
|
176
|
+
}
|
|
177
|
+
icon = path
|
|
178
|
+
} else if (assets.some((a) => a.path === "assets/icon.svg")) {
|
|
179
|
+
icon = "assets/icon.svg"
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { assets, fonts, icon }
|
|
150
183
|
}
|
|
151
184
|
|
|
152
185
|
// Resolve the app identity for a pack. All three fields are guaranteed
|