@solidrt/cli 0.0.48 → 0.0.49

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/AGENTS.md CHANGED
@@ -50,11 +50,29 @@ behavior in isolation.
50
50
  (display scale is pinned to 1), so frames are identical on every machine.
51
51
  - Run from the project directory. There is no `bunx --cwd` flag.
52
52
 
53
+ ## Sessions (parallel dev servers on one machine)
54
+
55
+ - `-s <N>` / `--session <N>` (default 0) picks the dev server: port
56
+ `34884 + N`. Valid on `run`, `server`, `client`, `mcp`. `srt run -s1` is a
57
+ second, fully independent dev setup; `srt client -s1 -c2` attaches another
58
+ client to it.
59
+ - `-c <N>` / `--client <N>` picks the client data tree, defaulting to the
60
+ session number. (`--compile` gave its short to `--client`.)
61
+ - Dev state lives in `~/.solidrt/`: `servers/<port>/` holds each server's tunnel
62
+ key and `live.json` (the registry record MCP resolution reads, removed at
63
+ exit), `clients/client<M>/` the client trees (srt passes
64
+ `--data-root ~/.solidrt/clients` to every locally spawned client).
65
+ - A server run serves the project it was started in; `load` outside the
66
+ project root is refused. Restart the server in another project to switch.
67
+ - `srt mcp` needs no port: each tool call resolves the server serving the
68
+ project the bridge runs in (registry match + probe); `-s`/`--port` pin it.
69
+
53
70
  ## Dev server proxies (when clients on other devices need your machine's data)
54
71
 
55
72
  - `--proxy-http` - route `fetch` through the dev server; responses cached in
56
- `.srt-data/http-cache.db` (delete the file to clear).
73
+ `.srt-data/http-cache.db` in the project root (delete the file to clear).
57
74
 
58
75
  ## REPL (opened by `run`/`server`)
59
76
 
60
77
  `load <file>`, `reload [n]`, `stop [n]`, `list`, `!<cmd>`, `quit`/`exit`.
78
+ `load` is bound to the project root the server was started in.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/cli",
3
- "version": "0.0.48",
3
+ "version": "0.0.49",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -22,22 +22,22 @@
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.31",
25
+ "babel-preset-solid": "2.0.0-rc.0",
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.48",
32
- "@solidrt/linux-arm64-gnu": "0.0.48",
33
- "@solidrt/linux-x64-gnu": "0.0.48",
34
- "@solidrt/win32-x64-msvc": "0.0.48"
31
+ "@solidrt/darwin-arm64": "0.0.49",
32
+ "@solidrt/linux-arm64-gnu": "0.0.49",
33
+ "@solidrt/linux-x64-gnu": "0.0.49",
34
+ "@solidrt/win32-x64-msvc": "0.0.49"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "typescript": "^7"
38
38
  },
39
39
  "devDependencies": {
40
- "@solidrt/flux-types": "0.0.48",
40
+ "@solidrt/flux-types": "0.0.49",
41
41
  "@types/babel__core": "^7.20.5",
42
42
  "@types/bun": "latest"
43
43
  }
@@ -281,6 +281,40 @@ work stops being free" below is where it does not. Rules, in order of leverage:
281
281
  to get_snapshot and every other MCP tool; `srt render` is the only way to
282
282
  see it (Run / verify below).
283
283
 
284
+ ### Isolates: heavy work off the JS thread
285
+
286
+ A long synchronous computation (a big parse, a simulation step, a blocking
287
+ `flux:ffi`/`flux:wasm` call) freezes rendering and input for its duration.
288
+ Move it into an isolate module: a file whose first statement is the
289
+ `"use isolate"` directive runs in a second runtime on its own thread, and
290
+ main calls its exports as async functions.
291
+
292
+ ```ts
293
+ // src/worker.ts
294
+ "use isolate"
295
+ export function crunch(data: Uint8Array): number { /* ... */ }
296
+ ```
297
+
298
+ ```ts
299
+ // src/index.tsx
300
+ import { isolate } from "flux:isolate"
301
+ import type * as Worker from "./worker"
302
+ let worker = isolate<typeof Worker>("worker") // id = path from src/, no extension
303
+ let n = await worker.crunch(bytes) // main keeps rendering meanwhile
304
+ ```
305
+
306
+ The bundler builds each such module as its own bundle and ships it with the
307
+ app (dev pushes and `srt pack` alike). Rules: main may only `import type`
308
+ from an isolate module (a value import is a build error); arguments and
309
+ results are copies (numbers, strings, byte buffers, arrays, plain objects -
310
+ no functions, no class instances); the child has the non-gui `flux:*`
311
+ modules only, so it never touches the render tree; module state persists
312
+ between calls and each `isolate()` call is its own instance. An
313
+ `async function*` export is a stream: `for await (let p of worker.progress())`
314
+ pulls one item per step (progress, ticks, a subscription), `break` ends it in
315
+ the isolate, and streams never block plain calls. Full contract:
316
+ node_modules/@solidrt/flux-types/modules/isolate.d.ts.
317
+
284
318
  ### Where GPU work stops being free
285
319
 
286
320
  "GPU work is nearly free" is a property of the hardware, not of the engine, and
@@ -346,7 +380,8 @@ up to the frame period, because work outside the frame call is not in them.
346
380
  (future launcher/window naming) with no storage meaning.
347
381
  - `bunx srt pack src/index.tsx` builds a single-file executable;
348
382
  `bunx srt pack --folder src/index.tsx` writes the flat app folder
349
- (runner + manifest.json + bundle + assets/) to `dist/`.
383
+ (runner + manifest.json + bundle + assets/, plus the runner's GL
384
+ libraries on Windows and macOS) to `dist/`.
350
385
 
351
386
  ## Run / verify
352
387
 
@@ -374,8 +409,11 @@ its tools over guessing at runtime state:
374
409
 
375
410
  - list_clients: connected app clients, their platform and runtime
376
411
  capabilities, plus the server's `entry` (the app source it serves) and
377
- `projectDir` - check entry matches the app you think you are driving; the
378
- dev port is fixed, so another project's server answers on the same port.
412
+ `projectDir` - check entry matches the app you think you are driving. Each
413
+ tool call finds the dev server currently serving this project (by its
414
+ project root), so a server restarted on another port/session is followed
415
+ automatically; no server serving this project is an error, not a wrong
416
+ server.
379
417
  Each client also lists `queries`, the dev-tool query kinds its runtime
380
418
  answers - check it before planning verification against a mixed-version
381
419
  fleet (no "input" = the client predates send_input)
@@ -10,12 +10,12 @@
10
10
  "android": "srt client --android"
11
11
  },
12
12
  "dependencies": {
13
- "@solidrt/core": "0.0.48",
14
- "@solidrt/components": "0.0.48"
13
+ "@solidrt/core": "0.0.49",
14
+ "@solidrt/components": "0.0.49"
15
15
  },
16
16
  "devDependencies": {
17
- "@solidrt/cli": "0.0.48",
18
- "@solidrt/flux-types": "0.0.48",
17
+ "@solidrt/cli": "0.0.49",
18
+ "@solidrt/flux-types": "0.0.49",
19
19
  "typescript": "^7"
20
20
  }
21
21
  }
package/server/main.ts CHANGED
@@ -74,16 +74,16 @@ async function handleInternal(req: FluxRequest, server: Server, path: string): P
74
74
 
