@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.
- package/AGENTS.md +20 -7
- package/agents/assets.md +32 -0
- package/agents/debugging.md +142 -0
- package/package.json +8 -6
- package/scaffold/AGENTS.md +102 -547
- package/scaffold/package.json +5 -5
- package/server/control.ts +15 -5
- package/server/main.ts +8 -8
- package/server/rebuild.ts +10 -2
- package/server/remap.ts +46 -33
- package/server/state.ts +6 -5
- package/src/args.ts +5 -7
- package/src/bundler.ts +112 -37
- package/src/commands/bundle.ts +106 -21
- package/src/commands/check.ts +7 -0
- package/src/commands/init.ts +18 -18
- package/src/commands/mcp.ts +19 -5
- package/src/commands/pack.ts +18 -8
- package/src/commands/render.ts +28 -5
- package/src/commands/server.ts +5 -5
- package/src/dev-server.ts +7 -7
- package/src/packer.ts +16 -12
- package/src/repl.ts +27 -11
- package/src/util.ts +4 -3
- package/src/watcher.ts +7 -3
package/src/commands/bundle.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { values, source, isPrebuilt } from "../args"
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
//
|
|
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
|
|
25
|
-
let
|
|
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
|
-
|
|
30
|
-
|
|
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 =
|
|
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
|
|
129
|
+
let entry = resolve(source!)
|
|
130
|
+
let name = basename(entry).replace(/\.[jt]sx?$/, "")
|
|
51
131
|
|
|
52
132
|
if (values.stdout) {
|
|
53
|
-
let result = await
|
|
54
|
-
if (
|
|
55
|
-
console.error("
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
73
|
-
let
|
|
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
|
+
}
|
package/src/commands/check.ts
CHANGED
|
@@ -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 })
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
|
52
|
-
//
|
|
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
|
-
|
|
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
|
|
package/src/commands/mcp.ts
CHANGED
|
@@ -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
|
|
247
|
-
inputSchema: {
|
|
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()
|
|
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
|
-
|
|
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))
|
package/src/commands/pack.ts
CHANGED
|
@@ -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]
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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()
|
package/src/commands/render.ts
CHANGED
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
import { appArgs, source, values } from "../args"
|
|
2
2
|
import { requireBinary, run } from "../util"
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
8
|
-
|
|
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(
|
|
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)
|
package/src/commands/server.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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); `
|
|
66
|
-
* server-side for stack-trace
|
|
67
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
8
|
-
// the runner-side checks:
|
|
9
|
-
// lattice/src/main.rs
|
|
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
|
|
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
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
125
|
-
state.currentManifest =
|
|
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
|
-
|
|
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
|
|