@solidrt/cli 0.0.39 → 0.0.41

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.39",
3
+ "version": "0.0.41",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -28,17 +28,17 @@
28
28
  "zod": "^4.4.3"
29
29
  },
30
30
  "optionalDependencies": {
31
- "@solidrt/darwin-arm64": "0.0.39",
32
- "@solidrt/linux-arm64-gnu": "0.0.39",
33
- "@solidrt/linux-x64-gnu": "0.0.39",
34
- "@solidrt/win32-x64-msvc": "0.0.39"
31
+ "@solidrt/darwin-arm64": "0.0.41",
32
+ "@solidrt/linux-arm64-gnu": "0.0.41",
33
+ "@solidrt/linux-x64-gnu": "0.0.41",
34
+ "@solidrt/win32-x64-msvc": "0.0.41"
35
35
  },
36
36
  "peerDependencies": {
37
- "@solidrt/core": "0.0.39",
37
+ "@solidrt/core": "0.0.41",
38
38
  "typescript": "^7"
39
39
  },
40
40
  "devDependencies": {
41
- "@solidrt/flux-types": "0.0.39",
41
+ "@solidrt/flux-types": "0.0.41",
42
42
  "@types/babel__core": "^7.20.5",
43
43
  "@types/bun": "latest"
44
44
  }
