@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.
@@ -10,13 +10,13 @@
10
10
  "android": "srt client --android"
11
11
  },
12
12
  "dependencies": {
13
- "@solidrt/core": "0.0.50",
14
- "@solidrt/components": "0.0.50",
15
- "@solidrt/3d": "0.0.50"
13
+ "@solidrt/core": "0.0.51",
14
+ "@solidrt/components": "0.0.51",
15
+ "@solidrt/3d": "0.0.51"
16
16
  },
17
17
  "devDependencies": {
18
- "@solidrt/cli": "0.0.50",
19
- "@solidrt/flux-types": "0.0.50",
18
+ "@solidrt/cli": "0.0.51",
19
+ "@solidrt/flux-types": "0.0.51",
20
20
  "typescript": "^7"
21
21
  }
22
22
  }
package/server/control.ts CHANGED
@@ -34,7 +34,7 @@ function sleep(ms: number): Promise<void> {
34
34
  /// A `log` message arrived from a client: buffer it and wake long-polls.
35
35
  /// Bundle positions in stack traces are remapped to .tsx sources on the way in.
36
36
  export function appendLog(client: number, level: string, text: string) {
37
- logs.push({ seq: ++logSeq, at: Date.now(), client, level, text: remapPositions(text, state.currentMap) })
37
+ logs.push({ seq: ++logSeq, at: Date.now(), client, level, text: remapPositions(text, state.currentMaps) })
38
38
  if (logs.length > LOG_CAP) logs.splice(0, logs.length - LOG_CAP)
39
39
  let waiters = logWaiters
40
40
  logWaiters = []
@@ -138,7 +138,7 @@ async function handleQuery(
138
138
  )
139
139
  // Error strings may carry stack traces (e.g. a debug command threw); remap
140
140
  // bundle positions to .tsx sources like appendLog does for forwarded logs.
141
- if (msg.error) return Response.json({ error: remapPositions(String(msg.error), state.currentMap) }, { status: 502 })
141
+ if (msg.error) return Response.json({ error: remapPositions(String(msg.error), state.currentMaps) }, { status: 502 })
142
142
  return Response.json(msg.data)
143
143
  }
144
144
 
@@ -225,8 +225,12 @@ export async function handleControl(req: Request, path: string, query: Map<strin
225
225
  if (query.get("props") === "true") extra.props = true
226
226
  return handleQuery(query, "tree", extra)
227
227
  }
228
- case "/__control__/stats":
229
- return handleQuery(query, "stats")
228
+ case "/__control__/stats": {
229
+ let extra: Record<string, unknown> = {}
230
+ let windowMs = parseInt(query.get("window") ?? "", 10)
231
+ if (Number.isFinite(windowMs)) extra.windowMs = windowMs
232
+ return handleQuery(query, "stats", extra)
233
+ }
230
234
  case "/__control__/snapshot": {
231
235
  let nodeId = parseInt(query.get("node") ?? "", 10)
232
236
  if (!Number.isFinite(nodeId)) return Response.json({ error: "Snapshot requires ?node=<id>" }, { status: 400 })
@@ -361,7 +365,13 @@ export async function handleControl(req: Request, path: string, query: Map<strin
361
365
  // An entry outside the project root cannot resolve the project's
362
366
  // dependencies, so the bundler would fail with misleading "bun install"
363
367
  // advice; name the real constraint instead.
364
- let norm = (p: string) => p.replace(/\\/g, "/")
368
+ // Windows paths are case-insensitive and the same drive shows up as both
369
+ // `c:` and `C:` (an editor-spawned bridge keeps its parent's spelling), so a
370
+ // drive-letter path folds case; a POSIX path stays exact.
371
+ let norm = (p: string) => {
372
+ let s = p.replace(/\\/g, "/")
373
+ return /^[a-zA-Z]:\//.test(s) ? s.toLowerCase() : s
374
+ }
365
375
  let root = norm(state.projectDir).replace(/\/+$/, "") + "/"
366
376
  if (!norm(entry).startsWith(root)) {
367
377
  return Response.json(
package/server/main.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // config argument; bundling, file watching, and the repl stay in srt, which
3
3
  // drives this process over the loopback-only /__internal__/ routes. The
4
4
  // shutdown-when-empty policy also lives in srt (it polls /__internal__/clients);
5
- // this process runs until srt kills it. See docs/flux-dev-server-plan.md.
5
+ // this process runs until srt kills it.
6
6
 
7
7
  import { serve } from "flux:http"
8
8
  import type { FluxRequest, Server } from "flux:http"
@@ -74,20 +74,20 @@ async function handleInternal(req: FluxRequest, server: Server, path: string): P
74
74
 
75
75
  switch (path) {
76
76
  case "/__internal__/reload": {
77
- // { message, clients?, latch?, sourceDir?, map? }: send `message` (a
77
+ // { message, clients?, latch?, sourceDir?, maps? }: send `message` (a
78
78
  // full client-protocol message, built by srt) to the listed client ids,
79
79
  // or to all when omitted. `latch` keeps it for late-joining clients
80
80
  // (code reloads latch, one-shot bytecode loads do not); `sourceDir`
81
81
  // moves the file-serving root (repl `load`; the project root - and with
82
- // it the /assets/ root - is fixed for the life of the run); `map` is
83
- // the bundle's sourcemap for log remapping, replaced on every reload
84
- // (absent means none).
82
+ // it the /assets/ root - is fixed for the life of the run); `maps` is
83
+ // the bundle's sourcemaps keyed by module name for log remapping,
84
+ // replaced on every reload (absent means none).
85
85
  let body = await req.json()
86
86
  if (typeof body.sourceDir === "string") state.sourceDir = body.sourceDir
87
87
  // Keep the rebuild entry in sync when `load` moves it, so a later MCP
88
88
  // reload bundles the newly loaded file, not the launch-time one.
89
89
  if (typeof body.entry === "string") state.config.entry = body.entry
90
- state.currentMap = typeof body.map === "string" ? body.map : null
90
+ state.currentMaps = body.maps && typeof body.maps === "object" ? body.maps : null
91
91
  let text = JSON.stringify(body.message)
92
92
  if (body.latch) state.currentReload = text
93
93
  sendTo(body.clients, text)
@@ -99,7 +99,7 @@ async function handleInternal(req: FluxRequest, server: Server, path: string): P
99
99
  // connects afterwards starts clean.
100
100
  if (!body.clients) {
101
101
  state.currentReload = null
102
- state.currentMap = null
102
+ state.currentMaps = null
103
103
  }
104
104
  sendTo(body.clients, JSON.stringify({ type: "stop" }))
105
105
  return new Response("", { status: 204 })
@@ -298,7 +298,7 @@ if (!config.tunnel) {
298
298
  }
299
299
  console.log(`[cli] WebSocket server on ws://${state.serverUrl}`)
300
300
  // mDNS advertise is intentionally not implemented here: the p2p ticket is the
301
- // cross-device connect story (see docs/flux-dev-server-plan.md).
301
+ // cross-device connect story (see okf/backlog/mdns-discovery.md).
302
302
 
303
303
  // Keepalive
304
304
  setInterval(() => {
package/server/rebuild.ts CHANGED
@@ -47,7 +47,12 @@ export async function rebuildAndBroadcast(): Promise<string | null> {
47
47
  }
48
48
 
49
49
  // bundle-cli writes one JSON object { code, map, manifest, isolates } to stdout.
50
- let bundle: { code?: string; map?: string | null; manifest?: string; isolates?: { id: string; code: string }[] }
50
+ let bundle: {
51
+ code?: string
52
+ map?: string | null
53
+ manifest?: string
54
+ isolates?: { id: string; code: string; map?: string | null }[]
55
+ }
51
56
  try {
52
57
  bundle = JSON.parse(typeof result.stdout === "string" ? result.stdout : "")
53
58
  } catch {
@@ -55,12 +60,15 @@ export async function rebuildAndBroadcast(): Promise<string | null> {
55
60
  }
56
61
  // Isolate bundles are manifest assets clients fetch from our /isolates/
57
62
  // route (served from cacheDir), so they must be on disk before the push.
63
+ let maps: Record<string, string> = {}
64
+ if (bundle.map) maps.main = bundle.map
58
65
  for (let isolate of bundle.isolates ?? []) {
59
66
  let path = `${config.cacheDir}/isolates/${isolate.id}.js`
60
67
  await dir(path.slice(0, path.lastIndexOf("/"))).create()
61
68
  await file(path).write(isolate.code)
69
+ if (isolate.map) maps[isolate.id] = isolate.map
62
70
  }
63
- state.currentMap = bundle.map ?? null
71
+ state.currentMaps = Object.keys(maps).length ? maps : null
64
72
  let text = JSON.stringify(buildReload(bundle.code ?? "", bundle.manifest))
65
73
  state.currentReload = text
66
74
  for (let ws of state.clients.keys()) ws.send(text)
package/server/remap.ts CHANGED
@@ -1,47 +1,60 @@
1
1
  import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping"
2
2
 
3
3
  // Stack-trace remapping for forwarded client logs. The runtime evaluates the
4
- // bundle as module "main", so QuickJS frames cite bundle positions like
5
- // "at boom (main:212:9)". With the current reload's sourcemap latched on the
6
- // server (state.currentMap), those positions are rewritten to the original
7
- // .tsx sources before a log entry is buffered.
4
+ // app bundle as module "main" and each isolate bundle under its isolate id,
5
+ // so QuickJS frames cite bundle positions like "at boom (main:212:9)" or
6
+ // "at boom (worker:65:13)". With the current reload's sourcemaps latched on
7
+ // the server (state.currentMaps, keyed by module name), those positions are
8
+ // rewritten to the original .tsx sources before a log entry is buffered. A
9
+ // module without a map is left as it is: a position is never remapped against
10
+ // another module's map.
8
11
 
9
- // The parsed map is cached per map text; a reload swaps the text and the next
10
- // lookup rebuilds the tracer.
11
- let cachedText: string | null = null
12
- let tracer: TraceMap | null = null
12
+ // Parsed maps are cached per module name and map text; a reload swaps the
13
+ // texts and the next lookup rebuilds the tracers. Entries for removed modules
14
+ // linger unused, which is harmless.
15
+ let cached = new Map<string, { text: string; tracer: TraceMap | null }>()
13
16
 
14
- function tracerFor(map: string | null): TraceMap | null {
15
- if (map !== cachedText) {
16
- cachedText = map
17
- tracer = null
18
- if (map) {
19
- try {
20
- tracer = new TraceMap(JSON.parse(map))
21
- } catch {
22
- // A malformed map disables remapping until the next reload.
23
- }
17
+ function tracerFor(name: string, text: string): TraceMap | null {
18
+ let entry = cached.get(name)
19
+ if (!entry || entry.text !== text) {
20
+ let tracer: TraceMap | null = null
21
+ try {
22
+ tracer = new TraceMap(JSON.parse(text))
23
+ } catch {
24
+ // A malformed map disables remapping for this module until the next reload.
24
25
  }
26
+ entry = { text, tracer }
27
+ cached.set(name, entry)
25
28
  }
26
- return tracer
29
+ return entry.tracer
27
30
  }
28
31
 
32
+ let REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g
33
+
29
34
  /**
30
- * Rewrite every "main:LINE:COL" (or "main:LINE") position in `text` to its
31
- * original source position, e.g. "src/app.tsx:42:7". Positions the map has no
32
- * entry for, and all text when `map` is null, pass through unchanged.
35
+ * Rewrite every "NAME:LINE:COL" (or "NAME:LINE") position in `text`, for each
36
+ * module NAME in `maps`, to its original source position, e.g.
37
+ * "src/app.tsx:42:7". Positions a map has no entry for, positions of modules
38
+ * without a map, and all text when `maps` is null, pass through unchanged.
33
39
  * QuickJS lines and columns are 1-based; sourcemap columns are 0-based.
34
40
  */
35
- export function remapPositions(text: string, map: string | null): string {
36
- if (!map || !text.includes("main:")) return text
37
- let t = tracerFor(map)
38
- if (!t) return text
39
- return text.replace(/\bmain:(\d+)(?::(\d+))?\b/g, (frame, line, column) => {
40
- let pos = originalPositionFor(t, {
41
- line: parseInt(line, 10),
42
- column: column ? Math.max(parseInt(column, 10) - 1, 0) : 0,
41
+ export function remapPositions(text: string, maps: Record<string, string> | null): string {
42
+ if (!maps) return text
43
+ for (let [name, map] of Object.entries(maps)) {
44
+ if (!text.includes(name + ":")) continue
45
+ let t = tracerFor(name, map)
46
+ if (!t) continue
47
+ // The leading capture keeps a name from matching inside a longer one
48
+ // ("audio" inside "workers/audio" or "app" inside "src/app.tsx").
49
+ let pattern = new RegExp(`(^|[^\\w/.$-])${name.replace(REGEX_SPECIALS, "\\$&")}:(\\d+)(?::(\\d+))?\\b`, "g")
50
+ text = text.replace(pattern, (frame, prefix, line, column) => {
51
+ let pos = originalPositionFor(t, {
52
+ line: parseInt(line, 10),
53
+ column: column ? Math.max(parseInt(column, 10) - 1, 0) : 0,
54
+ })
55
+ if (pos.source == null || pos.line == null) return frame
56
+ return `${prefix}${pos.source}:${pos.line}:${(pos.column ?? 0) + 1}`
43
57
  })
44
- if (pos.source == null || pos.line == null) return frame
45
- return `${pos.source}:${pos.line}:${(pos.column ?? 0) + 1}`
46
- })
58
+ }
59
+ return text
47
60
  }
package/server/state.ts CHANGED
@@ -66,12 +66,13 @@ export let state = {
66
66
  */
67
67
  currentReload: null as string | null,
68
68
  /**
69
- * The running bundle's sourcemap (JSON text, bundle -> .tsx sources), used
70
- * to remap stack traces in forwarded client logs (see control.ts). Replaced
71
- * on every reload; a reload without a map clears it so frames are never
72
- * remapped against a stale map.
69
+ * The running bundle's sourcemaps (JSON text, bundle -> .tsx sources),
70
+ * keyed by the module name stack frames cite ("main" for the app, the
71
+ * isolate id for each isolate), used to remap stack traces in forwarded
72
+ * client logs (see control.ts). Replaced on every reload; a reload without
73
+ * maps clears them so frames are never remapped against a stale map.
73
74
  */
74
- currentMap: null as string | null,
75
+ currentMaps: null as Record<string, string> | null,
75
76
  sourceDir: "",
76
77
  projectDir: "",
77
78
  serverUrl: "",
package/src/args.ts CHANGED
@@ -35,7 +35,6 @@ export let { values, positionals } = parseArgs({
35
35
  port: { type: "string" },
36
36
  android: { type: "boolean", default: false },
37
37
  device: { type: "string" },
38
- with: { type: "string" },
39
38
  },
40
39
  allowPositionals: true,
41
40
  })
@@ -81,9 +80,11 @@ export function clientStorageArgs(): string[] {
81
80
  export let command = positionals[0]
82
81
  export let source = positionals[1]
83
82
  export let isTsx = source?.endsWith(".tsx") || source?.endsWith(".jsx")
84
- export let isTs = source?.endsWith(".ts") || source?.endsWith(".js")
85
- export let isSource = isTsx || isTs
86
83
  export let isPrebuilt = source?.endsWith(".srt.js") || source?.endsWith(".srt.bin")
84
+ // A .srt.js also ends with .js: prebuilt wins, or the server would re-bundle
85
+ // a prebuilt bundle as source and skip the prebuilt load path.
86
+ export let isTs = (source?.endsWith(".ts") || source?.endsWith(".js")) && !isPrebuilt
87
+ export let isSource = isTsx || isTs
87
88
 
88
89
  function usage(line: string): never {
89
90
  console.error("Usage: " + line)
@@ -158,9 +159,6 @@ Commands:
158
159
  pack <file> Bundle + compile to a standalone executable (experimental)
159
160
  mcp MCP server (stdio) exposing the running dev server to coding agents
160
161
 
161
- init options:
162
- --with <pkg,pkg> Extensions to include, e.g. @solidrt/components,@solidrt/3d (skips the picker)
163
-
164
162
  run/server options:
165
163
  -s, --session <N> Session number: dev server on port 34884+N, client slot N (default: 0)
166
164
  --port <N> Dev server port (default: 34884 + session)
@@ -191,7 +189,7 @@ bundle options:
191
189
  -d, --dev Use development build of SolidJS (default: production)
192
190
  -m, --minify Minify the output
193
191
  --compile Compile to bytecode
194
- -o, --output <name> Output filename
192
+ -o, --output <dir> Output directory (default: <project>/dist/bundle)
195
193
  --stdout Write bundle to stdout
196
194
 
197
195
  pack options:
package/src/bundler.ts CHANGED
@@ -4,11 +4,11 @@ import ts from "@babel/preset-typescript"
4
4
  import remapping from "@jridgewell/remapping"
5
5
  import solid from "babel-preset-solid"
6
6
  import { type BunPlugin, type BuildArtifact } from "bun"
7
- import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
8
8
  import { dirname, join, relative, resolve as resolvePath, sep } from "node:path"
9
9
  import { values, source } from "./args"
10
10
  import { state, print, requireBinary } from "./util"
11
- import { buildManifest, manifestAssetFor } from "./project"
11
+ import { buildManifest, manifestAssetFor, type ManifestAsset } from "./project"
12
12
 
13
13
  // Babel plugin: rewrite `import data from "./x" with { type: "binary" }` into an
14
14
  // inline Uint8Array of the file's bytes, and `with { type: "text" }` into an
@@ -129,26 +129,39 @@ export function hasIsolateDirective(code: string): boolean {
129
129
 
130
130
  let SKIP_DIRS = new Set(["node_modules", "dist"])
131
131
 
132
- export type IsolateModule = { id: string; path: string }
133
-
134
- /** Every "use isolate" module under `root`, in id order. */
135
- export function findIsolateModules(root: string): IsolateModule[] {
136
- let out: IsolateModule[] = []
132
+ /**
133
+ * Depth-first files under `root`, visited as (absolute path, forward-slash
134
+ * path relative to root). Dotfiles and `skipDirs` are skipped; a missing
135
+ * root visits nothing.
136
+ */
137
+ export function walkFiles(root: string, visit: (abs: string, rel: string) => void, skipDirs?: Set<string>) {
138
+ if (!existsSync(root)) return
137
139
  let walk = (dir: string) => {
138
140
  for (let entry of readdirSync(dir, { withFileTypes: true })) {
139
- if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue
141
+ if (entry.name.startsWith(".") || skipDirs?.has(entry.name)) continue
140
142
  let abs = join(dir, entry.name)
141
- if (entry.isDirectory()) {
142
- walk(abs)
143
- } else if (entry.isFile() && /\.(js|ts)x?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
144
- if (hasIsolateDirective(readFileSync(abs, "utf8"))) {
145
- let id = relative(root, abs).split(sep).join("/").replace(/\.(js|ts)x?$/, "")
146
- out.push({ id, path: abs })
147
- }
148
- }
143
+ if (entry.isDirectory()) walk(abs)
144
+ else if (entry.isFile()) visit(abs, relative(root, abs).split(sep).join("/"))
149
145
  }
150
146
  }
151
147
  walk(root)
148
+ }
149
+
150
+ export type IsolateModule = { id: string; path: string }
151
+
152
+ /** Every "use isolate" module under `root`, in id order. */
153
+ export function findIsolateModules(root: string): IsolateModule[] {
154
+ let out: IsolateModule[] = []
155
+ walkFiles(
156
+ root,
157
+ (abs, rel) => {
158
+ if (!/\.(js|ts)x?$/.test(rel) || rel.endsWith(".d.ts")) return
159
+ if (hasIsolateDirective(readFileSync(abs, "utf8"))) {
160
+ out.push({ id: rel.replace(/\.(js|ts)x?$/, ""), path: abs })
161
+ }
162
+ },
163
+ SKIP_DIRS,
164
+ )
152
165
  out.sort((a, b) => (a.id < b.id ? -1 : 1))
153
166
  return out
154
167
  }
@@ -166,8 +179,20 @@ export type BundleResult = {
166
179
  map: string | null
167
180
  /** Version manifest JSON for this bundle; clients install pushes under its hash. */
168
181
  manifest: string
169
- /** The app's isolate bundles, one per "use isolate" module, in id order. */
170
- isolates: { id: string; code: string }[]
182
+ /** The app's isolate bundles, one per "use isolate" module, in id order; maps dev builds only. */
183
+ isolates: { id: string; code: string; map: string | null }[]
184
+ }
185
+
186
+ /**
187
+ * The bundle's sourcemaps keyed by the module name stack frames cite ("main"
188
+ * for the app, the isolate id for each isolate), for the server's log remap.
189
+ * Null when the build carries no maps (production builds).
190
+ */
191
+ export function bundleMaps(result: BundleResult): Record<string, string> | null {
192
+ let maps: Record<string, string> = {}
193
+ if (result.map) maps.main = result.map
194
+ for (let i of result.isolates) if (i.map) maps[i.id] = i.map
195
+ return Object.keys(maps).length ? maps : null
171
196
  }
172
197
 
173
198
  // The pure bundle: every input is explicit, so it runs identically in the srt
@@ -189,8 +214,8 @@ export async function bundleWith(opts: BundleOptions): Promise<BundleResult | nu
189
214
 
190
215
  // One Bun.build per entry: the app, then each isolate module as its own
191
216
  // self-contained bundle (splitting is off, so a helper both import gets
192
- // duplicated rather than shared). Only the app's build gets a composed
193
- // sourcemap for now.
217
+ // duplicated rather than shared). In dev every build gets a composed
218
+ // sourcemap, keyed downstream by its module name.
194
219
  let build = async (entry: string, babelMaps?: Map<string, object>, isolateEntry?: string) => {
195
220
  let result = null
196
221
  try {
@@ -221,22 +246,31 @@ export async function bundleWith(opts: BundleOptions): Promise<BundleResult | nu
221
246
  if (!main) return null
222
247
  let code = await codeFromOutputs(main.outputs)
223
248
 
224
- let isolates: { id: string; code: string }[] = []
249
+ let isolates: { id: string; code: string; map: string | null }[] = []
225
250
  for (let module of findIsolateModules(dirname(resolvePath(opts.entry)))) {
226
- let result = await build(module.path, undefined, module.path)
251
+ let moduleMaps = opts.dev ? new Map<string, object>() : undefined
252
+ let result = await build(module.path, moduleMaps, module.path)
227
253
  if (!result) return null
228
- isolates.push({ id: module.id, code: await codeFromOutputs(result.outputs) })
254
+ isolates.push({
255
+ id: module.id,
256
+ code: await codeFromOutputs(result.outputs),
257
+ map: await composeMap(result.outputs, moduleMaps),
258
+ })
229
259
  }
230
260
 
231
- let extra = isolates.map((i) => manifestAssetFor(isolateAssetPath(i.id, "js"), Buffer.from(i.code, "utf8")))
232
261
  return {
233
262
  code,
234
263
  map: await composeMap(main.outputs, babelMaps),
235
- manifest: buildManifest(code, opts.entry, extra),
264
+ manifest: buildManifest(code, opts.entry, isolateManifestAssets(isolates)),
236
265
  isolates,
237
266
  }
238
267
  }
239
268
 
269
+ /** The manifest assets for a set of isolate bundles (dev form: isolates/<id>.js). */
270
+ export function isolateManifestAssets(isolates: { id: string; code: string }[]): ManifestAsset[] {
271
+ return isolates.map((i) => manifestAssetFor(isolateAssetPath(i.id, "js"), Buffer.from(i.code, "utf8")))
272
+ }
273
+
240
274
  // Write dev isolate bundles where the dev server serves /isolates/ from
241
275
  // (<project>/.srt-data/isolates/<id>.js), so clients can fetch the manifest
242
276
  // assets the bundle lists. Stale files from removed modules stay behind
@@ -287,14 +321,52 @@ export function devIsolatesDir(projectDir: string): string {
287
321
  return join(projectDir, ".srt-data", "isolates")
288
322
  }
289
323
 
290
- export async function bundleTo(outfile: string) {
291
- let result = await bundle()
292
- if (!result) {
293
- console.error("Build failed")
294
- process.exit(1)
295
- }
296
- await Bun.write(outfile, result.code)
297
- return result
324
+ // A flux entry's isolate modules: everything under its isolates/ dir, id =
325
+ // the path relative to that dir without extension. Standalone flux resolves
326
+ // isolates by location, not directive - module <id> is
327
+ // <entry dir>/isolates/<id>.bin or .js - so this is the discovery for
328
+ // bundling and packing flux scripts (which also lets a worker be .ts, unlike
329
+ // running from source).
330
+ export function findFluxIsolates(entryDir: string): IsolateModule[] {
331
+ let out: IsolateModule[] = []
332
+ walkFiles(join(entryDir, "isolates"), (abs, rel) => {
333
+ if (/\.[jt]s$/.test(rel) && !rel.endsWith(".d.ts")) {
334
+ out.push({ id: rel.replace(/\.[jt]s$/, ""), path: abs })
335
+ }
336
+ })
337
+ out.sort((a, b) => (a.id < b.id ? -1 : 1))
338
+ return out
339
+ }
340
+
341
+ // A bundle cannot carry its isolate bundles inside itself, so they travel in
342
+ // the isolates/ dir next to it: `<dir>/isolates/<id>.js` (or `.bin`,
343
+ // compiled) beside the bundle file - the shape the flux runtime and an
344
+ // installed version dir resolve. Writes are confined to bundle-owned output
345
+ // dirs (the ensureOutDir rule in the bundle command); loads read the dir
346
+ // from wherever the bundle sits.
347
+ export function bundleIsolatesDir(bundlePath: string): string {
348
+ return join(dirname(bundlePath), "isolates")
349
+ }
350
+
351
+ /** A prebuilt bundle's isolate bundles, read back from its sibling dir; no dir means none. */
352
+ export function readPrebuiltIsolates(bundlePath: string): { id: string; code: string }[] {
353
+ let out: { id: string; code: string }[] = []
354
+ walkFiles(bundleIsolatesDir(resolvePath(bundlePath)), (abs, rel) => {
355
+ if (rel.endsWith(".js")) {
356
+ out.push({ id: rel.replace(/\.js$/, ""), code: readFileSync(abs, "utf8") })
357
+ }
358
+ })
359
+ out.sort((a, b) => (a.id < b.id ? -1 : 1))
360
+ return out
361
+ }
362
+
363
+ // Loading a prebuilt .srt.js re-publishes its sibling isolate bundles through
364
+ // the dev flow - written where the server's /isolates/ route serves from,
365
+ // listed in the manifest - so isolate() works as it does from a source build.
366
+ export function prebuiltManifest(code: string, path: string, projectDir: string): string {
367
+ let isolates = readPrebuiltIsolates(path)
368
+ writeIsolates(devIsolatesDir(projectDir), isolates)
369
+ return buildManifest(code, path, isolateManifestAssets(isolates))
298
370
  }
299
371
 
300
372
  // Bundle for the bare Flux runtime: no Solid plugin, flux: modules stay external.
@@ -314,7 +386,8 @@ export async function bundleFlux(entry: string): Promise<string> {
314
386
  return codeFromOutputs(result.outputs)
315
387
  }
316
388
 
317
- // Bundle for the SolidRT runtime via the standard Solid-aware bundler.
389
+ // Bundle for the SolidRT runtime via the standard Solid-aware bundler, or
390
+ // exit the command on a failed build.
318
391
  export async function bundleSolid(): Promise<BundleResult> {
319
392
  let result = await bundle()
320
393
  if (!result) {
@@ -324,10 +397,12 @@ export async function bundleSolid(): Promise<BundleResult> {
324
397
  return result
325
398
  }
326
399
 
327
- // Compile JS source to QuickJS bytecode via the fluxc binary.
328
- export async function compileToBytecode(jsCode: string): Promise<Buffer> {
400
+ // Compile JS source to QuickJS bytecode via the fluxc binary. `moduleName` is
401
+ // what stack frames cite at runtime: "main" for the entry, the isolate id for
402
+ // an isolate bundle.
403
+ export async function compileToBytecode(jsCode: string, moduleName = "main"): Promise<Buffer> {
329
404
  let compiler = requireBinary("fluxc")
330
- let proc = Bun.spawn([compiler], {
405
+ let proc = Bun.spawn([compiler, moduleName], {
331
406
  stdin: new Blob([jsCode]),
332
407
  stdout: "pipe",
333
408
  stderr: "inherit",