@solidrt/cli 0.0.48 → 0.0.50

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/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
  }
@@ -1,7 +1,7 @@
1
1
  import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises"
2
2
  import { basename, dirname, join, resolve } from "node:path"
3
3
  import { source, values } from "../args"
4
- import { select, text } from "../prompt"
4
+ import { multiselect, note, text } from "../prompt"
5
5
 
6
6
  const DEFAULT_NAME = "solidrt-app"
7
7
 
@@ -29,76 +29,57 @@ function packageName(dir: string): string {
29
29
  }
30
30
 
31
31
  const DEFAULT_TEMPLATE = "default"
32
- const TEMPLATE_MANIFEST = "template.json"
33
-
34
- // Each template's template.json declares which level the scaffolded app is
35
- // written at: "core" (only @solidrt/core, no component framework) or
36
- // "components" (built with @solidrt/components). The level decides the
37
- // generated dependencies; the description labels the template in the picker.
38
- interface TemplateInfo {
39
- name: string
40
- level: "core" | "components"
32
+
33
+ // Optional packages an app can opt into on top of core. Each maps to a
34
+ // dependency in the scaffold package.json (kept when selected, removed
35
+ // otherwise) and optionally to a starter under scaffold/templates/.
36
+ interface Extension {
37
+ pkg: string
38
+ template?: string
41
39
  description: string
42
40
  }
43
41
 
44
- // Templates are the directories under scaffold/templates/; each holds the files
45
- // that become the new project's src/, plus a template.json manifest. `default`
46
- // sorts first as the starting point, the rest alphabetically.
47
- async function listTemplates(): Promise<TemplateInfo[]> {
48
- let entries = await readdir(TEMPLATES_DIR, { withFileTypes: true })
49
- let names = entries
50
- .filter((e) => e.isDirectory())
51
- .map((e) => e.name)
52
- .sort((a, b) =>
53
- a === DEFAULT_TEMPLATE ? -1 : b === DEFAULT_TEMPLATE ? 1 : a.localeCompare(b),
54
- )
55
- let templates: TemplateInfo[] = []
56
- for (let name of names) {
57
- // A missing manifest falls back to the components level: it keeps every
58
- // dependency, so the scaffolded app works at either level.
59
- let manifest = await readFile(join(TEMPLATES_DIR, name, TEMPLATE_MANIFEST), "utf8")
60
- .then((raw) => JSON.parse(raw))
61
- .catch(() => ({}))
62
- templates.push({
63
- name,
64
- level: manifest.level === "core" ? "core" : "components",
65
- description: typeof manifest.description === "string" ? manifest.description : "",
66
- })
67
- }
68
- return templates
69
- }
42
+ const EXTENSIONS: Extension[] = [
43
+ {
44
+ pkg: "@solidrt/components",
45
+ template: "components",
46
+ description: "component framework: widgets, theming, navigation",
47
+ },
48
+ { pkg: "@solidrt/3d", description: "general purpose 3D library" },
49
+ ]
70
50
 
71
- // Resolve which template to scaffold from: an explicit --template if valid, an
72
- // interactive picker on a TTY, else `default` (or the first available).
73
- async function resolveTemplate(): Promise<TemplateInfo> {
74
- let templates = await listTemplates()
75
- if (templates.length === 0) {
76
- console.error(`!! No templates found in ${TEMPLATES_DIR}`)
77
- process.exit(1)
78
- }
79
- let chosen = values.template
80
- if (chosen) {
81
- let found = templates.find((t) => t.name === chosen)
82
- if (!found) {
83
- let names = templates.map((t) => t.name).join(", ")
84
- console.error(`!! Unknown template "${chosen}"; choose from: ${names}`)
85
- process.exit(1)
51
+ // Resolve which extensions the app takes: an explicit --with list if valid,
52
+ // an interactive picker on a TTY, else none (core only).
53
+ async function resolveExtensions(): Promise<Extension[]> {
54
+ let raw = values.with
55
+ if (raw !== undefined) {
56
+ let names = raw.split(",").map((n) => n.trim()).filter(Boolean)
57
+ let chosen: Extension[] = []
58
+ for (let name of names) {
59
+ let found = EXTENSIONS.find((e) => e.pkg === name)
60
+ if (!found) {
61
+ let all = EXTENSIONS.map((e) => e.pkg).join(", ")
62
+ console.error(`!! Unknown extension "${name}"; choose from: ${all}`)
63
+ process.exit(1)
64
+ }
65
+ if (!chosen.includes(found)) chosen.push(found)
86
66
  }
87
- return found
88
- }
89
- if (process.stdin.isTTY) {
90
- let picked = await select(
91
- "Select a template",
92
- templates.map((t) => {
93
- // Core is the runtime every app has; anything else is a package the
94
- // app opts into, so the picker marks it as such.
95
- let name = t.level === "core" ? t.name : `${t.name} (extension)`
96
- return { label: t.description ? `${name} - ${t.description}` : name, value: t.name }
97
- }),
98
- )
99
- return templates.find((t) => t.name === picked)!
67
+ return chosen
100
68
  }
101
- return templates.find((t) => t.name === DEFAULT_TEMPLATE) ?? templates[0]!
69
+ if (!process.stdin.isTTY) return []
70
+ // Core is the runtime every app has, so it is not a choice.
71
+ note("@solidrt/core is always included", "Packages")
72
+ let picked = await multiselect(
73
+ "Select extensions",
74
+ EXTENSIONS.map((e) => ({ label: `${e.pkg} - ${e.description}`, value: e.pkg })),
75
+ )
76
+ return EXTENSIONS.filter((e) => picked.includes(e.pkg))
77
+ }
78
+
79
+ // The starter src/ comes from the first selected extension that brings a
80
+ // template; with none, the core `default` starter.
81
+ function resolveTemplate(extensions: Extension[]): string {
82
+ return extensions.find((e) => e.template)?.template ?? DEFAULT_TEMPLATE
102
83
  }
103
84
 
104
85
  export async function runInitCommand() {
@@ -120,9 +101,11 @@ export async function runInitCommand() {
120
101
  process.exit(1)
121
102
  }
122
103
 
123
- let template = await resolveTemplate()
104
+ let extensions = await resolveExtensions()
105
+ let template = resolveTemplate(extensions)
106
+ let summary = ["@solidrt/core", ...extensions.map((e) => e.pkg)].join(", ")
124
107
 
125
- console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${template.name})`)
108
+ console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${summary})`)
126
109
  for (let { from, to } of TEMPLATE_FILES) {
127
110
  let dest = join(dir, to)
128
111
  await mkdir(dirname(dest), { recursive: true })
@@ -130,13 +113,11 @@ export async function runInitCommand() {
130
113
  console.log(` Write ${to}`)
131
114
  }
132
115
 
133
- // The chosen template's files become the project's src/. Entries may be
134
- // nested directories (e.g. an asset folder), so copy recursively. The
135
- // manifest describes the template rather than belonging to the app.
136
- let templateDir = join(TEMPLATES_DIR, template.name)
116
+ // The template's files become the project's src/. Entries may be nested
117
+ // directories (e.g. an asset folder), so copy recursively.
118
+ let templateDir = join(TEMPLATES_DIR, template)
137
119
  await mkdir(join(dir, "src"), { recursive: true })
138
120
  for (let file of await readdir(templateDir)) {
139
- if (file === TEMPLATE_MANIFEST) continue
140
121
  await cp(join(templateDir, file), join(dir, "src", file), { recursive: true })
141
122
  console.log(` Write src/${file}`)
142
123
  }
@@ -149,12 +130,15 @@ export async function runInitCommand() {
149
130
  await writeFile(join(dir, "assets", "icon.svg"), await readFile(join(SCAFFOLD_DIR, "icon.svg")))
150
131
  console.log(" Write assets/icon.svg")
151
132
 
152
- // The scaffold package.json carries a placeholder name; set it from the
153
- // target folder. A core-level app gets no component framework dependency.
133
+ // The scaffold package.json carries a placeholder name and every extension
134
+ // dependency; set the name from the target folder and keep only the
135
+ // selected extensions.
154
136
  let pkgPath = join(dir, "package.json")
155
137
  let pkg = JSON.parse(await readFile(pkgPath, "utf8"))
156
138
  pkg.name = packageName(dir)
157
- if (template.level === "core") delete pkg.dependencies["@solidrt/components"]
139
+ for (let ext of EXTENSIONS) {
140
+ if (!extensions.includes(ext)) delete pkg.dependencies[ext.pkg]
141
+ }
158
142
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n")
159
143
 
160
144
  // Deps are declared in scaffold/package.json (Solid peers resolve via
@@ -9,14 +9,153 @@ 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, realpathSync } from "node:fs"
14
+ import { values, DEFAULT_DEV_PORT } 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
+ // The two sides of a projectDir comparison come from different processes
39
+ // (the server's entry path, the bridge's cwd) and only agree by construction
40
+ // on the directory, not the spelling: an editor-spawned bridge on Windows
41
+ // keeps its parent's lower-case drive letter while a shell writes it upper
42
+ // case, and 8.3 names, symlinks and subst drives are the same class. Compare
43
+ // the canonical path, so the spelling never decides.
44
+ function sameDir(a: string, b: string): boolean {
45
+ if (a === b) return true
46
+ try {
47
+ return realpathSync.native(a) === realpathSync.native(b)
48
+ } catch {
49
+ return false
50
+ }
51
+ }
52
+
53
+ // Only ESRCH means the process is gone. EPERM is a live process this bridge
54
+ // may not signal (Windows reports it for other users' processes), and a
55
+ // bare try/catch would drop that healthy server from the registry.
56
+ function pidAlive(pid: number): boolean {
57
+ try {
58
+ process.kill(pid, 0)
59
+ return true
60
+ } catch (e: any) {
61
+ return e?.code === "EPERM"
62
+ }
63
+ }
64
+
65
+ type LiveRecord = { pid: number; port: number; projectDir: string }
66
+
67
+ // The global server registry: every running dev server keeps a live.json in
68
+ // ~/.solidrt/servers/<port>/ (see dev-server.ts writeLiveRecord). Unreadable or
69
+ // malformed records are skipped, not fatal - the registry is a hint.
70
+ function liveRecords(): LiveRecord[] {
71
+ let root = devDir("servers")
72
+ let names: string[]
73
+ try {
74
+ names = readdirSync(root)
75
+ } catch {
76
+ return []
77
+ }
78
+ let records: LiveRecord[] = []
79
+ for (let name of names) {
80
+ try {
81
+ let record = JSON.parse(readFileSync(join(root, name, "live.json"), "utf8"))
82
+ if (typeof record?.pid === "number" && typeof record?.port === "number" && typeof record?.projectDir === "string") {
83
+ records.push(record)
84
+ }
85
+ } catch {}
86
+ }
87
+ return records
88
+ }
89
+
90
+ type PortResult = { ok: true; port: number } | { ok: false; message: string }
91
+
92
+ async function resolvePort(): Promise<PortResult> {
93
+ if (FIXED_PORT !== null) return { ok: true, port: FIXED_PORT }
94
+ let project = findProjectDir()
95
+ if (!project) {
96
+ return {
97
+ ok: false,
98
+ 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.`,
99
+ }
100
+ }
101
+ let records = liveRecords()
102
+ let matches = records.filter((r) => sameDir(r.projectDir, project) && pidAlive(r.pid))
103
+ if (matches.length > 1) {
104
+ let ports = matches
105
+ .map((r) => r.port)
106
+ .sort((a, b) => a - b)
107
+ .join(", ")
108
+ return { ok: false, message: `${matches.length} dev servers are serving this project (ports ${ports}); pass -s <N> to srt mcp` }
109
+ }
110
+ if (matches.length === 0) {
111
+ // A lookup by key that fails against a small table prints the table: an
112
+ // empty registry, a dead pid and a record for another project are three
113
+ // different problems, and the reader can only tell them apart if the
114
+ // candidates are listed next to the key that was looked up.
115
+ let listing =
116
+ records.length === 0
117
+ ? `Registry ${devDir("servers")}: no records.`
118
+ : `Registry ${devDir("servers")}: ${records.length} record(s).\n` +
119
+ records
120
+ .map((r) => {
121
+ let session = r.port - DEFAULT_DEV_PORT
122
+ let flag = session >= 0 && session < 100 ? `-s ${session}` : `--port ${r.port}`
123
+ return ` port ${r.port} (${flag}) pid ${r.pid} (${pidAlive(r.pid) ? "alive" : "dead"}) serving ${r.projectDir}`
124
+ })
125
+ .join("\n")
126
+ return {
127
+ ok: false,
128
+ message: `No dev server for ${project}.\n${listing}\nStart one with srt run, or pin one of the servers above by passing its flag to srt mcp.`,
129
+ }
130
+ }
131
+ let port = matches[0]!.port
132
+ // The record is a hint; the server is authoritative. The probe catches a
133
+ // stale record whose pid was reused by an unrelated process.
134
+ try {
135
+ let probe = await fetch(`http://127.0.0.1:${port}/__control__/clients`)
136
+ let body: any = await probe.json().catch(() => null)
137
+ if (!probe.ok || typeof body?.projectDir !== "string" || !sameDir(body.projectDir, project)) {
138
+ return {
139
+ ok: false,
140
+ message: `The server on port ${port} is not serving ${project}${
141
+ typeof body?.projectDir === "string" ? ` (it serves ${body.projectDir})` : ""
142
+ }. Start one with srt run, or pass -s <N> to srt mcp.`,
143
+ }
144
+ }
145
+ } catch {
146
+ return {
147
+ ok: false,
148
+ message: `No dev server for ${project}: the registry lists port ${port} but nothing answers there. Start one with srt run.`,
149
+ }
150
+ }
151
+ return { ok: true, port }
152
+ }
16
153
 
17
154
  type ControlResult = { ok: true; body: any } | { ok: false; message: string }
18
155
 
19
156
  async function control(path: string, method: "GET" | "POST" = "GET", payload?: unknown): Promise<ControlResult> {
157
+ let resolved = await resolvePort()
158
+ if (!resolved.ok) return resolved
20
159
  let resp
21
160
  try {
22
161
  let init: RequestInit = { method }
@@ -24,7 +163,7 @@ async function control(path: string, method: "GET" | "POST" = "GET", payload?: u
24
163
  init.headers = { "content-type": "application/json" }
25
164
  init.body = JSON.stringify(payload)
26
165
  }
27
- resp = await fetch(CONTROL_BASE + path, init)
166
+ resp = await fetch(`http://127.0.0.1:${resolved.port}/__control__${path}`, init)
28
167
  } catch {
29
168
  return {
30
169
  ok: false,
@@ -104,7 +243,7 @@ let TOOLS: {
104
243
  name: "get_stats",
105
244
  readOnly: true,
106
245
  description:
107
- "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).",
246
+ "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, i.e. words the shared word cache did not have; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), wordHits (words answered from the shared word cache; hits high and paraShapes near zero on a text change means only the changed words were reshaped), 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).",
108
247
  inputSchema: { client: CLIENT_ARG },
109
248
  },
110
249
  {