@solidrt/cli 0.0.49 → 0.0.51

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.
@@ -1,6 +1,17 @@
1
1
  import { values, source, isPrebuilt } from "../args"
2
- import { bundle, bundleTo, bundleFlux, compileToBytecode } from "../bundler"
3
- import { resolve } from "path"
2
+ import {
3
+ bundleFlux,
4
+ bundleIsolatesDir,
5
+ bundleSolid,
6
+ compileToBytecode,
7
+ findFluxIsolates,
8
+ readPrebuiltIsolates,
9
+ walkFiles,
10
+ writeIsolates,
11
+ } from "../bundler"
12
+ import { projectDirFor } from "../project"
13
+ import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"
14
+ import { basename, dirname, join, resolve } from "node:path"
4
15
 
5
16
  // Write to stdout and resolve only once the whole payload is flushed.
6
17
  // process.stdout.write to a pipe is async and applies backpressure; the
@@ -11,7 +22,41 @@ function writeStdout(data: string): Promise<void> {
11
22
  })
12
23
  }
13
24
 
14
- // Compile JS to a .srt.bin file and report its size.
25
+ // The bundle output dir (okf/backlog/build-output-dirs.md): the bundle flow's
26
+ // subdir of the build root, or an explicit --output dir. Only reused when it
27
+ // is empty or already a bundle output (a *.srt.* or *.flux.* bundle at top
28
+ // level) - the writePackFolder rule - so it never writes into an unrelated
29
+ // directory.
30
+ function ensureOutDir(entry: string): string {
31
+ let outDir = values.output ?? join(projectDirFor(entry), "dist", "bundle")
32
+ let existing = existsSync(outDir) ? readdirSync(outDir) : null
33
+ if (existing && existing.length > 0 && !existing.some((name) => /\.(srt|flux)\.(js|bin)$/.test(name))) {
34
+ console.error(`${resolve(outDir)} exists and is not a bundle output; choose another --output`)
35
+ process.exit(1)
36
+ }
37
+ mkdirSync(outDir, { recursive: true })
38
+ return outDir
39
+ }
40
+
41
+ // Clear one form's files from the output's isolates/ dir before rewriting it,
42
+ // so removed modules cannot go stale. The dir is shared by a bundle's .js and
43
+ // .bin forms, so only the form being rewritten is cleared - a --compile must
44
+ // not delete the .js set the .js bundle pairs with, nor the reverse.
45
+ function clearIsolates(dir: string, ext: ".js" | ".bin") {
46
+ walkFiles(dir, (abs) => {
47
+ if (abs.endsWith(ext)) rmSync(abs)
48
+ })
49
+ }
50
+
51
+ // Compile one isolate bundle to `<dir>/<id>.bin` (module name = its id, for
52
+ // stack attribution).
53
+ async function writeIsolateBytecode(dir: string, isolate: { id: string; code: string }) {
54
+ let outfile = join(dir, isolate.id + ".bin")
55
+ mkdirSync(dirname(outfile), { recursive: true })
56
+ await Bun.write(outfile, await compileToBytecode(isolate.code, isolate.id))
57
+ }
58
+
59
+ // Compile JS to a bytecode file and report its size.
15
60
  async function writeBytecode(jsCode: string, outfile: string) {
16
61
  let bytecode = await compileToBytecode(jsCode)
17
62
  await Bun.write(outfile, bytecode)
@@ -21,18 +66,47 @@ async function writeBytecode(jsCode: string, outfile: string) {
21
66
 
22
67
  export async function runBundleCommand() {
23
68
  if (values.flux) {
24
- let baseName = values.output ?? source!.replace(/\.[jt]s$/, "")
25
- let jsCode = await bundleFlux(source!)
69
+ let entry = resolve(source!)
70
+ let name = basename(entry).replace(/\.[jt]s$/, "")
71
+ let jsCode = await bundleFlux(entry)
72
+ // Standalone flux resolves isolates by location, not directive: module
73
+ // <id> is <entry dir>/isolates/<id>.js. Bundling keeps that shape - every
74
+ // module under the entry's isolates/ dir is built bare like the entry
75
+ // (which also lets a worker be .ts, unlike running from source) and lands
76
+ // as isolates/<id>.js next to the bundle.
77
+ let isolateModules = findFluxIsolates(dirname(entry))
26
78
 
27
79
  if (values.stdout) {
80
+ if (isolateModules.length) {
81
+ console.error("[cli] Warning: this script has isolate modules; --stdout carries only the main bundle")
82
+ }
28
83
  await writeStdout(jsCode)
29
- } else if (values.compile) {
30
- await writeBytecode(jsCode, baseName + ".flux.bin")
84
+ process.exit()
85
+ }
86
+ let outDir = ensureOutDir(entry)
87
+ if (values.compile) {
88
+ await writeBytecode(jsCode, join(outDir, name + ".flux.bin"))
31
89
  } else {
32
- let outfile = baseName + ".flux.js"
90
+ let outfile = join(outDir, name + ".flux.js")
33
91
  await Bun.write(outfile, jsCode)
34
92
  console.log(`>> wrote ${jsCode.length} bytes to ${outfile}`)
35
93
  }
94
+ // Isolates follow the main bundle's form: source beside a .flux.js,
95
+ // bytecode beside a .flux.bin (the flux resolver reads .bin first).
96
+ let isolatesDir = join(outDir, "isolates")
97
+ if (values.compile) {
98
+ clearIsolates(isolatesDir, ".bin")
99
+ for (let module of isolateModules) {
100
+ await writeIsolateBytecode(isolatesDir, { id: module.id, code: await bundleFlux(module.path) })
101
+ }
102
+ } else {
103
+ clearIsolates(isolatesDir, ".js")
104
+ for (let module of isolateModules) {
105
+ let file = join(isolatesDir, module.id + ".js")
106
+ mkdirSync(dirname(file), { recursive: true })
107
+ await Bun.write(file, await bundleFlux(module.path))
108
+ }
109
+ }
36
110
  process.exit()
37
111
  }
38
112
 
@@ -44,33 +118,44 @@ export async function runBundleCommand() {
44
118
  let jsFile = resolve(source!)
45
119
  let binOut = jsFile.replace(/\.srt\.js$/, ".srt.bin").replace(/\.js$/, ".bin")
46
120
  await writeBytecode(await Bun.file(jsFile).text(), binOut)
121
+ // The isolate bundles compile along, .bin beside .js in the output's
122
+ // isolates/ dir (the ids match, the extension picks the form).
123
+ for (let isolate of readPrebuiltIsolates(jsFile)) {
124
+ await writeIsolateBytecode(bundleIsolatesDir(jsFile), isolate)
125
+ }
47
126
  process.exit()
48
127
  }
49
128
 
50
- let baseName = values.output ?? source!.replace(/\.[jt]sx?$/, "")
129
+ let entry = resolve(source!)
130
+ let name = basename(entry).replace(/\.[jt]sx?$/, "")
51
131
 
52
132
  if (values.stdout) {
53
- let result = await bundle()
54
- if (!result) {
55
- console.error("Build failed")
56
- process.exit(1)
133
+ let result = await bundleSolid()
134
+ if (result.isolates.length) {
135
+ console.error("[cli] Warning: this app has isolate modules; --stdout carries only the main bundle")
57
136
  }
58
137
  await writeStdout(result.code)
59
138
  process.exit()
60
139
  }
61
140
 
141
+ let outDir = ensureOutDir(entry)
142
+ let isolatesDir = join(outDir, "isolates")
143
+
62
144
  if (values.compile) {
63
- let result = await bundle()
64
- if (!result) {
65
- console.error("Build failed")
66
- process.exit(1)
145
+ let result = await bundleSolid()
146
+ await writeBytecode(result.code, join(outDir, name + ".srt.bin"))
147
+ clearIsolates(isolatesDir, ".bin")
148
+ for (let isolate of result.isolates) {
149
+ await writeIsolateBytecode(isolatesDir, isolate)
67
150
  }
68
- await writeBytecode(result.code, baseName + ".srt.bin")
69
151
  process.exit()
70
152
  }
71
153
 
72
- let jsOutfile = baseName + ".srt.js"
73
- let result = await bundleTo(jsOutfile)
154
+ let result = await bundleSolid()
155
+ let jsOutfile = join(outDir, name + ".srt.js")
156
+ await Bun.write(jsOutfile, result.code)
157
+ clearIsolates(isolatesDir, ".js")
158
+ writeIsolates(isolatesDir, result.isolates)
74
159
  console.log(`>> wrote ${result.code.length} bytes to ${jsOutfile}`)
75
160
  process.exit()
76
- }
161
+ }
@@ -128,6 +128,13 @@ export function reportTypes(
128
128
 
129
129
  export async function runCheckCommand() {
130
130
  let entry = source!
131
+ if (!existsSync(entry)) {
132
+ // Without this, the missing file surfaces later as an internal ENOENT
133
+ // stack trace (scandir/Bun.build), which reads as a CLI bug - the common
134
+ // cause is just running from the wrong directory.
135
+ console.error(`No such entry: ${entry} (resolved from ${process.cwd()})`)
136
+ process.exit(1)
137
+ }
131
138
  let failed = false
132
139
 
133
140
  let result = await bundleWith({ entry, dev: true, minify: false })
@@ -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,55 @@ 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
+ // One AGENTS.md serves every template, so the lines that point an agent at
34
+ // @solidrt/components docs are fenced between markers: with the extension
35
+ // selected only the markers go, without it the block goes too, so a core-only
36
+ // app never ships references to files that are not installed.
37
+ const MARKED_BLOCK = /^<!-- components:begin -->\n[\s\S]*?^<!-- components:end -->\n/gm
38
+ const MARKER = /^<!-- components:(?:begin|end) -->\n/gm
39
+
40
+ function resolveMarkers(text: string, extensions: Extension[]): string {
41
+ let selected = extensions.some((e) => e.pkg === "@solidrt/components")
42
+ return selected ? text.replace(MARKER, "") : text.replace(MARKED_BLOCK, "")
43
+ }
44
+
45
+ // Optional packages an app can opt into on top of core. Each maps to a
46
+ // dependency in the scaffold package.json (kept when selected, removed
47
+ // otherwise) and optionally to a starter under scaffold/templates/.
48
+ interface Extension {
49
+ pkg: string
50
+ template?: string
41
51
  description: string
42
52
  }
43
53
 
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
54
+ const EXTENSIONS: Extension[] = [
55
+ {
56
+ pkg: "@solidrt/components",
57
+ template: "components",
58
+ description: "component framework: widgets, theming, navigation",
59
+ },
60
+ { pkg: "@solidrt/3d", description: "general purpose 3D library" },
61
+ ]
62
+
63
+ // Resolve which extensions the app takes: an interactive picker on a TTY,
64
+ // else none (core only). Extensions are ordinary dependencies, so a script
65
+ // adds them afterwards with `bun add`.
66
+ async function resolveExtensions(): Promise<Extension[]> {
67
+ if (!process.stdin.isTTY) return []
68
+ // Core is the runtime every app has, so it is not a choice.
69
+ note("@solidrt/core is always included", "Packages")
70
+ let picked = await multiselect(
71
+ "Select extensions",
72
+ EXTENSIONS.map((e) => ({ label: `${e.pkg} - ${e.description}`, value: e.pkg })),
73
+ )
74
+ return EXTENSIONS.filter((e) => picked.includes(e.pkg))
69
75
  }
70
76
 
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)
86
- }
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)!
100
- }
101
- return templates.find((t) => t.name === DEFAULT_TEMPLATE) ?? templates[0]!
77
+ // The starter src/ comes from the first selected extension that brings a
78
+ // template; with none, the core `default` starter.
79
+ function resolveTemplate(extensions: Extension[]): string {
80
+ return extensions.find((e) => e.template)?.template ?? DEFAULT_TEMPLATE
102
81
  }
103
82
 
104
83
  export async function runInitCommand() {
@@ -120,23 +99,25 @@ export async function runInitCommand() {
120
99
  process.exit(1)
121
100
  }
122
101
 
123
- let template = await resolveTemplate()
102
+ let extensions = await resolveExtensions()
103
+ let template = resolveTemplate(extensions)
104
+ let summary = ["@solidrt/core", ...extensions.map((e) => e.pkg)].join(", ")
124
105
 
125
- console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${template.name})`)
106
+ console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${summary})`)
126
107
  for (let { from, to } of TEMPLATE_FILES) {
127
108
  let dest = join(dir, to)
128
109
  await mkdir(dirname(dest), { recursive: true })
129
- await writeFile(dest, await readFile(join(SCAFFOLD_DIR, from)))
110
+ let body: string | Buffer = await readFile(join(SCAFFOLD_DIR, from))
111
+ if (to === "AGENTS.md") body = resolveMarkers(body.toString("utf8"), extensions)
112
+ await writeFile(dest, body)
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
@@ -10,8 +10,8 @@ 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
12
  import { dirname, join, resolve } from "node:path"
13
- import { existsSync, readdirSync, readFileSync } from "node:fs"
14
- import { values } from "../args"
13
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"
14
+ import { values, DEFAULT_DEV_PORT } from "../args"
15
15
  import { DEV_PORT } from "../dev-server"
16
16
  import { devDir } from "../dev-dir"
17
17
 
@@ -35,12 +35,30 @@ function findProjectDir(): string | null {
35
35
  }
36
36
  }