75
75
  switch (path) {
76
76
  case "/__internal__/reload": {
77
- // { message, clients?, latch?, sourceDir?, projectDir?, map? }: send
78
- // `message` (a full client-protocol message, built by srt) to the listed
79
- // client ids, or to all when omitted. `latch` keeps it for late-joining
80
- // clients (code reloads latch, one-shot bytecode loads do not);
81
- // `sourceDir` moves the file-serving root and `projectDir` the /assets/
82
- // root (repl `load`); `map` is the bundle's sourcemap for log remapping,
83
- // replaced on every reload (absent means none).
77
+ // { message, clients?, latch?, sourceDir?, map? }: send `message` (a
78
+ // full client-protocol message, built by srt) to the listed client ids,
79
+ // or to all when omitted. `latch` keeps it for late-joining clients
80
+ // (code reloads latch, one-shot bytecode loads do not); `sourceDir`
81
+ // moves the file-serving root (repl `load`; the project root - and with
82
+ // it the /assets/ root - is fixed for the life of the run); `map` is
83
+ // the bundle's sourcemap for log remapping, replaced on every reload
84
+ // (absent means none).
84
85
  let body = await req.json()
85
86
  if (typeof body.sourceDir === "string") state.sourceDir = body.sourceDir
86
- if (typeof body.projectDir === "string") state.projectDir = body.projectDir
87
87
  // Keep the rebuild entry in sync when `load` moves it, so a later MCP
88
88
  // reload bundles the newly loaded file, not the launch-time one.
89
89
  if (typeof body.entry === "string") state.config.entry = body.entry
@@ -211,6 +211,12 @@ serve({
211
211
  if (path === "/assets" || path.startsWith("/assets/")) {
212
212
  return handleFiles(req, path, state.projectDir)
213
213
  }
214
+ // Isolate bundles are build outputs, not project files: srt (and the
215
+ // rebuild here) write them under .srt-data/isolates/, and the manifest
216
+ // lists them as isolates/<id>.js.
217
+ if (path.startsWith("/isolates/")) {
218
+ return handleFiles(req, path, config.cacheDir)
219
+ }
214
220
  return handleFiles(req, path, state.sourceDir)
215
221
  },
216
222
  websocket: {
package/server/rebuild.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { command } from "flux:subprocess"
2
+ import { dir, file } from "flux:fs"
2
3
  import { state } from "./state"
3
4
 
4
5
  // Server-owned "rebuild and push": the single place the running app is rebuilt
@@ -45,13 +46,20 @@ export async function rebuildAndBroadcast(): Promise<string | null> {
45
46
  return `Rebuild failed:\n${stderr.trim()}`
46
47
  }
47
48
 
48
- // bundle-cli writes one JSON object { code, map, manifest } to stdout.
49
- let bundle: { code?: string; map?: string | null; manifest?: string }
49
+ // bundle-cli writes one JSON object { code, map, manifest, isolates } to stdout.
50
+ let bundle: { code?: string; map?: string | null; manifest?: string; isolates?: { id: string; code: string }[] }
50
51
  try {
51
52
  bundle = JSON.parse(typeof result.stdout === "string" ? result.stdout : "")
52
53
  } catch {
53
54
  return "Rebuild failed: unreadable bundler output"
54
55
  }
56
+ // Isolate bundles are manifest assets clients fetch from our /isolates/
57
+ // route (served from cacheDir), so they must be on disk before the push.
58
+ for (let isolate of bundle.isolates ?? []) {
59
+ let path = `${config.cacheDir}/isolates/${isolate.id}.js`
60
+ await dir(path.slice(0, path.lastIndexOf("/"))).create()
61
+ await file(path).write(isolate.code)
62
+ }
55
63
  state.currentMap = bundle.map ?? null
56
64
  let text = JSON.stringify(buildReload(bundle.code ?? "", bundle.manifest))
57
65
  state.currentReload = text
package/server/state.ts CHANGED
@@ -29,7 +29,7 @@ export type Config = {
29
29
  cache: boolean
30
30
  /** Directory holding the proxy cache db (the project-local .srt-data). */
31
31
  cacheDir: string
32
- /** Directory holding .srt-tunnel-key (the project root). */
32
+ /** Directory holding tunnel.key (the server's ~/.solidrt/servers/<port>/ folder). */
33
33
  keyDir: string
34
34
  /** Destination for captured key events, or unset when off. */
35
35
  capture?: string
package/server/tunnel.ts CHANGED
@@ -15,15 +15,16 @@ import { printQr } from "./qr"
15
15
  // the handshake instead of desyncing.
16
16
  export const TUNNEL_PROTOCOL = "solidrt-dev/0"
17
17
 
18
- // The persisted identity file, at the project root. Delete it to rotate the
19
- // tunnel's identity (which invalidates any old ticket).
20
- const KEY_FILE = ".srt-tunnel-key"
18
+ // The persisted identity file, in the server's ~/.solidrt/servers/<port>/ folder
19
+ // (keyDir): the tunnel identity belongs to the server, not to any project it
20
+ // serves. Delete it to rotate the identity (which invalidates any old ticket).
21
+ const KEY_FILE = "tunnel.key"
21
22
 
22
23
  /**
23
24
  * Bind the tunnel endpoint and print its ticket (text + QR). The endpoint is
24
25
  * kept stable across restarts so a paired client can re-dial the old ticket
25
26
  * without re-scanning: the UDP port is pinned to the dev server's port, and the
26
- * secret key is persisted in <keyDir>/.srt-tunnel-key (generated on first
27
+ * secret key is persisted in <keyDir>/tunnel.key (generated on first
27
28
  * run). Both are needed - a moving port or a fresh key each start would change
28
29
  * the ticket. Stable across restarts on the same network only; a new machine IP
29
30
  * still stales the ticket's addresses (that is the discovery/off-LAN story).
package/src/args.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseArgs } from "node:util"
2
2
  import { resolve } from "node:path"
3
+ import { clientsRoot } from "./dev-dir"
3
4
 
4
5
  // Everything after a bare "--" is the app's argument vector, kept out of
5
6
  // parseArgs (which would fold it into positionals) and forwarded verbatim to
@@ -14,8 +15,9 @@ export let { values, positionals } = parseArgs({
14
15
  options: {
15
16
  dev: { type: "boolean", short: "d", default: false },
16
17
  minify: { type: "boolean", short: "m", default: false },
17
- compile: { type: "boolean", short: "c", default: false },
18
+ compile: { type: "boolean", default: false },
18
19
  flux: { type: "boolean", short: "f", default: false },
20
+ session: { type: "string", short: "s" },
19
21
  folder: { type: "boolean", default: false },
20
22
  stdout: { type: "boolean", default: false },
21
23
  output: { type: "string", short: "o" },
@@ -28,7 +30,7 @@ export let { values, positionals } = parseArgs({
28
30
  tunnel: { type: "boolean", default: false },
29
31
  stats: { type: "boolean", default: false },
30
32
  "data-root": { type: "string" },
31
- client: { type: "string" },
33
+ client: { type: "string", short: "c" },
32
34
  server: { type: "string" },
33
35
  port: { type: "string" },
34
36
  android: { type: "boolean", default: false },
@@ -38,23 +40,41 @@ export let { values, positionals } = parseArgs({
38
40
  allowPositionals: true,
39
41
  })
40
42
 
41
- // Storage flags for a locally spawned client, forwarded only when given: with
42
- // no --data-root the client resolves its platform pref path (see
43
- // lattice/src/storage.rs); --client <N> selects the client<N>/ tree under
44
- // either root. An explicit root is passed absolute because the client chdirs
45
- // into its app sandbox at startup.
43
+ export const DEFAULT_DEV_PORT = 0x8844
44
+
45
+ // The session number: -s/--session <N> selects the dev server (port
46
+ // DEFAULT_DEV_PORT + N, see dev-server.ts resolveDevPort) and the default
47
+ // client slot. Resolved at load, like the port.
48
+ function resolveSession(): number {
49
+ let raw = values.session
50
+ if (raw === undefined) return 0
51
+ let n = Number(raw)
52
+ if (!/^\d+$/.test(raw) || DEFAULT_DEV_PORT + n > 65535) {
53
+ console.error(
54
+ `Invalid --session value "${raw}": expected a non-negative integer with ${DEFAULT_DEV_PORT} + N at most 65535`,
55
+ )
56
+ process.exit(1)
57
+ }
58
+ return n
59
+ }
60
+ export let session = resolveSession()
61
+
62
+ // Storage flags for a locally spawned client. Dev client trees live in
63
+ // ~/.solidrt/clients/client<M>/, reached through --data-root so the client
64
+ // runtime keeps its single pref-path default rule (see lattice/src/storage.rs
65
+ // and okf/backlog/parallel-dev-servers.md); an explicit --data-root wins. The
66
+ // client slot defaults to the session number, so each session gets its own
67
+ // tree with a single flag. Roots are passed absolute because the client
68
+ // chdirs into its app sandbox at startup.
46
69
  export function clientStorageArgs(): string[] {
47
- let args: string[] = []
48
70
  let root = values["data-root"]
49
- if (root) args.push("--data-root", resolve(root))
50
- let client = values.client
51
- if (client !== undefined) {
52
- if (!/^\d+$/.test(client)) {
53
- console.error(`Invalid --client value "${client}": expected a non-negative integer`)
54
- process.exit(1)
55
- }
56
- args.push("--client", client)
71
+ let args = ["--data-root", root ? resolve(root) : clientsRoot()]
72
+ let client = values.client ?? String(session)
73
+ if (!/^\d+$/.test(client)) {
74
+ console.error(`Invalid --client value "${client}": expected a non-negative integer`)
75
+ process.exit(1)
57
76
  }
77
+ args.push("--client", client)
58
78
  return args
59
79
  }
60
80
 
@@ -111,6 +131,17 @@ export function validateArgs() {
111
131
  if (values.port !== undefined && command !== "run" && command !== "server" && command !== "mcp") {
112
132
  usage("srt <run|server|mcp> --port <N> (--port is only valid with the run, server and mcp commands)")
113
133
  }
134
+ // --session selects a dev server (and the default client slot), so it is
135
+ // valid wherever a dev server is started, attached to, or resolved.
136
+ if (
137
+ values.session !== undefined &&
138
+ command !== "run" &&
139
+ command !== "server" &&
140
+ command !== "client" &&
141
+ command !== "mcp"
142
+ ) {
143
+ usage("srt <run|server|client|mcp> -s <N> (--session is only valid with the run, server, client and mcp commands)")
144
+ }
114
145
  }
115
146
 
116
147
  export function printUsage() {
@@ -131,7 +162,8 @@ init options:
131
162
  -t, --template <name> Start from a named template (skips the interactive picker)
132
163
 
133
164
  run/server options:
134
- --port <N> Dev server port (default: 34884)
165
+ -s, --session <N> Session number: dev server on port 34884+N, client slot N (default: 0)
166
+ --port <N> Dev server port (default: 34884 + session)
135
167
  --proxy-http Route fetch calls through the dev server (HTTP cache enabled)
136
168
  --capture <file> Record connected clients' key events to a script file
137
169
  --tunnel Accept ticket-paired clients through the p2p tunnel
@@ -140,22 +172,25 @@ run/server options:
140
172
  run/client options:
141
173
  --size <WxH> Window size (default: 1280x720)
142
174
  --stats Show the debug stats overlay (FPS, memory, frame timings)
143
- --data-root <dir> Client data root (default: the platform pref path)
144
- --client <N> Client number: its own data tree under the data root (default: 0)
175
+ --data-root <dir> Client data root (default: ~/.solidrt/clients)
176
+ -c, --client <N> Client number: its own data tree under the data root (default: the session)
145
177
 
146
178
  client options:
147
- --server <host[:port]> Connect to a dev server at this address (default port: 34884)
179
+ -s, --session <N> Connect to this session's dev server on this machine
180
+ (127.0.0.1:34884+N); without it, start on the connect screen
181
+ --server <host[:port]> Connect to a dev server at this address (default port: 34884 + session)
148
182
  --android Install and launch the client on a connected Android device
149
183
  --device <serial> Target a specific adb device by serial or unique prefix
150
184
 
151
185
  mcp options:
152
- --port <N> Port of the dev server to attach to (default: 34884)
186
+ -s, --session <N> Attach to the dev server of this session (default: resolve by project)
187
+ --port <N> Port of the dev server to attach to (default: resolve by project)
153
188
 
154
189
  bundle options:
155
190
  -f, --flux Bundle for the bare Flux runtime, without SolidJS (entry must be .ts|.js)
156
191
  -d, --dev Use development build of SolidJS (default: production)
157
192
  -m, --minify Minify the output
158
- -c, --compile Compile to bytecode
193
+ --compile Compile to bytecode
159
194
  -o, --output <name> Output filename
160
195
  --stdout Write bundle to stdout
161
196
 
package/src/bundle-cli.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  // Standalone bundler entry, spawned by the dev server (a flux process) as a
2
2
  // Bun subprocess to rebuild the app on an MCP-triggered reload. flux cannot call
3
3
  // Bun.build, so the server shells out to this. Params arrive as one JSON
4
- // argument; one JSON object { code, map } goes to stdout and diagnostics to
5
- // stderr. On a build failure it exits non-zero with an empty stdout.
4
+ // argument; one JSON object { code, map, manifest, isolates } goes to stdout
5
+ // and diagnostics to stderr. On a build failure it exits non-zero with an
6
+ // empty stdout.
6
7
 
7
8
  import { bundleWith, type BundleOptions } from "./bundler"
8
9
 
package/src/bundler.ts CHANGED
@@ -4,11 +4,11 @@ import ts from "@babel/preset-typescript"
4
4
  import remapping from "@jridgewell/remapping"
5
5
  import solid from "babel-preset-solid"
6
6
  import { type BunPlugin, type BuildArtifact } from "bun"
7
- import { readFileSync } from "node:fs"
8
- import { dirname, resolve as resolvePath } from "node:path"
7
+ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
8
+ import { dirname, join, relative, resolve as resolvePath, sep } from "node:path"
9
9
  import { values, source } from "./args"
10
10
  import { state, print, requireBinary } from "./util"
11
- import { buildManifest } from "./project"
11
+ import { buildManifest, manifestAssetFor } from "./project"
12
12
 
13
13
  // Babel plugin: rewrite `import data from "./x" with { type: "binary" }` into an
14
14
  // inline Uint8Array of the file's bytes, and `with { type: "text" }` into an
@@ -82,7 +82,10 @@ async function codeFromOutputs(outputs: BuildArtifact[]): Promise<string> {
82
82
  // (node_modules) skips the babel detour and keeps Bun's native loaders.
83
83
  // With `babelMaps`, each file's transform map (original -> babel output) is
84
84
  // collected there, keyed by absolute path, for sourcemap composition later.
85
- function solidPlugin(babelMaps?: Map<string, object>): BunPlugin {
85
+ // `isolateEntry` is the one "use isolate" module this build may load (its own
86
+ // entry); loading any other one means a by-value import of an isolate module,
87
+ // which is a build error (see isolate modules below).
88
+ function solidPlugin(babelMaps?: Map<string, object>, isolateEntry?: string): BunPlugin {
86
89
  return {
87
90
  name: "bun-plugin-solid",
88
91
  setup: (build) => {
@@ -90,6 +93,11 @@ function solidPlugin(babelMaps?: Map<string, object>): BunPlugin {
90
93
  if (!/\.(js|ts)x$/.test(args.path) && args.path.includes("node_modules")) return
91
94
  let file = Bun.file(args.path)
92
95
  let code = await file.text()
96
+ if (args.path !== isolateEntry && hasIsolateDirective(code)) {
97
+ throw new Error(
98
+ `${args.path} is a "use isolate" module: import its types only (import type * as W from "./...") and call it through isolate() from flux:isolate`,
99
+ )
100
+ }
93
101
  let transforms = await transformAsync(code, {
94
102
  filename: args.path,
95
103
  sourceMaps: !!babelMaps,
@@ -103,6 +111,53 @@ function solidPlugin(babelMaps?: Map<string, object>): BunPlugin {
103
111
  }
104
112
  }
105
113
 
114
+ // Isolate modules (okf/done/isolates-and-ports.md): a source file whose first
115
+ // statement is the "use isolate" directive is the entry of its own bundle,
116
+ // run by flux:isolate in a second runtime. Its id is its path relative to
117
+ // the source root (the entry's directory) without extension; the bundle
118
+ // travels as the manifest asset isolates/<id>.js (dev) or .bin (pack). The
119
+ // main build never loads such a module (only `import type` reaches it), so
120
+ // the set is found by scanning the tree rather than by following imports.
121
+
122
+ // The directive is the first statement: leading whitespace, comments and a
123
+ // shebang may precede it, nothing else.
124
+ let ISOLATE_DIRECTIVE = /^(?:#![^\n]*\n)?(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*(?:"use isolate"|'use isolate')\s*(?:;|\n|$)/
125
+
126
+ export function hasIsolateDirective(code: string): boolean {
127
+ return ISOLATE_DIRECTIVE.test(code)
128
+ }
129
+
130
+ let SKIP_DIRS = new Set(["node_modules", "dist"])
131
+
132
+ export type IsolateModule = { id: string; path: string }
133
+
134
+ /** Every "use isolate" module under `root`, in id order. */
135
+ export function findIsolateModules(root: string): IsolateModule[] {
136
+ let out: IsolateModule[] = []
137
+ let walk = (dir: string) => {
138
+ for (let entry of readdirSync(dir, { withFileTypes: true })) {
139
+ if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue
140
+ let abs = join(dir, entry.name)
141
+ if (entry.isDirectory()) {
142
+ walk(abs)
143
+ } else if (entry.isFile() && /\.(js|ts)x?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
144
+ if (hasIsolateDirective(readFileSync(abs, "utf8"))) {
145
+ let id = relative(root, abs).split(sep).join("/").replace(/\.(js|ts)x?$/, "")
146
+ out.push({ id, path: abs })
147
+ }
148
+ }
149
+ }
150
+ }
151
+ walk(root)
152
+ out.sort((a, b) => (a.id < b.id ? -1 : 1))
153
+ return out
154
+ }
155
+
156
+ /** The manifest asset path of an isolate bundle. */
157
+ export function isolateAssetPath(id: string, ext: "js" | "bin"): string {
158
+ return `isolates/${id}.${ext}`
159
+ }
160
+
106
161
  export type BundleOptions = { entry: string; devBase?: string; dev: boolean; minify: boolean }
107
162
 
108
163
  export type BundleResult = {
@@ -111,6 +166,8 @@ export type BundleResult = {
111
166
  map: string | null
112
167
  /** Version manifest JSON for this bundle; clients install pushes under its hash. */
113
168
  manifest: string
169
+ /** The app's isolate bundles, one per "use isolate" module, in id order. */
170
+ isolates: { id: string; code: string }[]
114
171
  }
115
172
 
116
173
  // The pure bundle: every input is explicit, so it runs identically in the srt
@@ -130,32 +187,66 @@ export async function bundleWith(opts: BundleOptions): Promise<BundleResult | nu
130
187
  }
131
188
  if (opts.devBase) define.__SRT_DEV_BASE__ = opts.devBase
132
189
 
190
+ // One Bun.build per entry: the app, then each isolate module as its own
191
+ // self-contained bundle (splitting is off, so a helper both import gets
192
+ // duplicated rather than shared). Only the app's build gets a composed
193
+ // sourcemap for now.
194
+ let build = async (entry: string, babelMaps?: Map<string, object>, isolateEntry?: string) => {
195
+ let result = null
196
+ try {
197
+ result = await Bun.build({
198
+ entrypoints: [entry],
199
+ target: "browser",
200
+ format: "esm",
201
+ minify: opts.minify,
202
+ external: ["flux:*", "srt:*"],
203
+ define,
204
+ loader: { ".svg": "text" },
205
+ sourcemap: babelMaps ? "external" : "none",
206
+ plugins: [solidPlugin(babelMaps, isolateEntry)],
207
+ })
208
+ } catch (e) {
209
+ console.error("[cli] compile error:\n", e)
210
+ return null
211
+ }
212
+ if (!result.success) {
213
+ for (let msg of result.logs) console.error(msg)
214
+ return null
215
+ }
216
+ return result
217
+ }
218
+
133
219
  let babelMaps = opts.dev ? new Map<string, object>() : undefined
134
- let result = null
135
- try {
136
- result = await Bun.build({
137
- entrypoints: [opts.entry],
138
- target: "browser",
139
- format: "esm",
140
- minify: opts.minify,
141
- external: ["flux:*", "srt:*"],
142
- define,
143
- loader: { ".svg": "text" },
144
- sourcemap: opts.dev ? "external" : "none",
145
- plugins: [solidPlugin(babelMaps)],
146
- })
147
- } catch (e) {
148
- console.error("[cli] compile error:\n", e)
149
- return null
220
+ let main = await build(opts.entry, babelMaps)
221
+ if (!main) return null
222
+ let code = await codeFromOutputs(main.outputs)
223
+
224
+ let isolates: { id: string; code: string }[] = []
225
+ for (let module of findIsolateModules(dirname(resolvePath(opts.entry)))) {
226
+ let result = await build(module.path, undefined, module.path)
227
+ if (!result) return null
228
+ isolates.push({ id: module.id, code: await codeFromOutputs(result.outputs) })
150
229
  }
151
230
 
152
- if (!result.success) {
153
- for (let msg of result.logs) console.error(msg)
154
- return null
231
+ let extra = isolates.map((i) => manifestAssetFor(isolateAssetPath(i.id, "js"), Buffer.from(i.code, "utf8")))
232
+ return {
233
+ code,
234
+ map: await composeMap(main.outputs, babelMaps),
235
+ manifest: buildManifest(code, opts.entry, extra),
236
+ isolates,
155
237
  }
238
+ }
156
239
 
157
- let code = await codeFromOutputs(result.outputs)
158
- return { code, map: await composeMap(result.outputs, babelMaps), manifest: buildManifest(code, opts.entry) }
240
+ // Write dev isolate bundles where the dev server serves /isolates/ from
241
+ // (<project>/.srt-data/isolates/<id>.js), so clients can fetch the manifest
242
+ // assets the bundle lists. Stale files from removed modules stay behind
243
+ // unlisted, which is harmless.
244
+ export function writeIsolates(dir: string, isolates: { id: string; code: string }[]) {
245
+ for (let i of isolates) {
246
+ let file = join(dir, `${i.id}.js`)
247
+ mkdirSync(dirname(file), { recursive: true })
248
+ writeFileSync(file, i.code)
249
+ }
159
250
  }
160
251
 
161
252
  // Compose Bun's bundle map (babel output -> bundle) with the per-file Babel
@@ -185,7 +276,15 @@ export async function bundle(entry = source) {
185
276
  let dev = !!devBase || values.dev
186
277
  // Keep stdout clean when the bundle itself is written to stdout.
187
278
  if (!values.stdout) print(`[cli] Bundling (${dev ? "development" : "production"})`)
188
- return bundleWith({ entry: entry!, devBase, dev, minify: values.minify })
279
+ let result = await bundleWith({ entry: entry!, devBase, dev, minify: values.minify })
280
+ // With a server running, its /isolates/ route serves what we write here.
281
+ if (result && devBase) writeIsolates(devIsolatesDir(state.projectDir), result.isolates)
282
+ return result
283
+ }
284
+
285
+ /** Where a project's dev isolate bundles are written and served from. */
286
+ export function devIsolatesDir(projectDir: string): string {
287
+ return join(projectDir, ".srt-data", "isolates")
189
288
  }
190
289
 
191
290
  export async function bundleTo(outfile: string) {
@@ -216,13 +315,13 @@ export async function bundleFlux(entry: string): Promise<string> {
216
315
  }
217
316
 
218
317
  // Bundle for the SolidRT runtime via the standard Solid-aware bundler.
219
- export async function bundleSolid(): Promise<string> {
318
+ export async function bundleSolid(): Promise<BundleResult> {
220
319
  let result = await bundle()
221
320
  if (!result) {
222
321
  console.error("Build failed")
223
322
  process.exit(1)
224
323
  }
225
- return result.code
324
+ return result
226
325
  }
227
326
 
228
327
  // Compile JS source to QuickJS bytecode via the fluxc binary.
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, rmSync } from "node:fs"
2
2
  import { dirname, join, resolve } from "node:path"
3
3
  import { source } from "../args"
4
- import { bundleWith } from "../bundler"
4
+ import { bundleWith, findIsolateModules } from "../bundler"
5
5
 
6
6
  // srt check: verify the app without side effects. Bundles in memory (nothing
7
7
  // written, so no dev-server reload fires and no build outputs land in the
@@ -86,7 +86,11 @@ export async function typecheck(root: string, entry: string): Promise<{ app: Dia
86
86
  // applies precisely because nothing imports it, so entry-only rooting would
87
87
  // silently drop it and every asset import would fail with TS2307. The
88
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)] }))
89
+ // Isolate modules are program roots of their own: main reaches them by
90
+ // `import type` at most, and one nothing imports would otherwise go
91
+ // unchecked.
92
+ let files = [resolve(entry), ...findIsolateModules(dirname(resolve(entry))).map((m) => m.path)]
93
+ await Bun.write(config, JSON.stringify({ extends: tsconfig, include: ["../**/*.d.ts"], files }))
90
94
  try {
91
95
  let proc = Bun.spawn([tsc, "-p", config, "--noEmit", "--pretty", "false"], {
92
96
  cwd: root,
@@ -1,13 +1,14 @@
1
1
  import { values, clientStorageArgs } from "../args"
2
2
  import { requireBinary, run } from "../util"
3
3
  import { spawnAndroidClient } from "../dev-android"
4
- import { DEV_PORT } from "../dev-server"
4
+ import { DEV_HOST, DEV_PORT } from "../dev-server"
5
5
 
6
6
  // Standalone solidrt-go client (no dev server). The `run` command instead uses
7
7
  // spawnClient() to launch a client tied to the dev-server lifecycle. --server
8
- // auto-connects to a dev server at the given address (otherwise the client
9
- // starts on the connect screen); with --android it is installed and launched
10
- // on a connected Android device instead of run locally.
8
+ // auto-connects to a dev server at the given address, and -s <N> is its
9
+ // shorthand for the session's server on this machine; without either, the
10
+ // client starts on the connect screen. With --android it is installed and
11
+ // launched on a connected Android device instead of run locally.
11
12
  export async function runClientCommand() {
12
13
  if (values.android) {
13
14
  await spawnAndroidClient()
@@ -17,10 +18,17 @@ export async function runClientCommand() {
17
18
  let runner = requireBinary("solidrt-go")
18
19
  let args: string[] = [...clientStorageArgs()]
19
20
  if (values.size) args.push("--size", values.size)
20
- if (values.server) {
21
- let address = values.server.includes(":") ? values.server : `${values.server}:${DEV_PORT}`
22
- args.push("--dev-server", address)
23
- }
21
+ // Both flags resolve to the one address the client understands. --server
22
+ // wins: an explicit host is never overridden by a session number, which
23
+ // only ever names a loopback port.
24
+ let address = values.server
25
+ ? values.server.includes(":")
26
+ ? values.server
27
+ : `${values.server}:${DEV_PORT}`
28
+ : values.session !== undefined
29
+ ? `${DEV_HOST}:${DEV_PORT}`
30
+ : null
31
+ if (address) args.push("--dev-server", address)
24
32
  let exit = await run(runner, args)
25
33
  process.exit(exit)
26
34
  }
@@ -9,14 +9,116 @@ import { z } from "zod"
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
11
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
12
- import { resolve } from "node:path"
12
+ import { dirname, join, resolve } from "node:path"
13
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
14
+ import { values } from "../args"
13
15
  import { DEV_PORT } from "../dev-server"
16
+ import { devDir } from "../dev-dir"
14
17
 
15
- const CONTROL_BASE = `http://127.0.0.1:${DEV_PORT}/__control__`
18
+ // An explicit -s/--port pins the port for the bridge's lifetime. Otherwise
19
+ // the port is resolved per tool call from the server registry, so one bridge
20
+ // (started when the workspace opens, kept alive across server restarts)
21
+ // follows whichever server is currently serving this project - and the
22
+ // scaffold's mcp.json never carries a port.
23
+ const FIXED_PORT = values.port !== undefined || values.session !== undefined ? DEV_PORT : null
24
+
25
+ // The projectDir the bridge is working in: the nearest package.json above its
26
+ // own cwd, the same rule srt applies to an entry (project.ts projectDirFor),
27
+ // so both sides derive the same string.
28
+ function findProjectDir(): string | null {
29
+ let dir = process.cwd()
30
+ while (true) {
31
+ if (existsSync(join(dir, "package.json"))) return dir
32
+ let parent = dirname(dir)
33
+ if (parent === dir) return null
34
+ dir = parent
35
+ }
36
+ }
37
+
38
+ function pidAlive(pid: number): boolean {
39
+ try {
40
+ process.kill(pid, 0)
41
+ return true
42
+ } catch {
43
+ return false
44
+ }
45
+ }
46
+
47
+ type LiveRecord = { pid: number; port: number; projectDir: string }
48
+
49
+ // The global server registry: every running dev server keeps a live.json in
50
+ // ~/.solidrt/servers/<port>/ (see dev-server.ts writeLiveRecord). Unreadable or
51
+ // malformed records are skipped, not fatal - the registry is a hint.
52
+ function liveRecords(): LiveRecord[] {
53
+ let root = devDir("servers")
54
+ let names: string[]
55
+ try {
56
+ names = readdirSync(root)
57
+ } catch {
58
+ return []
59
+ }
60
+ let records: LiveRecord[] = []
61
+ for (let name of names) {
62
+ try {
63
+ let record = JSON.parse(readFileSync(join(root, name, "live.json"), "utf8"))
64
+ if (typeof record?.pid === "number" && typeof record?.port === "number" && typeof record?.projectDir === "string") {
65
+ records.push(record)
66
+ }
67
+ } catch {}
68
+ }
69
+ return records
70
+ }
71
+
72
+ type PortResult = { ok: true; port: number } | { ok: false; message: string }
73
+
74
+ async function resolvePort(): Promise<PortResult> {
75
+ if (FIXED_PORT !== null) return { ok: true, port: FIXED_PORT }
76
+ let project = findProjectDir()
77
+ if (!project) {
78
+ return {
79
+ ok: false,
80
+ message: `No package.json found above ${process.cwd()}, so no dev server can be resolved by project. Pass -s <N> or --port <N> to srt mcp.`,
81
+ }
82
+ }
83
+ let matches = liveRecords().filter((r) => r.projectDir === project && pidAlive(r.pid))
84
+ if (matches.length > 1) {
85
+ let ports = matches
86
+ .map((r) => r.port)
87
+ .sort((a, b) => a - b)
88
+ .join(", ")
89
+ return { ok: false, message: `${matches.length} dev servers are serving this project (ports ${ports}); pass -s <N> to srt mcp` }
90
+ }
91
+ if (matches.length === 0) {
92
+ return { ok: false, message: `No dev server for ${project}. Start one with srt run, or pass -s <N> to srt mcp.` }
93
+ }
94
+ let port = matches[0]!.port
95
+ // The record is a hint; the server is authoritative. The probe catches a
96
+ // stale record whose pid was reused by an unrelated process.
97
+ try {
98
+ let probe = await fetch(`http://127.0.0.1:${port}/__control__/clients`)
99
+ let body: any = await probe.json().catch(() => null)
100
+ if (!probe.ok || body?.projectDir !== project) {
101
+ return {
102
+ ok: false,
103
+ message: `The server on port ${port} is not serving ${project}${
104
+ typeof body?.projectDir === "string" ? ` (it serves ${body.projectDir})` : ""
105
+ }. Start one with srt run, or pass -s <N> to srt mcp.`,
106
+ }
107
+ }
108
+ } catch {
109
+ return {
110
+ ok: false,
111
+ message: `No dev server for ${project}: the registry lists port ${port} but nothing answers there. Start one with srt run.`,
112
+ }
113
+ }
114
+ return { ok: true, port }
115
+ }
16
116
 
17
117
  type ControlResult = { ok: true; body: any } | { ok: false; message: string }
18
118
 
19
119
  async function control(path: string, method: "GET" | "POST" = "GET", payload?: unknown): Promise<ControlResult> {
120
+ let resolved = await resolvePort()
121
+ if (!resolved.ok) return resolved
20
122
  let resp
21
123
  try {
22
124
  let init: RequestInit = { method }
@@ -24,7 +126,7 @@ async function control(path: string, method: "GET" | "POST" = "GET", payload?: u
24
126
  init.headers = { "content-type": "application/json" }
25
127
  init.body = JSON.stringify(payload)
26
128
  }
27
- resp = await fetch(CONTROL_BASE + path, init)
129
+ resp = await fetch(`http://127.0.0.1:${resolved.port}/__control__${path}`, init)
28
130
  } catch {
29
131
  return {
30
132
  ok: false,
@@ -41,8 +41,12 @@ export async function runPackCommand() {
41
41
  let fonts = resolvePackFonts(source!)
42
42
  console.log(`>> fonts: ${fonts.length ? fonts.map((f) => f.alias).join(", ") : "none"}`)
43
43
 
44
- let bytecode = await compileToBytecode(await bundleSolid())
45
- let folder = buildPackFolder(source!, bytecode)
44
+ let bundled = await bundleSolid()
45
+ let bytecode = await compileToBytecode(bundled.code)
46
+ let isolates = []
47
+ for (let i of bundled.isolates) isolates.push({ id: i.id, bytecode: await compileToBytecode(i.code) })
48
+ if (isolates.length) console.log(`>> isolates: ${isolates.map((i) => i.id).join(", ")}`)
49
+ let folder = buildPackFolder(source!, bytecode, isolates)
46
50
 
47
51
  if (values.folder) {
48
52
  let outDir = values.output ?? "dist"
@@ -16,7 +16,10 @@ export async function runServerCommand() {
16
16
  // Initialize state from args
17
17
  state.source = source
18
18
  state.sourceDir = source ? dirname(resolve(source)) : process.cwd()
19
- state.projectDir = source ? projectDirFor(resolve(source)) : process.cwd()
19
+ // With no entry the project is wherever srt was started: walk up to the
20
+ // nearest package.json exactly like an entry would, so the projectDir the
21
+ // MCP bridge derives for its registry match agrees with ours.
22
+ state.projectDir = projectDirFor(source ? resolve(source) : resolve("package.json"))
20
23
  state.stats = values.stats
21
24
  state.capture = values.capture ? resolve(values.capture) : undefined
22
25
 
package/src/dev-dir.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+
4
+ // The folder name is deliberately isolated here: one switch point if it ever
5
+ // changes or becomes configurable.
6
+ const DEV_DIR_NAME = ".solidrt"
7
+
8
+ // All dev-tooling state lives in one home dotdir, one rule on every platform
9
+ // (okf/backlog/parallel-dev-servers.md): servers/<port>/ holds a dev server's
10
+ // identity (tunnel.key) and its registry record (live.json), clients/ is the
11
+ // data root srt passes for every locally spawned client, so dev client trees
12
+ // land in clients/client<M>/. Deleting the dir resets every bit of dev state.
13
+ export function devDir(...parts: string[]): string {
14
+ return join(homedir(), DEV_DIR_NAME, ...parts)
15
+ }
16
+
17
+ /** `servers/<port>/` under the dev dir - a dev server's identity and registry record, keyed by port. */
18
+ export function serverDir(port: number): string {
19
+ return devDir("servers", String(port))
20
+ }
21
+
22
+ /** `clients/` under the dev dir - the --data-root for locally spawned dev clients. */
23
+ export function clientsRoot(): string {
24
+ return devDir("clients")
25
+ }
package/src/dev-server.ts CHANGED
@@ -1,18 +1,21 @@
1
- import { resolve } from "path"
1
+ import { resolve, join } from "path"
2
2
  import { tmpdir, networkInterfaces } from "node:os"
3
3
  import { fileURLToPath } from "node:url"
4
+ import { mkdirSync, writeFileSync, unlinkSync } from "node:fs"
4
5
  import { state, print, printErr, requireBinary, pipeAbovePrompt, shutdown } from "./util"
5
- import { appArgs, values } from "./args"
6
+ import { appArgs, values, session, DEFAULT_DEV_PORT } from "./args"
7
+ import { serverDir } from "./dev-dir"
6
8
 
7
9
  export const DEV_HOST = "127.0.0.1"
8
- export const DEFAULT_DEV_PORT = 0x8844
9
10
 
10
11
  // The port every dev-server consumer dials: the spawned server, the local and
11
12
  // 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
+ // Resolved once here, so --port/--session need no threading through those
14
+ // call sites. An explicit --port wins over the session; the server folder is
15
+ // keyed by the port actually bound either way.
13
16
  function resolveDevPort(): number {
14
17
  let raw = values.port
15
- if (raw === undefined) return DEFAULT_DEV_PORT
18
+ if (raw === undefined) return DEFAULT_DEV_PORT + session
16
19
  let port = Number(raw)
17
20
  if (!/^\d+$/.test(raw) || port < 1 || port > 65535) {
18
21
  console.error(`Invalid --port value "${raw}": expected a port number between 1 and 65535`)
@@ -58,10 +61,10 @@ async function post(path: string, body: object) {
58
61
  * Send a client-protocol message through the server: to the given client ids,
59
62
  * or to every client when omitted. `latch` keeps the message for late-joining
60
63
  * clients (code reloads latch, one-shot bytecode loads do not); `sourceDir`
61
- * moves the server's file-serving root and `projectDir` its /assets/ root
62
- * (repl `load`); `map` is the bundle's sourcemap, kept server-side for
63
- * stack-trace remapping (omitting it clears the server's map, so a mapless
64
- * reload never remaps against a stale one).
64
+ * moves the server's file-serving root (repl `load`; the project root is
65
+ * fixed for the life of the run); `map` is the bundle's sourcemap, kept
66
+ * server-side for stack-trace remapping (omitting it clears the server's map,
67
+ * so a mapless reload never remaps against a stale one).
65
68
  */
66
69
  export async function sendReload(
67
70
  message: object,
@@ -69,7 +72,6 @@ export async function sendReload(
69
72
  clients?: number[]
70
73
  latch?: boolean
71
74
  sourceDir?: string
72
- projectDir?: string
73
75
  entry?: string
74
76
  map?: string | null
75
77
  } = {},
@@ -185,15 +187,41 @@ function requireFreePort(port: number) {
185
187
  let probe = Bun.serve({ port, fetch: () => new Response() })
186
188
  probe.stop(true)
187
189
  } catch {
188
- printErr(`[cli] Port ${port} is already in use; start on another port with --port <N>`)
190
+ printErr(`[cli] Port ${port} is already in use; start on another session with -s <N> (or --port <P>)`)
189
191
  process.exit(1)
190
192
  }
191
193
  }
192
194
 
195
+ // The server's registry record: written once the server answers, removed at
196
+ // exit, so MCP bridges can resolve a project to a port without any per-project
197
+ // config (okf/backlog/parallel-dev-servers.md). The pid is the flux server's
198
+ // (the process owning the port), so a record left behind by a crash fails the
199
+ // bridge's liveness check; the record is a hint either way - the bridge's
200
+ // /__control__/clients probe is authoritative.
201
+ function writeLiveRecord() {
202
+ let record = {
203
+ pid: state.serverProc?.pid,
204
+ port: DEV_PORT,
205
+ projectDir: state.projectDir,
206
+ entry: state.source ?? null,
207
+ started: new Date().toISOString(),
208
+ }
209
+ writeFileSync(join(serverDir(DEV_PORT), "live.json"), JSON.stringify(record))
210
+ }
211
+
212
+ function removeLiveRecord() {
213
+ try {
214
+ unlinkSync(join(serverDir(DEV_PORT), "live.json"))
215
+ } catch {}
216
+ }
217
+
193
218
  export async function startServer() {
194
219
  let flux = requireBinary("flux")
195
220
  requireFreePort(DEV_PORT)
196
221
  let script = await bundleServer()
222
+ // The server's own folder (tunnel.key lands there, written by the flux
223
+ // process, which does not create directories).
224
+ mkdirSync(serverDir(DEV_PORT), { recursive: true })
197
225
 
198
226
  let lanAddress = Object.values(networkInterfaces())
199
227
  .flat()
@@ -220,8 +248,8 @@ export async function startServer() {
220
248
  minify: values.minify,
221
249
  bundlerCmd: [process.execPath, bundleCli],
222
250
  cache: values["proxy-http"],
223
- cacheDir: resolve(".srt-data"),
224
- keyDir: process.cwd(),
251
+ cacheDir: resolve(state.projectDir, ".srt-data"),
252
+ keyDir: serverDir(DEV_PORT),
225
253
  capture: state.capture,
226
254
  stats: state.stats,
227
255
  tunnel: values.tunnel,
@@ -257,6 +285,11 @@ export async function startServer() {
257
285
  }
258
286
  }
259
287
 
288
+ writeLiveRecord()
289
+ // shutdown() exits via process.exit, so the exit hook covers every orderly
290
+ // path; only a kill -9 leaves the record behind, for the pid check to catch.
291
+ process.on("exit", removeLiveRecord)
292
+
260
293
  // mDNS advertise (dropped, code kept for future use - see
261
294
  // docs/flux-dev-server-plan.md): the p2p ticket is the cross-device connect
262
295
  // story now. If advertise returns, it belongs next to the server (a flux
@@ -10,6 +10,8 @@ import {
10
10
  type ManifestFont,
11
11
  } from "./project"
12
12
  import { resolvePackFonts } from "./fonts"
13
+ import { runnerGlLibs } from "./packer"
14
+ import { isolateAssetPath } from "./bundler"
13
15
 
14
16
  // The canonical flat pack folder (okf/plans/client-storage-updates.md, Pack
15
17
  // output): runner + manifest.json + bundle.bin + assets/. The manifest
@@ -32,13 +34,20 @@ export type PackFolder = {
32
34
  manifest: string
33
35
  /** Files to place in the folder: absolute source -> folder-relative path. */
34
36
  copies: Array<{ from: string; to: string }>
37
+ /** Build outputs to place in the folder (isolate bytecode): folder-relative path + bytes. */
38
+ files: Array<{ to: string; bytes: Buffer }>
35
39
  }
36
40
 
37
- export function buildPackFolder(entry: string, bytecode: Buffer): PackFolder {
41
+ // `isolates` are the app's isolate bundles compiled to bytecode; they ship as
42
+ // the manifest assets isolates/<id>.bin (the production runtime has no
43
+ // compiler, so pack never ships isolate source).
44
+ export function buildPackFolder(entry: string, bytecode: Buffer, isolates: { id: string; bytecode: Buffer }[]): PackFolder {
38
45
  let identity = loadAppIdentity(entry)
39
46
  let projectDir = projectDirFor(resolve(entry))
40
47
  let { assets, icon } = collectAssets(entry)
41
48
  let copies = assets.map((a) => ({ from: join(projectDir, a.path), to: a.path }))
49
+ let files = isolates.map((i) => ({ to: isolateAssetPath(i.id, "bin"), bytes: i.bytecode }))
50
+ for (let f of files) assets.push({ path: f.to, sha256: hashHex(f.bytes), size: f.bytes.length })
42
51
 
43
52
  // The full resolved font set: custom fonts are already collected assets;
44
53
  // defaults materialize under assets/fonts/ (a user file already at that
@@ -77,7 +86,7 @@ export function buildPackFolder(entry: string, bytecode: Buffer): PackFolder {
77
86
  ...(assets.length ? { assets } : {}),
78
87
  ...(fonts.length ? { fonts } : {}),
79
88
  })
80
- return { manifest, copies }
89
+ return { manifest, copies, files }
81
90
  }
82
91
 
83
92
  /**
@@ -93,9 +102,11 @@ export function writePackFolder(outDir: string, runnerPath: string, bytecode: Bu
93
102
  }
94
103
 
95
104
  let runnerName = "solidrt" + (process.platform === "win32" ? ".exe" : "")
105
+ let glLibs = runnerGlLibs(runnerPath)
96
106
  mkdirSync(outDir, { recursive: true })
97
107
  rmSync(join(outDir, "assets"), { recursive: true, force: true })
98
- for (let name of ["manifest.json", "bundle.bin", runnerName]) {
108
+ rmSync(join(outDir, "isolates"), { recursive: true, force: true })
109
+ for (let name of ["manifest.json", "bundle.bin", runnerName, ...glLibs.map((lib) => lib.name)]) {
99
110
  rmSync(join(outDir, name), { force: true })
100
111
  }
101
112
 
@@ -105,6 +116,11 @@ export function writePackFolder(outDir: string, runnerPath: string, bytecode: Bu
105
116
  if (process.platform !== "win32") {
106
117
  Bun.spawnSync(["chmod", "+x", join(outDir, runnerName)])
107
118
  }
119
+ // The runner loads its GL libraries from next to itself; a folder pack must
120
+ // carry them like the platform package does.
121
+ for (let lib of glLibs) {
122
+ cpSync(lib.path, join(outDir, lib.name), { dereference: true })
123
+ }
108
124
  writeFileSync(join(outDir, "bundle.bin"), bytecode)
109
125
  writeFileSync(join(outDir, "manifest.json"), folder.manifest)
110
126
  for (let { from, to } of folder.copies) {
@@ -112,4 +128,9 @@ export function writePackFolder(outDir: string, runnerPath: string, bytecode: Bu
112
128
  mkdirSync(dirname(dest), { recursive: true })
113
129
  cpSync(from, dest)
114
130
  }
131
+ for (let { to, bytes } of folder.files) {
132
+ let dest = join(outDir, to)
133
+ mkdirSync(dirname(dest), { recursive: true })
134
+ writeFileSync(dest, bytes)
135
+ }
115
136
  }
package/src/packer.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { readFileSync } from "node:fs"
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { dirname, join } from "node:path"
2
3
  import { requireBinary } from "./util"
3
4
  import { compileToBytecode } from "./bundler"
4
5
  import type { PackFolder } from "./pack-folder"
@@ -14,6 +15,33 @@ const MAGIC = {
14
15
  // Section kinds in the solidrt trailer. Must match lattice/src/main.rs.
15
16
  const SECTION_MANIFEST = 1
16
17
  const SECTION_FILE = 2
18
+ const SECTION_GL_LIB = 3
19
+
20
+ // The GL libraries the runner needs next to it (or, single-file, embedded as
21
+ // kind-3 sections it extracts at boot): ANGLE's libraries on Windows and
22
+ // macOS, nothing on platforms with a system GL. Order matters and the runner
23
+ // preloads in section order: libGLESv2 must load before libEGL so libEGL's
24
+ // import of it resolves against the already-loaded module instead of a
25
+ // directory search.
26
+ const GL_LIB_NAMES: Partial<Record<NodeJS.Platform, string[]>> = {
27
+ win32: ["libGLESv2.dll", "libEGL.dll"],
28
+ darwin: ["libGLESv2.dylib", "libEGL.dylib"],
29
+ }
30
+
31
+ // The GL libraries shipped next to the runner binary, resolved to their paths.
32
+ // Missing files are fatal: a pack without them cannot create a window.
33
+ export function runnerGlLibs(runnerPath: string): Array<{ name: string; path: string }> {
34
+ let names = GL_LIB_NAMES[process.platform] ?? []
35
+ let dir = dirname(runnerPath)
36
+ return names.map((name) => {
37
+ let path = join(dir, name)
38
+ if (!existsSync(path)) {
39
+ console.error(`Could not find ${name} next to the runner (${dir}); the packed app needs it to create a GL context.`)
40
+ process.exit(1)
41
+ }
42
+ return { name, path }
43
+ })
44
+ }
17
45
 
18
46
  type Section = { kind: number; bytes: Buffer; name?: string }
19
47
 
@@ -48,13 +76,18 @@ function packSections(runnerBytes: Buffer, sections: Section[], magic: Buffer):
48
76
  // section form - the canonical manifest verbatim, then every manifest-listed
49
77
  // file named by its manifest path. Bundle, fonts, and identity all come from
50
78
  // the manifest; assets are read in place via ranged reads at their section
51
- // offsets, so nothing is unpacked at runtime.
79
+ // offsets, so nothing is unpacked at runtime. GL libraries ride along as
80
+ // kind-3 sections (runtime freight, deliberately outside the manifest); the
81
+ // runner extracts those to its cache and preloads them before window setup.
52
82
  export function packSolid(folder: PackFolder, bytecode: Buffer): Buffer {
53
- let runnerBytes = readFileSync(requireBinary("solidrt"))
83
+ let runnerPath = requireBinary("solidrt")
84
+ let runnerBytes = readFileSync(runnerPath)
54
85
  let sections: Section[] = [
55
86
  { kind: SECTION_MANIFEST, bytes: Buffer.from(folder.manifest, "utf8") },
56
87
  { kind: SECTION_FILE, bytes: bytecode, name: "bundle.bin" },
57
88
  ...folder.copies.map((c) => ({ kind: SECTION_FILE, bytes: readFileSync(c.from), name: c.to })),
89
+ ...folder.files.map((f) => ({ kind: SECTION_FILE, bytes: f.bytes, name: f.to })),
90
+ ...runnerGlLibs(runnerPath).map((lib) => ({ kind: SECTION_GL_LIB, bytes: readFileSync(lib.path), name: lib.name })),
58
91
  ]
59
92
  return packSections(runnerBytes, sections, MAGIC.solidrt)
60
93
  }
package/src/project.ts CHANGED
@@ -71,10 +71,13 @@ export const RUNTIME_VERSION = 1
71
71
  let pkgVersion = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).version
72
72
  export const SOLIDRT_VERSION: string = pkgVersion === "0.0.0" ? "unknown" : pkgVersion
73
73
 
74
- export function buildManifest(code: string, entry: string): string {
74
+ // `extra` are build outputs that ship as assets too (isolate bundles); they
75
+ // follow the assets/ tree in the list, in the order given.
76
+ export function buildManifest(code: string, entry: string, extra: ManifestAsset[] = []): string {
75
77
  let identity = loadAppIdentity(entry)
76
78
  let sha256 = new Bun.CryptoHasher("sha256").update(code).digest("hex")
77
79
  let { assets, fonts, icon } = collectAssets(entry)
80
+ assets.push(...extra)
78
81
  return JSON.stringify({
79
82
  appId: identity.appId,
80
83
  displayName: identity.displayName,
@@ -90,6 +93,11 @@ export function buildManifest(code: string, entry: string): string {
90
93
  export type ManifestAsset = { path: string; sha256: string; size: number }
91
94
  export type ManifestFont = { path: string; alias: string }
92
95
 
96
+ /** The manifest entry for in-memory asset bytes at `path`. */
97
+ export function manifestAssetFor(path: string, bytes: Uint8Array): ManifestAsset {
98
+ return { path, sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex"), size: bytes.length }
99
+ }
100
+
93
101
  // The project root the assets/ convention hangs off: the nearest package.json
94
102
  // dir, or the entry's own dir when there is none.
95
103
  export function projectDirFor(sourcePath: string): string {
package/src/repl.ts CHANGED
@@ -4,7 +4,7 @@ import { readdirSync } from "node:fs"
4
4
  import { state, print, printErr, shutdown } from "./util"
5
5
  import { buildReload, getClients, sendReload, sendStop, sendStats, sendWatch, showBuildFailure } from "./dev-server"
6
6
  import { bundle } from "./bundler"
7
- import { buildManifest, projectDirFor } from "./project"
7
+ import { buildManifest } from "./project"
8
8
  import { startWatcher, stopWatcher } from "./watcher"
9
9
 
10
10
  // Resolve repl client indexes ("0 2") against the server's client list,
@@ -101,6 +101,15 @@ async function cmdLoad(file: string) {
101
101
  return
102
102
  }
103
103
  let path = resolve(file)
104
+ // Same rule as /__control__/load (control.ts): a server run serves the
105
+ // project it started in, and an entry outside the project root cannot
106
+ // resolve the project's dependencies anyway.
107
+ let norm = (p: string) => p.replace(/\\/g, "/")
108
+ let root = norm(state.projectDir).replace(/\/+$/, "") + "/"
109
+ if (!norm(path).startsWith(root)) {
110
+ printErr(`[cli] Entry is outside the project root: ${path} is not under ${state.projectDir}. Restart srt in that project to work on it.`)
111
+ return
112
+ }
104
113
  if (file.endsWith(".tsx")) {
105
114
  let result = await bundle(path)
106
115
  if (!result) {
@@ -126,15 +135,14 @@ async function cmdLoad(file: string) {
126
135
  }
127
136
  state.source = path
128
137
  state.sourceDir = dirname(path)
129
- state.projectDir = projectDirFor(path)
130
138
  startWatcher()
131
- // The load also moves the server's file-serving root to the new source dir,
132
- // its /assets/ root to the new project dir, and its rebuild entry to the
133
- // new file (for a later MCP reload).
139
+ // The load also moves the server's file-serving root to the new source dir
140
+ // and its rebuild entry to the new file (for a later MCP reload). The
141
+ // project root - and with it the /assets/ root - is fixed for the life of
142
+ // the run.
134
143
  await sendReload(buildReload({ code: state.currentCode, manifest: state.currentManifest }), {
135
144
  latch: true,
136
145
  sourceDir: state.sourceDir,
137
- projectDir: state.projectDir,
138
146
  entry: file.endsWith(".tsx") ? path : undefined,
139
147
  map: state.currentMap,
140
148
  })