@solidrt/cli 0.0.49 → 0.0.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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/prompt.ts CHANGED
@@ -1,18 +1,21 @@
1
- import { createInterface, emitKeypressEvents } from "node:readline"
1
+ import * as clack from "@clack/prompts"
2
+
3
+ // Thin wrappers over @clack/prompts. Every prompt guards on a TTY: a non-TTY
4
+ // stdin resolves the default rather than blocking on input that will never
5
+ // arrive. Cancelling (ctrl-c) exits the process.
6
+
7
+ function unwrap<T>(value: T | symbol): T {
8
+ if (clack.isCancel(value)) {
9
+ clack.cancel("Cancelled")
10
+ process.exit(130)
11
+ }
12
+ return value as T
13
+ }
2
14
 
3
- // Single-line text prompt with an optional default (shown in parentheses, used
4
- // when the answer is blank). Non-TTY stdin resolves the default rather than
5
- // blocking on input that will never arrive.
6
- export function text(message: string, def = ""): Promise<string> {
7
- return new Promise<string>((resolve) => {
8
- if (!process.stdin.isTTY) return resolve(def)
9
- let rl = createInterface({ input: process.stdin, output: process.stdout })
10
- let suffix = def ? ` (${def})` : ""
11
- rl.question(`? ${message}${suffix}: `, (answer) => {
12
- rl.close()
13
- resolve(answer.trim() || def)
14
- })
15
- })
15
+ // Single-line text prompt; a blank answer resolves the default.
16
+ export async function text(message: string, def = ""): Promise<string> {
17
+ if (!process.stdin.isTTY) return def
18
+ return unwrap(await clack.text({ message, defaultValue: def, placeholder: def }))
16
19
  }
17
20
 
18
21
  export interface SelectOption {
@@ -20,65 +23,36 @@ export interface SelectOption {
20
23
  value: string
21
24
  }
22
25
 
23
- // Minimal arrow-key single-select prompt, built on node:readline (same
24
- // dependency-free approach as repl.ts). Renders the option list, moves the
25
- // highlight on up/down, resolves the chosen value on enter. Options are plain
26
- // strings or { label, value } pairs when the display text differs from the
27
- // resolved value. Callers guard on process.stdin.isTTY; a non-TTY stdin here
28
- // resolves the first option rather than hanging on input that will never
29
- // arrive.
30
- export function select(message: string, options: Array<string | SelectOption>): Promise<string> {
26
+ // Arrow-key single-select; non-TTY resolves the first option.
27
+ export async function select(message: string, options: Array<string | SelectOption>): Promise<string> {
31
28
  let items = options.map((o) => (typeof o === "string" ? { label: o, value: o } : o))
32
- return new Promise((resolve) => {
33
- let input = process.stdin
34
- let output = process.stdout
35
- if (!input.isTTY) return resolve(items[0]!.value)
36
-
37
- let selected = 0
38
- emitKeypressEvents(input)
39
- let wasRaw = input.isRaw
40
- input.setRawMode(true)
41
-
42
- let render = (first = false) => {
43
- // After the first paint the cursor sits below the block; move it back up
44
- // to the message line so the list redraws in place.
45
- if (!first) output.write(`\x1b[${items.length + 1}A`)
46
- output.write(`\x1b[K? ${message}\n`)
47
- for (let i = 0; i < items.length; i++) {
48
- let active = i === selected
49
- let pointer = active ? "\x1b[36m> " : " "
50
- let reset = active ? "\x1b[0m" : ""
51
- output.write(`\x1b[K${pointer}${items[i]!.label}${reset}\n`)
52
- }
53
- }
54
-
55
- let cleanup = () => {
56
- input.off("keypress", onKey)
57
- input.setRawMode(wasRaw)
58
- input.pause()
59
- }
29
+ if (!process.stdin.isTTY) return items[0]!.value
30
+ return unwrap(await clack.select({ message, options: items }))
31
+ }
60
32
 
61
- let onKey = (_str: string, key: { name: string; ctrl: boolean } | undefined) => {
62
- if (!key) return
63
- if (key.name === "up") {
64
- selected = (selected - 1 + items.length) % items.length
65
- render()
66
- } else if (key.name === "down") {
67
- selected = (selected + 1) % items.length
68
- render()
69
- } else if (key.name === "return" || key.name === "enter") {
70
- cleanup()
71
- output.write("\n")
72
- resolve(items[selected]!.value)
73
- } else if (key.ctrl && (key.name === "c" || key.name === "d")) {
74
- cleanup()
75
- output.write("\n")
76
- process.exit(130)
77
- }
78
- }
33
+ export interface MultiSelectOption {
34
+ label: string
35
+ value: string
36
+ checked?: boolean
37
+ }
79
38
 
80
- input.on("keypress", onKey)
81
- input.resume()
82
- render(true)
83
- })
39
+ // Space toggles, enter confirms; resolves the selected values in option
40
+ // order. Non-TTY resolves the preselected values.
41
+ export async function multiselect(message: string, options: MultiSelectOption[]): Promise<string[]> {
42
+ let preset = options.filter((o) => o.checked).map((o) => o.value)
43
+ if (!process.stdin.isTTY) return preset
44
+ let picked = unwrap(
45
+ await clack.multiselect({
46
+ message,
47
+ options: options.map((o) => ({ label: o.label, value: o.value })),
48
+ initialValues: preset,
49
+ required: false,
50
+ }),
51
+ )
52
+ return options.filter((o) => picked.includes(o.value)).map((o) => o.value)
84
53
  }
54
+
55
+ // Boxed informational message; silent on a non-TTY.
56
+ export function note(message: string, title?: string) {
57
+ if (process.stdin.isTTY) clack.note(message, title)
58
+ }
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
 
package/src/util.ts CHANGED
@@ -8,9 +8,10 @@ export let state = {
8
8
  // What srt believes the current bundle is; the server process keeps its own
9
9
  // latched copy for late-joining clients (see packages/cli/server/).
10
10
  currentCode: null as string | null,
11
- // The bundle's composed sourcemap (dev builds), sent to the server alongside
12
- // reloads so it can remap logged stack traces to .tsx positions.
13
- currentMap: null as string | null,
11
+ // The bundle's composed sourcemaps (dev builds), keyed by module name
12
+ // ("main", each isolate id), sent to the server alongside reloads so it can
13
+ // remap logged stack traces to .tsx positions.
14
+ currentMaps: null as Record<string, string> | null,
14
15
  // The bundle's version manifest (canonical JSON string, see buildManifest);
15
16
  // travels with code reloads so clients install the push as a version.
16
17
  currentManifest: null as string | null,
package/src/watcher.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync, watch } from "node:fs"
2
2
  import { resolve, dirname, sep, basename } from "path"
3
3
  import { state, print, printErr } from "./util"
4
4
  import { buildReload, sendReload, showBuildFailure, watchAllowed } from "./dev-server"
5
- import { bundle } from "./bundler"
5
+ import { bundle, bundleMaps } from "./bundler"
6
6
 
7
7
  let watchers: ReturnType<typeof watch>[] = []
8
8
 
@@ -25,11 +25,11 @@ async function rebuild(filename: string) {
25
25
  return
26
26
  }
27
27
  state.currentCode = result.code
28
- state.currentMap = result.map
28
+ state.currentMaps = bundleMaps(result)
29
29
  state.currentManifest = result.manifest
30
30
  await sendReload(buildReload({ code: state.currentCode, manifest: state.currentManifest }), {
31
31
  latch: true,
32
- map: state.currentMap,
32
+ maps: state.currentMaps,
33
33
  })
34
34
  }
35
35
 
@@ -53,6 +53,10 @@ export function startWatcher() {
53
53
  watchers.push(
54
54
  watch(watchDir, { recursive: true }, (_event, filename) => {
55
55
  if (!filename) return
56
+ // Dot directories are never sources: .srt-data in particular is this
57
+ // server's own output (isolate bundles are .js files in there), and
58
+ // rebuilding on it would rebuild forever.
59
+ if (filename.split(sep).some((part, i, parts) => i < parts.length - 1 && part.startsWith("."))) return
56
60
  if (!/\.(tsx?|jsx?)$/.test(filename) && !(covered && isAsset(filename))) return
57
61
  rebuild(filename)
58
62
  }),
@@ -1,4 +0,0 @@
1
- {
2
- "level": "components",
3
- "description": "components starter application"
4
- }
@@ -1,4 +0,0 @@
1
- {
2
- "level": "core",
3
- "description": "core starter application"
4
- }