@@ -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. Cover/contain images: give `Image` a `fit` prop ("fill" | "cover" |
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,39 @@ 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. Rules, in order of leverage:
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
- fragment shader: createShader (from @solidrt/core/gpu) + `<texture
135
- params={{ iTime }}>`. The whole effect then costs one setProperty per
136
- frame - the iTime write - regardless of visual complexity. Shader output
143
+ fragment shader: createShaderTexture (from @solidrt/core/gpu) + `<texture
144
+ params={{ uTime }}>` (the shader declares `uniform float uTime;` itself -
145
+ the preamble declares only what the runtime fills). The whole effect then
146
+ costs one setProperty per frame - the uTime write - regardless of visual
147
+ complexity. Shader output
137
148
  must be premultiplied alpha (white flakes are `vec4(vec3(a), a)`);
138
- straight alpha (`vec4(1,1,1,a)`) composites as opaque white.
149
+ straight alpha (`vec4(1,1,1,a)`) composites as opaque white. A source that
150
+ starts with `#version 300 es` is compiled exactly as written - no preamble
151
+ is injected, though the built-in vertex stage still supplies `vUV` - so a
152
+ shader ported from elsewhere keeps its own uniform names without dropping
153
+ to compileShader/linkProgram. Params drive any uniform type: a number
154
+ fills a `float`/`int` scalar, a flat number array fills `vec2`/`vec3`/
155
+ `vec4` (2/3/4 numbers) or `mat4` (16, column-major), dispatched by the
156
+ shader's own declaration - a ported shader's `vec2 uCenter` or Shadertoy's
157
+ `vec3 iResolution` needs no splitting into scalars. To combine several
158
+ GPU passes, stack `<texture>` elements and set `blendMode` (e.g. a base
159
+ pass plus an additive `blendMode="plus"` pass) rather than writing a
160
+ compositing shader. Within one pipeline draw, createPipelineTexture's
161
+ `blend: "add"` accumulates overlapping geometry additively (soft point
162
+ splats, glow) - pair it with `depthWrite: false` when depth-tested;
163
+ neither option implies the other. A pipeline's own vertex stage writes
164
+ into a y-down clip space: `gl_Position` y = -1 is the top row of the
165
+ target and +1 the bottom, so camera-up geometry must negate y (or fold
166
+ the flip into its projection) or it draws upside down. Sampling is a
167
+ create-time option on every texture: `{ filter: "nearest" }` for
168
+ hard-pixel upscaling (render a small target, display it big - the
169
+ retro/pixel-art path) and `{ wrap: "repeat" }` to tile outside 0..1 in
170
+ shaders; the defaults are linear and clamp, and the choice applies both
171
+ on screen and to shaders sampling the texture.
139
172
  2. Reduce setProperty calls wherever possible: one path string rebuilt per
140
173
  frame beats N elements with N animated positions; a shader beats the path
141
174
  string. get_stats' setPropsPerFrame is the counter to watch.
@@ -159,6 +192,44 @@ nearly free. Rules, in order of leverage:
159
192
  creating many at once (dealing a board of 64 sprites) is a visible
160
193
  one-frame hiccup - pool or pre-warm if that moment matters.
161
194
 
195
+ ### Where GPU work stops being free
196
+
197
+ "GPU work is nearly free" is a property of the hardware, not of the engine, and
198
+ the spread is wide enough to design against rather than discover late. The same
199
+ app - two point-cloud pipelines, 233,600 vertices, one params write each per
200
+ onFrame, i.e. exactly what rule 1 recommends - measured 16.7 ms/frame (60 fps,
201
+ vsync-locked) on both desktop and a mid-range 2020 tablet, and 120 ms/frame
202
+ (8.3 fps) on a 2017 Android TV. Roughly 8x for identical work, with the tablet
203
+ indistinguishable from desktop. Measure on a target device if it matters; do
204
+ not infer it from the desktop number.
205
+
206
+ - **On a tiled GPU the budget is primitive count, not pixels.** Every point or
207
+ triangle costs the tiler regardless of how few pixels it covers. On that TV,
208
+ frame time against total vertices with a trivial vertex shader: 20k -> 80 ms,
209
+ 35k -> 100 ms, 100k -> 380 ms. Meanwhile `gl_PointSize = 3.0` - nine times
210
+ the fill - measured within one vsync of 1.0, and rendering into a
211
+ quarter-size target measured identical to full size. So for a heavy pass the
212
+ lever is fewer primitives; shrinking the target or the splat usually is not,
213
+ and coverage is far cheaper bought with point size than with more points.
214
+ - **A device's compositor can set the frame budget outright**, in which case
215
+ none of the above moves. That TV never presents faster than every 80 ms -
216
+ four refresh periods blocked inside `eglSwapBuffers` - even for a near-empty
217
+ scene, so its ceiling is ~12 fps whatever you draw. Recognise it by a
218
+ content-independent floor: if a trivial scene and a heavy one present at
219
+ nearly the same rate, you are compositor-bound and tuning the scene is
220
+ wasted effort.
221
+ - **Per-frame writes are gated on the raster thread, not on vsync**, so a pass
222
+ that costs more than a refresh period does not silently pile up. If
223
+ `rasterQueue` sits persistently above 0 the raster thread is behind; if
224
+ `fenceTimeouts` climbs, the GPU is over its pacing budget.
225
+
226
+ Finding your own numbers: `get_stats` gives fps, frameMs, setPropsPerFrame,
227
+ rasterQueue and fenceTimeouts. When those disagree with what the screen is
228
+ visibly doing, ground truth on Android is
229
+ `adb shell dumpsys SurfaceFlinger --latency <layer>` for real present
230
+ timestamps - engine-reported phase timings can each be honest and still not add
231
+ up to the frame period, because work outside the frame call is not in them.
232
+
162
233
  ## Assets and app identity
163
234
 
164
235
  - Everything under `assets/` ships with the app: the folder is collected
@@ -167,10 +238,13 @@ nearly free. Rules, in order of leverage:
167
238
  from `flux:fs` - and treat them as read-only at runtime; writes belong in
168
239
  plain relative paths, which land in the app's private data dir.
169
240
  - Small text-like assets (SVG documents, shaders) can instead be inlined via
170
- imports (`import icon from "./icon.svg"` yields the file's text;
171
- `with { type: "binary" }` yields a Uint8Array). Inlining trades update
172
- granularity for zero I/O - keep big or streamable files (audio, images) in
173
- `assets/`.
241
+ imports. An import attribute picks the form and works on any extension:
242
+ `import src from "./effect.glsl" with { type: "text" }` yields the file's
243
+ contents as a string, `with { type: "binary" }` yields a Uint8Array. `.svg`
244
+ is text-loaded with no attribute needed. Shader sources (`.glsl`/`.vert`/
245
+ `.frag`) are declared as text modules out of the box, so they typecheck
246
+ without setup. Inlining trades update granularity for zero I/O - keep big or
247
+ streamable files (audio, images) in `assets/`.
174
248
  - Custom fonts go in `assets/fonts/` and are declared in the `solidrt.fonts`
175
249
  map in package.json (alias -> file path; role aliases `sans`/`serif`/`mono`
176
250
  replace the built-in defaults, `false` drops one, other keys add fonts
@@ -225,11 +299,12 @@ its tools over guessing at runtime state:
225
299
  (e.g. keep a before/after pair to diff)
226
300
  - get_gpu_resources: inventory of GPU state - textures (size, render target
227
301
  or not), vertex buffers (byteLength), pipelines (draw count, attribute
228
- layout, bound textures, last-applied uniform values)
302
+ layout, bound textures, current uniform values - the most recent writes,
303
+ which the next frame or readback draws with)
229
304
  - 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 is
231
- "what this pipeline last drew", no frame or snapshot needed); crop with
232
- x/y/width/height
305
+ textures, and shader/pipeline render targets alike (a render target reads
306
+ as its current output, pending writes included, with no frame or snapshot
307
+ needed); crop with x/y/width/height
233
308
  - get_buffer: a vertex-buffer range decoded to numbers (f32/u16/u8, 64 KiB
234
309
  per call) - verify geometry after a writeBuffer instead of inferring it