37
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.
38
56
  function pidAlive(pid: number): boolean {
39
57
  try {
40
58
  process.kill(pid, 0)
41
59
  return true
42
- } catch {
43
- return false
60
+ } catch (e: any) {
61
+ return e?.code === "EPERM"
44
62
  }
45
63
  }
46
64
 
@@ -80,7 +98,8 @@ async function resolvePort(): Promise<PortResult> {
80
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.`,
81
99
  }
82
100
  }
83
- let matches = liveRecords().filter((r) => r.projectDir === project && pidAlive(r.pid))
101
+ let records = liveRecords()
102
+ let matches = records.filter((r) => sameDir(r.projectDir, project) && pidAlive(r.pid))
84
103
  if (matches.length > 1) {
85
104
  let ports = matches
86
105
  .map((r) => r.port)
@@ -89,7 +108,25 @@ async function resolvePort(): Promise<PortResult> {
89
108
  return { ok: false, message: `${matches.length} dev servers are serving this project (ports ${ports}); pass -s <N> to srt mcp` }
90
109
  }
91
110
  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.` }
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
+ }
93
130
  }
94
131
  let port = matches[0]!.port
95
132
  // The record is a hint; the server is authoritative. The probe catches a
@@ -97,7 +134,7 @@ async function resolvePort(): Promise<PortResult> {
97
134
  try {
98
135
  let probe = await fetch(`http://127.0.0.1:${port}/__control__/clients`)
99
136
  let body: any = await probe.json().catch(() => null)
100
- if (!probe.ok || body?.projectDir !== project) {
137
+ if (!probe.ok || typeof body?.projectDir !== "string" || !sameDir(body.projectDir, project)) {
101
138
  return {
102
139
  ok: false,
103
140
  message: `The server on port ${port} is not serving ${project}${
@@ -206,8 +243,17 @@ let TOOLS: {
206
243
  name: "get_stats",
207
244
  readOnly: true,
208
245
  description:
209
- "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).",
210
- inputSchema: { client: CLIENT_ARG },
246
+ "Performance statistics from a running app client. Start with `window`: a summary of the frames rebuilt in the last window_ms (default 5000, max 10000) - frames, p50Ms/p95Ms/maxMs of the JS-thread critical path per frame (render handler + layout + postLayout + paint + hover), slowFrames (frames over the refresh period, periodMs), and `worst`, the single most expensive frame with its ageMs, phase breakdown (jsMs/layoutMs/postLayoutMs/paintMs/hoverMs) and that frame's own layout activity (paraShapes, measureCalls, dirtiedNodes, cacheGets/cacheHits, nodesPainted). This is where jank shows: the smoothed figures below average a one-frame hitch away, the window keeps it. Typical flow: send_input a burst (typing, a drag), then get_stats - `frames: 0` means nothing was rebuilt in the window (idle app), which is different from all-fast. The window also carries rates for the GPU counters when it spans 2+ frames: fenceTimeoutsPerSec, gpuPassesPerFrame (per presented frame), gpuPassMsPerFrame, rasterCmdMsPerSec - read these instead of differencing the cumulatives yourself. timeMs (client monotonic clock) and frame (present index) stamp the payload so two samples can be differenced. Then the smoothed figures: 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), nodesPainted (nodes the last paint walk entered; mountedNodes minus this is what viewport culling skipped - a long scroller should paint a near-constant number of nodes however long its content). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed at the instant of the query, including the one executing; the frame command blocks on vsync in it, so 1 while frames flow is normal - it is a backlog signal only when it climbs across queries while fps drops; a persistently high idle reading has been seen once on a Windows client and is unexplained, so do not conclude from this field alone), 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).",
247
+ inputSchema: {
248
+ window_ms: z
249
+ .number()
250
+ .int()
251
+ .min(0)
252
+ .max(10000)
253
+ .describe("How far back the window summary looks, in ms (default 5000, max 10000)")
254
+ .optional(),
255
+ client: CLIENT_ARG,
256
+ },
211
257
  },
212
258
  {
213
259
  name: "get_render_tree",
@@ -352,7 +398,7 @@ let TOOLS: {
352
398
  name: "set_time_scale",
353
399
  annotations: { destructiveHint: false, idempotentHint: true },
354
400
  description:
355
- "Control a running app client's clock. scale=0 freezes app time: onFrame/requestAnimationFrame stop being delivered, setTimeout/setInterval freeze, performance.now() holds still, and the picture stops - so get_snapshot can capture an exact frame of any animation instead of racing it (tool round trips are usually slower than the animation). Combine with a registerDebug command that sets up the state to photograph: set state, pause, snapshot. Other values scale time for dt-driven apps (0.5 = half speed, 2 = double); apps that advance a fixed amount per onFrame call only respond to 0 and 1. Date.now() stays wall time throughout. The scale is client runtime state: it survives across your snapshots but resets to 1 on reload/load and on client restart. ALWAYS set it back to 1 when you are done - a paused client looks wedged to the human watching the screen.",
401
+ "Control a running app client's clock. scale=0 freezes app time: onFrame/requestAnimationFrame stop being delivered, setTimeout/setInterval freeze, and the picture stops (performance.now() and Date.now() keep running: they are real time, not the frame timeline, so only animations driven off the onFrame tick pause) - so get_snapshot can capture an exact frame of any animation instead of racing it (tool round trips are usually slower than the animation). Combine with a registerDebug command that sets up the state to photograph: set state, pause, snapshot. Other values scale time for dt-driven apps (0.5 = half speed, 2 = double); apps that advance a fixed amount per onFrame call only respond to 0 and 1. The scale is client runtime state: it survives across your snapshots but resets to 1 on reload/load and on client restart. ALWAYS set it back to 1 when you are done - a paused client looks wedged to the human watching the screen.",
356
402
  inputSchema: {
357
403
  scale: z
358
404
  .number()
@@ -431,8 +477,13 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
431
477
  let qs = params.toString()
432
478
  return control(qs ? `/logs?${qs}` : "/logs")
433
479
  }
434
- case "get_stats":
435
- return control(`/stats${clientParam(args)}`)
480
+ case "get_stats": {
481
+ let params = new URLSearchParams()
482
+ if (typeof args?.window_ms === "number") params.set("window", String(args.window_ms))
483
+ if (typeof args?.client === "number") params.set("client", String(args.client))
484
+ let qs = params.toString()
485
+ return control(qs ? `/stats?${qs}` : "/stats")
486
+ }
436
487
  case "get_render_tree": {
437
488
  let params = new URLSearchParams()
438
489
  if (typeof args?.root === "number") params.set("root", String(args.root))
@@ -1,11 +1,17 @@
1
1
  import { values, source } from "../args"
2
- import { bundleFlux, bundleSolid, compileToBytecode } from "../bundler"
2
+ import { bundleFlux, bundleSolid, compileToBytecode, findFluxIsolates } from "../bundler"
3
3
  import { resolvePackFonts } from "../fonts"
4
4
  import { loadAppIdentity } from "../project"
5
5
  import { packFlux, packSolid } from "../packer"
6
6
  import { buildPackFolder, writePackFolder } from "../pack-folder"
7
7
  import { requireBinary } from "../util"
8
- import { resolve } from "node:path"
8
+ import { dirname, join, resolve } from "node:path"
9
+
10
+ // Windows executables need the suffix; a user-given --output may already
11
+ // carry it.
12
+ function exeName(outfile: string): string {
13
+ return process.platform === "win32" && !outfile.toLowerCase().endsWith(".exe") ? outfile + ".exe" : outfile
14
+ }
9
15
 
10
16
  // Write the packed executable, mark it runnable, and report its size.
11
17
  async function writeExecutable(packed: Buffer, outfile: string) {
@@ -22,11 +28,15 @@ export async function runPackCommand() {
22
28
  console.error("--folder is for app packs; flux scripts have no folder output")
23
29
  process.exit(1)
24
30
  }
25
- let outfile = values.output ?? source!.replace(/\.[jt]sx?$/, "")
26
- if (process.platform === "win32" && !outfile.toLowerCase().endsWith(".exe")) {
27
- outfile += ".exe"
31
+ let outfile = exeName(values.output ?? source!.replace(/\.[jt]s$/, ""))
32
+ // The entry's isolate modules ride along as isolates/<id>.bin sections
33
+ // (module name = id, for stack attribution).
34
+ let isolates = []
35
+ for (let module of findFluxIsolates(dirname(resolve(source!)))) {
36
+ isolates.push({ id: module.id, bytecode: await compileToBytecode(await bundleFlux(module.path), module.id) })
28
37
  }
29
- await writeExecutable(await packFlux(await bundleFlux(source!)), outfile)
38
+ if (isolates.length) console.log(`>> isolates: ${isolates.map((i) => i.id).join(", ")}`)
39
+ await writeExecutable(packFlux(await compileToBytecode(await bundleFlux(source!)), isolates), outfile)
30
40
  process.exit()
31
41
  }
32
42
 
@@ -44,12 +54,12 @@ export async function runPackCommand() {
44
54
  let bundled = await bundleSolid()
45
55
  let bytecode = await compileToBytecode(bundled.code)
46
56
  let isolates = []
47
- for (let i of bundled.isolates) isolates.push({ id: i.id, bytecode: await compileToBytecode(i.code) })
57
+ for (let i of bundled.isolates) isolates.push({ id: i.id, bytecode: await compileToBytecode(i.code, i.id) })
48
58
  if (isolates.length) console.log(`>> isolates: ${isolates.map((i) => i.id).join(", ")}`)
49
59
  let folder = buildPackFolder(source!, bytecode, isolates)
50
60
 
51
61
  if (values.folder) {
52
- let outDir = values.output ?? "dist"
62
+ let outDir = values.output ?? join("dist", "pack")
53
63
  writePackFolder(outDir, requireBinary("solidrt"), bytecode, folder)
54
64
  console.log(`>> wrote pack folder to ${resolve(outDir)}`)
55
65
  process.exit()
@@ -1,11 +1,33 @@
1
1
  import { appArgs, source, values } from "../args"
2
2
  import { requireBinary, run } from "../util"
3
- import { bundleTo } from "../bundler"
4
- import { resolve } from "path"
3
+ import { bundleSolid, writeIsolates } from "../bundler"
4
+ import { collectAssets, projectDirFor } from "../project"
5
+ import { cpSync, mkdirSync, rmSync } from "node:fs"
6
+ import { basename, dirname, join, resolve } from "path"
5
7
 
6
8
  export async function runRenderCommand() {
7
- let jsOutfile = source!.replace(/\.[jt]sx$/, "") + ".srt.js"
8
- await bundleTo(jsOutfile)
9
+ let entry = resolve(source!)
10
+ let projectDir = projectDirFor(entry)
11
+ let result = await bundleSolid()
12
+ // The staged run dir (okf/backlog/build-output-dirs.md): bundle +
13
+ // isolates/ + assets/ under one root - the shape of an installed version
14
+ // dir, so the runtime's assets mount resolves both trees. Wiped first so
15
+ // removed isolates and deleted assets cannot go stale; render owns this
16
+ // subdir and nothing else under dist/.
17
+ let outDir = join(projectDir, "dist", "render")
18
+ rmSync(outDir, { recursive: true, force: true })
19
+ let jsOutfile = join(outDir, basename(entry).replace(/\.[jt]sx?$/, "") + ".srt.js")
20
+ await Bun.write(jsOutfile, result.code)
21
+ writeIsolates(join(outDir, "isolates"), result.isolates)
22
+ // The project's assets/ tree, copied in (dotfiles filtered, like a pack)
23
+ // so `assets/...` resolves like it does under the dev server and in a
24
+ // packed app (the runtime's cwd is the data sandbox, which holds no
25
+ // assets).
26
+ for (let asset of collectAssets(entry).assets) {
27
+ let dest = join(outDir, asset.path)
28
+ mkdirSync(dirname(dest), { recursive: true })
29
+ cpSync(join(projectDir, asset.path), dest)
30
+ }
9
31
  let runner = requireBinary("solidrt-go")
10
32
  let playbackArgs = ["--playback"]
11
33
  if (values.fps) playbackArgs.push("--fps", values.fps)
@@ -15,7 +37,8 @@ export async function runRenderCommand() {
15
37
  // Always absolute: the runtime chdirs into the app's data sandbox before
16
38
  // frames are written, so a bare prefix would land the PNGs there.
17
39
  playbackArgs.push("--out", resolve(values.output ?? "."))
18
- playbackArgs.push(resolve(jsOutfile))
40
+ playbackArgs.push("--assets", outDir)
41
+ playbackArgs.push(jsOutfile)
19
42
  // The runner takes everything after the source path verbatim as the app's
20
43
  // argument vector (flux:process argv).
21
44
  playbackArgs.push(...appArgs)