@solidrt/cli 0.0.50 → 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 })
@@ -30,6 +30,18 @@ function packageName(dir: string): string {
30
30
 
31
31
  const DEFAULT_TEMPLATE = "default"
32
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
+
33
45
  // Optional packages an app can opt into on top of core. Each maps to a
34
46
  // dependency in the scaffold package.json (kept when selected, removed
35
47
  // otherwise) and optionally to a starter under scaffold/templates/.
@@ -48,24 +60,10 @@ const EXTENSIONS: Extension[] = [
48
60
  { pkg: "@solidrt/3d", description: "general purpose 3D library" },
49
61
  ]
50
62
 
51
- // Resolve which extensions the app takes: an explicit --with list if valid,
52
- // an interactive picker on a TTY, else none (core only).
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`.
53
66
  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)
66
- }
67
- return chosen
68
- }
69
67
  if (!process.stdin.isTTY) return []
70
68
  // Core is the runtime every app has, so it is not a choice.
71
69
  note("@solidrt/core is always included", "Packages")
@@ -109,7 +107,9 @@ export async function runInitCommand() {
109
107
  for (let { from, to } of TEMPLATE_FILES) {
110
108
  let dest = join(dir, to)
111
109
  await mkdir(dirname(dest), { recursive: true })
112
- 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)
113
113
  console.log(` Write ${to}`)
114
114
  }
115
115
 
@@ -243,8 +243,17 @@ let TOOLS: {
243
243
  name: "get_stats",
244
244
  readOnly: true,
245
245
  description:
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).",
247
- 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
+ },
248
257
  },
249
258
  {
250
259
  name: "get_render_tree",
@@ -389,7 +398,7 @@ let TOOLS: {
389
398
  name: "set_time_scale",
390
399
  annotations: { destructiveHint: false, idempotentHint: true },
391
400
  description:
392
- "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.",
393
402
  inputSchema: {
394
403
  scale: z
395
404
  .number()
@@ -468,8 +477,13 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
468
477
  let qs = params.toString()
469
478
  return control(qs ? `/logs?${qs}` : "/logs")
470
479
  }
471
- case "get_stats":
472
- 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
+ }
473
487
  case "get_render_tree": {
474
488
  let params = new URLSearchParams()
475
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)
@@ -2,8 +2,8 @@ import pkg from "../../package.json"
2
2
  import { source, isSource, isPrebuilt, values } from "../args"
3
3
  import { state, shutdown, print, printErr } from "../util"
4
4
  import { findProjectRoot, typecheck, reportTypes } from "./check"
5
- import { bundle } from "../bundler"
6
- import { buildManifest, projectDirFor } from "../project"
5
+ import { bundle, bundleMaps, prebuiltManifest } from "../bundler"
6
+ import { projectDirFor } from "../project"
7
7
  import { startServer, buildReload, sendReload, showBuildFailure } from "../dev-server"
8
8
  import { startRepl } from "../repl"
9
9
  import { startWatcher } from "../watcher"
@@ -34,11 +34,11 @@ export async function runServerCommand() {
34
34
  let initialResult = await bundle()
35
35
  if (initialResult) {
36
36
  state.currentCode = initialResult.code
37
- state.currentMap = initialResult.map
37
+ state.currentMaps = bundleMaps(initialResult)
38
38
  state.currentManifest = initialResult.manifest
39
39
  await sendReload(buildReload({ code: state.currentCode, manifest: state.currentManifest }), {
40
40
  latch: true,
41
- map: state.currentMap,
41
+ maps: state.currentMaps,
42
42
  })
43
43
  } else {
44
44
  await showBuildFailure()
@@ -46,7 +46,7 @@ export async function runServerCommand() {
46
46
  } else if (source && isPrebuilt && source.endsWith(".srt.js")) {
47
47
  let path = resolve(source)
48
48
  state.currentCode = await Bun.file(path).text()
49
- state.currentManifest = buildManifest(state.currentCode, path)
49
+ state.currentManifest = prebuiltManifest(state.currentCode, path, state.projectDir)
50
50
  await sendReload(buildReload({ code: state.currentCode, manifest: state.currentManifest }), { latch: true })
51
51
  }
52
52
 
package/src/dev-server.ts CHANGED
@@ -28,8 +28,7 @@ export let DEV_PORT = resolveDevPort()
28
28
 
29
29
  // The dev server itself is a flux script (packages/cli/server/), spawned by
30
30
  // srt: bundling, file watching, and the repl stay here and drive the server
31
- // process over its loopback-only /__internal__/ routes. See
32
- // docs/flux-dev-server-plan.md.
31
+ // process over its loopback-only /__internal__/ routes.
33
32
 
34
33
  const INTERNAL_BASE = `http://${DEV_HOST}:${DEV_PORT}/__internal__`
35
34
 
@@ -62,9 +61,10 @@ async function post(path: string, body: object) {
62
61
  * or to every client when omitted. `latch` keeps the message for late-joining
63
62
  * clients (code reloads latch, one-shot bytecode loads do not); `sourceDir`
64
63
  * 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).
64
+ * fixed for the life of the run); `maps` is the bundle's sourcemaps keyed by
65
+ * module name ("main", each isolate id), kept server-side for stack-trace
66
+ * remapping (omitting it clears the server's maps, so a mapless reload never
67
+ * remaps against stale ones).
68
68
  */