235
310
  from pixels
@@ -246,7 +321,9 @@ its tools over guessing at runtime state:
246
321
  when you stop working - the user's own saves rely on it.
247
322
 
248
323
  The tools need a running app: if list_clients is empty, ask the user to start
249
- `bunx srt run src/index.tsx`.
324
+ `bunx srt run src/index.tsx`. The bridge dials the dev server's default port
325
+ (34884), so if the user started it with `--port N`, .mcp.json needs the same
326
+ flag: `"args": [..., "mcp", "--port", "N"]`.
250
327
 
251
328
  - Permission prompts: agents typically ask approval per MCP tool. All of
252
329
  these tools only talk to the local dev server the user started with
@@ -274,13 +351,17 @@ The tools need a running app: if list_clients is empty, ask the user to start
274
351
  you will want repeatedly (a pose, a mode, a counter), bind a debug key that
275
352
  logs it and read it back via get_logs.
276
353
  - 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. This
278
- runtime names arrow keys "Left"/"Right"/"Up"/"Down", not "ArrowLeft".
279
- - Idle frames skip work: shaders/pipelines only re-render when their params
280
- change, so measure performance while uniforms are actually changing.
281
- get_snapshot works on an idle client (it requests its own frame); a
282
- timeout means the JS thread is busy or wedged. get_texture on a pipeline's
283
- render target reads the last-drawn frame without needing a new one.
354
+ setFocus(node.id) from the window's ref or onKeyDown never fires. `key` and
355
+ `code` are W3C KeyboardEvent values, so arrow keys arrive as "ArrowLeft"/
356
+ "ArrowRight"/"ArrowUp"/"ArrowDown" (not "Left"), alongside "Enter",
357
+ "Escape", "a".
358
+ - Idle frames skip work: shaders/pipelines only re-render when an input
359
+ changes - their own params/geometry, or a sampled texture (a data upload,
360
+ or a sampled target re-rendering; chains propagate automatically). Measure
361
+ performance while inputs are actually changing. get_snapshot works on an
362
+ idle client (it requests its own frame); a timeout means the JS thread is
363
+ busy or wedged. get_texture on a pipeline's render target reads the
364
+ current output, pending writes included, without needing a new frame.
284
365
  - When a human reports a visual bug: capture a snapshot and SAY WHAT YOU SEE
285
366
  in it before investigating, so you agree on the symptom. If you cannot see
286
367
  the problem in the capture, say that instead of guessing.
@@ -9,12 +9,12 @@
9
9
  "android": "srt client --android"
10
10
  },
