@solidrt/cli 0.0.39 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/cli",
3
- "version": "0.0.39",
3
+ "version": "0.0.40",
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.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.39",
37
+ "@solidrt/core": "0.0.40",
38
38
  "typescript": "^7"
39
39
  },
40
40
  "devDependencies": {
41
- "@solidrt/flux-types": "0.0.39",
41
+ "@solidrt/flux-types": "0.0.40",
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,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. 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
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 (`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/`.
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, last-applied uniform values)
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 is
231
- "what this pipeline last drew", no frame or snapshot needed); crop with
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. 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.
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.
@@ -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.40",
13
+ "@solidrt/components": "0.0.40"
14
14
  },
15
15
  "devDependencies": {
16
- "@solidrt/cli": "0.0.39",
17
- "@solidrt/flux-types": "0.0.39",
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. 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,
@@ -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, 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.",
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/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.",
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())