69
69
  export async function sendReload(
70
70
  message: object,
@@ -73,7 +73,7 @@ export async function sendReload(
73
73
  latch?: boolean
74
74
  sourceDir?: string
75
75
  entry?: string
76
- map?: string | null
76
+ maps?: Record<string, string> | null
77
77
  } = {},
78
78
  ) {
79
79
  await post("/reload", { message, ...opts })
@@ -291,7 +291,7 @@ export async function startServer() {
291
291
  process.on("exit", removeLiveRecord)
292
292
 
293
293
  // mDNS advertise (dropped, code kept for future use - see
294
- // docs/flux-dev-server-plan.md): the p2p ticket is the cross-device connect
294
+ // okf/backlog/mdns-discovery.md): the p2p ticket is the cross-device connect
295
295
  // story now. If advertise returns, it belongs next to the server (a flux
296
296
  // capability), not here.
297
297
  //
package/src/packer.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  import { existsSync, readFileSync } from "node:fs"
2
2
  import { dirname, join } from "node:path"
3
3
  import { requireBinary } from "./util"
4
- import { compileToBytecode } from "./bundler"
5
4
  import type { PackFolder } from "./pack-folder"
6
5
 
7
- // Trailer magic identifying the runner an embedded payload belongs to. Must match
8
- // the runner-side checks: fluxrt -> flux/src/bin/fluxrt.rs, solidrt ->
9
- // lattice/src/main.rs (load_embedded_payload).
6
+ // Trailer magic identifying the runner an embedded payload belongs to. Must
7
+ // match the runner-side checks: both runners parse the trailer through
8
+ // forge/src/trailer.rs (readers: lattice/src/main.rs, flux/src/bin/fluxrt.rs).
10
9
  const MAGIC = {
11
10
  fluxrt: Buffer.from([0x46, 0x4c, 0x55, 0x58, 0x52, 0x54, 0x88, 0x44]), // "FLUXRT\x88\x44"
12
11
  solidrt: Buffer.from([0x53, 0x4f, 0x4c, 0x49, 0x44, 0x52, 0x54, 0x88, 0x44]), // "SOLIDRT\x88\x44"
13
12
  }
14
13
 
15
- // Section kinds in the solidrt trailer. Must match lattice/src/main.rs.
14
+ // Section kinds in the trailer. Must match forge/src/trailer.rs (fluxrt only
15
+ // consumes kind-2 file sections; solidrt consumes all three).
16
16
  const SECTION_MANIFEST = 1
17
17
  const SECTION_FILE = 2
18
18
  const SECTION_GL_LIB = 3
@@ -92,12 +92,16 @@ export function packSolid(folder: PackFolder, bytecode: Buffer): Buffer {
92
92
  return packSections(runnerBytes, sections, MAGIC.solidrt)
93
93
  }
94
94
 
95
- // Compile JS to bytecode and append it to the fluxrt runner as its
96
- // single-payload trailer: [bytecode][u64 offset LE][8-byte magic].
97
- export async function packFlux(jsCode: string): Promise<Buffer> {
98
- let bytecode = await compileToBytecode(jsCode)
95
+ // The single-file flux executable: the fluxrt runner plus the program in the
96
+ // same section trailer packSolid uses, kind-2 file sections only -
97
+ // "bundle.bin" is the program, each isolate module "isolates/<id>.bin".
98
+ // Like packSolid, this assembles precompiled bytecode; the pack command
99
+ // compiles.
100
+ export function packFlux(bytecode: Buffer, isolates: { id: string; bytecode: Buffer }[] = []): Buffer {
99
101
  let runnerBytes = readFileSync(requireBinary("fluxrt"))
100
- let offsetBuf = Buffer.allocUnsafe(8)
101
- offsetBuf.writeBigUInt64LE(BigInt(runnerBytes.length))
102
- return Buffer.concat([runnerBytes, bytecode, offsetBuf, MAGIC.fluxrt])
102
+ let sections: Section[] = [
103
+ { kind: SECTION_FILE, bytes: bytecode, name: "bundle.bin" },
104
+ ...isolates.map((i) => ({ kind: SECTION_FILE, bytes: i.bytecode, name: `isolates/${i.id}.bin` })),
105
+ ]
106
+ return packSections(runnerBytes, sections, MAGIC.fluxrt)
103
107
  }
package/src/repl.ts CHANGED
@@ -3,8 +3,7 @@ import { resolve, dirname } from "path"
3
3
  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
- import { bundle } from "./bundler"
7
- import { buildManifest } from "./project"
6
+ import { bundle, bundleMaps, prebuiltManifest } from "./bundler"
8
7
  import { startWatcher, stopWatcher } from "./watcher"
9
8
 
10
9
  // Resolve repl client indexes ("0 2") against the server's client list,
@@ -28,7 +27,7 @@ async function cmdStop(args: string) {
28
27
  if (!args) {
29
28
  stopWatcher()
30
29
  state.currentCode = null
31
- state.currentMap = null
30
+ state.currentMaps = null
32
31
  state.currentManifest = null
33
32
  state.source = undefined
34
33
  await sendStop()
@@ -51,18 +50,18 @@ async function cmdReload(args: string) {
51
50
  return
52
51
  }
53
52
  state.currentCode = result.code
54
- state.currentMap = result.map
53
+ state.currentMaps = bundleMaps(result)
55
54
  state.currentManifest = result.manifest
56
55
  }
57
56
  let msg = buildReload({ code: state.currentCode, manifest: state.currentManifest })
58
57
  if (!args) {
59
- await sendReload(msg, { latch: true, map: state.currentMap })
58
+ await sendReload(msg, { latch: true, maps: state.currentMaps })
60
59
  print("[cli] Sent reload to all clients")
61
60
  return
62
61
  }
63
62
  let ids = await indexesToIds(args)
64
63
  if (ids.length) {
65
- await sendReload(msg, { clients: ids, map: state.currentMap })
64
+ await sendReload(msg, { clients: ids, maps: state.currentMaps })
66
65
  print(`[cli] Sent reload to client(s) ${ids.join(", ")}`)
67
66
  }
68
67
  }
@@ -104,7 +103,13 @@ async function cmdLoad(file: string) {
104
103
  // Same rule as /__control__/load (control.ts): a server run serves the
105
104
  // project it started in, and an entry outside the project root cannot
106
105
  // resolve the project's dependencies anyway.
107
- let norm = (p: string) => p.replace(/\\/g, "/")
106
+ // Windows paths are case-insensitive and the same drive shows up as both
107
+ // `c:` and `C:` (an editor-spawned bridge keeps its parent's spelling), so a
108
+ // drive-letter path folds case; a POSIX path stays exact.
109
+ let norm = (p: string) => {
110
+ let s = p.replace(/\\/g, "/")
111
+ return /^[a-zA-Z]:\//.test(s) ? s.toLowerCase() : s
112
+ }
108
113
  let root = norm(state.projectDir).replace(/\/+$/, "") + "/"
109
114
  if (!norm(path).startsWith(root)) {
110
115
  printErr(`[cli] Entry is outside the project root: ${path} is not under ${state.projectDir}. Restart srt in that project to work on it.`)
@@ -117,12 +122,12 @@ async function cmdLoad(file: string) {
117
122
  return
118
123
  }
119
124
  state.currentCode = result.code
120
- state.currentMap = result.map
125
+ state.currentMaps = bundleMaps(result)
121
126
  state.currentManifest = result.manifest
122
127
  } else if (file.endsWith(".srt.js")) {
123
128
  state.currentCode = await Bun.file(path).text()
124
- state.currentMap = null
125
- state.currentManifest = buildManifest(state.currentCode, path)
129
+ state.currentMaps = null
130
+ state.currentManifest = prebuiltManifest(state.currentCode, path, state.projectDir)
126
131
  } else if (file.endsWith(".srt.bin")) {
127
132
  let bytes = await Bun.file(path).arrayBuffer()
128
133
  // One-shot: bytecode loads are pushed but not latched for late joiners.
@@ -144,7 +149,7 @@ async function cmdLoad(file: string) {
144
149
  latch: true,
145
150
  sourceDir: state.sourceDir,
146
151
  entry: file.endsWith(".tsx") ? path : undefined,
147
- map: state.currentMap,
152
+ maps: state.currentMaps,
148
153
  })
149
154
  print(`[cli] Loaded ${file}`)
150
155
  }
@@ -185,6 +190,17 @@ function guard(p: Promise<void>) {
185
190
  }
186
191
 
187
192
  export function startRepl() {
193
+ // Without a terminal there is nobody to prompt, and stdin is at EOF from the
194
+ // start: readline would fire `close` immediately and shutdown() would tear
195
+ // down the server, the client and the registry record about a second after
196
+ // boot. A backgrounded or supervisor-launched srt therefore runs with no
197
+ // repl at all, kept alive by the server process and the watcher, and stopped
198
+ // with a signal. See okf/backlog/srt-run-exits-on-stdin-eof.md.
199
+ if (!process.stdin.isTTY) {
200
+ print("[cli] No terminal on stdin, running without the repl")
201
+ return
202
+ }
203
+
188
204
  state.rl = createInterface({ input: process.stdin, output: process.stdout, completer })
189
205
  state.rl.setPrompt("srt> ")
190
206