11
11
  "dependencies": {
12
- "@solidrt/core": "0.0.39",
13
- "@solidrt/components": "0.0.39"
12
+ "@solidrt/core": "0.0.41",
13
+ "@solidrt/components": "0.0.41"
14
14
  },
15
15
  "devDependencies": {
16
- "@solidrt/cli": "0.0.39",
17
- "@solidrt/flux-types": "0.0.39",
16
+ "@solidrt/cli": "0.0.41",
17
+ "@solidrt/flux-types": "0.0.41",
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. The import attribute is invisible to
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. Decoded at runtime via the global
19
- // atob; for ASCII-extension files (.jpg/.png/...) we may add an attribute-free
20
- // path later.
21
- function binaryImport({ types: t }: { types: any }) {
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 isBinary = attrs?.some((a: any) => a.key.name === "type" && a.value.value === "binary")
27
- if (!isBinary) return
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
- 'A binary import needs a default import: import data from "./file" with { type: "binary" }',
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
- let b64 = readFileSync(abs).toString("base64")
39
-
40
- // var <local> = Uint8Array.from(atob("<b64>"), c => c.charCodeAt(0))
41
- let expr = t.callExpression(t.memberExpression(t.identifier("Uint8Array"), t.identifier("from")), [
42
- t.callExpression(t.identifier("atob"), [t.stringLiteral(b64)]),
43
- t.arrowFunctionExpression(
44
- [t.identifier("c")],
45
- t.callExpression(t.memberExpression(t.identifier("c"), t.identifier("charCodeAt")), [t.numericLiteral(0)]),
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 binaryImport
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, binaryImport],
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" }
@@ -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 entry's import closure - unrelated files
64
- // are excluded by construction. The config lives in the project-local
65
- // .srt-data (the dev-artifact dir; absolute paths inside, so its location
66
- // only matters for type-package resolution, which walks up to the project's
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: [] overrides any include inherited from the extended config -
83
- // files and include are unioned, so without this a base config's include
84
- // would drag the whole project back into the program.
85
- await Bun.write(config, JSON.stringify({ extends: tsconfig, include: [], files: [resolve(entry)] }))
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,
@@ -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). 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).",
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), gpuPasses/gpuPassMs (cumulative shader/pipeline target renders on the raster thread and the wall time they took in whole ms - diff two queries to get a rate; passes racing far ahead of frames means redundant target re-renders, the failure mode where fps and frameMs look healthy while the raster thread drowns; the ms figure is raster-thread occupancy issuing the passes, not GPU-side duration), rasterCmdMs (cumulative wall time in whole ms the raster thread spent executing non-frame commands - texture uploads, readbacks, offscreen rasterizations, shader compiles, param writes and the target re-renders they trigger; the work frameMs never sees, so rasterCmdMs growing much faster than frames are presented means the raster thread is drowning in side work even if every counter above looks calm).",
107
107
  inputSchema: { client: CLIENT_ARG },
108
108
  },
109
109
  {
@@ -137,7 +137,7 @@ 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. 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 have a non-zero layout box. 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.",
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
142
  nodeId: z
143
143
  .number()
@@ -151,14 +151,14 @@ let TOOLS: {
151
151
  name: "get_gpu_resources",
152
152
  readOnly: true,
153
153
  description:
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, last-applied uniform values). 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.",
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 plus firstVertex/instanceCount when off their 0/1 defaults, depth, attribute layout, bound sampler texture ids, current uniform values - the most recent writes, which the next frame or readback draws with - plus passes/passMs, cumulative per-target render count and raster-thread wall time in whole ms: when get_stats shows gpuPasses running hot, these attribute the cost to the specific target). 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.",
155
155
  inputSchema: { client: CLIENT_ARG },
156
156
  },
157
157
  {
158
158
  name: "get_texture",
159
159
  readOnly: true,
160
160
  description:
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. 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/createShaderTexture/createPipelineTexture 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.",
162
162
  inputSchema: {
163
163
  id: z.number().int().describe("Texture id, from get_gpu_resources"),
164
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 DEV_PORT = 0x8844
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())