@solidrt/cli 0.0.51 → 0.0.53

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.
Files changed (100) hide show
  1. package/AGENTS.md +68 -38
  2. package/README.md +86 -15
  3. package/agents/assets.md +19 -5
  4. package/agents/debugging.md +173 -17
  5. package/dist/console.srtapp +73916 -51
  6. package/dist/demos/3d/assets/icon.svg +23 -0
  7. package/dist/demos/3d/package.json +9 -0
  8. package/dist/demos/3d/the-third-dimension/the-third-dimension.srt.js +7528 -0
  9. package/dist/demos/components/assets/icon.png +0 -0
  10. package/dist/demos/components/assets/icon.svg +23 -0
  11. package/dist/demos/components/gallery/gallery.srt.js +14087 -0
  12. package/dist/demos/components/package.json +9 -0
  13. package/dist/server.js +3936 -0
  14. package/package.json +10 -11
  15. package/src/android/docs.md +21 -0
  16. package/src/android/main.ts +286 -0
  17. package/src/{bundler.ts → bundle/bundler.ts} +70 -58
  18. package/src/bundle/docs.md +12 -0
  19. package/src/{commands/bundle.ts → bundle/main.ts} +67 -25
  20. package/src/{untyped-deps.d.ts → bundle/untyped-deps.d.ts} +3 -3
  21. package/src/check/docs.md +10 -0
  22. package/src/check/main.ts +85 -0
  23. package/src/{commands/check.ts → check/typecheck.ts} +11 -41
  24. package/src/client/docs.md +9 -0
  25. package/src/client/main.ts +33 -0
  26. package/src/console/docs.md +14 -0
  27. package/src/console/main.ts +21 -0
  28. package/src/demo/docs.md +33 -0
  29. package/src/demo/main.ts +69 -0
  30. package/src/init/docs.md +11 -0
  31. package/src/{commands/init.ts → init/main.ts} +21 -15
  32. package/src/init/scaffold/AGENTS.md +100 -0
  33. package/src/init/scaffold/package.json +23 -0
  34. package/{scaffold → src/init/scaffold}/templates/components/index.tsx +2 -3
  35. package/{scaffold → src/init/scaffold}/templates/default/index.tsx +2 -3
  36. package/src/lib/args.ts +194 -0
  37. package/src/{artifacts.ts → lib/artifacts.ts} +41 -1
  38. package/src/{dev-dir.ts → lib/dev-dir.ts} +10 -8
  39. package/src/{fonts.ts → lib/fonts.ts} +12 -26
  40. package/src/lib/mode.ts +77 -0
  41. package/src/{project.ts → lib/project.ts} +109 -61
  42. package/src/lib/registry.ts +120 -0
  43. package/src/lib/server-bundle.ts +24 -0
  44. package/src/lib/usage.ts +117 -0
  45. package/src/lib/util.ts +36 -0
  46. package/src/main.ts +109 -31
  47. package/src/mcp/docs.md +22 -0
  48. package/src/mcp/main.ts +719 -0
  49. package/src/pack/docs.md +18 -0
  50. package/src/{pack-folder.ts → pack/layout.ts} +13 -22
  51. package/src/{commands/pack.ts → pack/main.ts} +28 -17
  52. package/src/{packer.ts → pack/trailer.ts} +32 -42
  53. package/src/render/docs.md +19 -0
  54. package/src/{commands/render.ts → render/main.ts} +17 -15
  55. package/src/server/args.ts +126 -0
  56. package/src/server/binaries.ts +47 -0
  57. package/src/server/config.ts +54 -0
  58. package/{server → src/server}/control.ts +239 -81
  59. package/src/server/docs.md +51 -0
  60. package/src/server/line-editor.ts +200 -0
  61. package/src/server/main.ts +476 -0
  62. package/src/server/mode.ts +92 -0
  63. package/src/server/rebuild.ts +90 -0
  64. package/src/server/registry.ts +138 -0
  65. package/src/server/repl.ts +223 -0
  66. package/src/server/state.ts +54 -0
  67. package/{server → src/server}/tsconfig.json +1 -1
  68. package/{server → src/server}/tunnel.ts +6 -6
  69. package/src/server/watcher.ts +121 -0
  70. package/src/tool/main.ts +70 -0
  71. package/src/types/bundle.d.ts +24 -0
  72. package/src/types/control.d.ts +90 -0
  73. package/src/types/registry.d.ts +16 -0
  74. package/scaffold/AGENTS.md +0 -185
  75. package/scaffold/package.json +0 -22
  76. package/scaffold/templates/components/icon.tsx +0 -48
  77. package/scaffold/templates/default/icon.tsx +0 -48
  78. package/server/main.ts +0 -308
  79. package/server/rebuild.ts +0 -76
  80. package/server/state.ts +0 -94
  81. package/src/args.ts +0 -210
  82. package/src/bundle-cli.ts +0 -13
  83. package/src/commands/client.ts +0 -34
  84. package/src/commands/mcp.ts +0 -617
  85. package/src/commands/server.ts +0 -73
  86. package/src/dev-android.ts +0 -176
  87. package/src/dev-client.ts +0 -29
  88. package/src/dev-server.ts +0 -302
  89. package/src/repl.ts +0 -249
  90. package/src/util.ts +0 -122
  91. package/src/watcher.ts +0 -73
  92. /package/src/{prompt.ts → init/prompt.ts} +0 -0
  93. /package/{scaffold → src/init/scaffold}/gitignore +0 -0
  94. /package/{scaffold → src/init/scaffold}/icon.svg +0 -0
  95. /package/{scaffold → src/init/scaffold}/mcp.json +0 -0
  96. /package/{scaffold → src/init/scaffold}/tsconfig.json +0 -0
  97. /package/{server → src/server}/cache.ts +0 -0
  98. /package/{server → src/server}/proxy.ts +0 -0
  99. /package/{server → src/server}/qr.ts +0 -0
  100. /package/{server → src/server}/remap.ts +0 -0
@@ -1,73 +0,0 @@
1
- import pkg from "../../package.json"
2
- import { source, isSource, isPrebuilt, values } from "../args"
3
- import { state, shutdown, print, printErr } from "../util"
4
- import { findProjectRoot, typecheck, reportTypes } from "./check"
5
- import { bundle, bundleMaps, prebuiltManifest } from "../bundler"
6
- import { projectDirFor } from "../project"
7
- import { startServer, buildReload, sendReload, showBuildFailure } from "../dev-server"
8
- import { startRepl } from "../repl"
9
- import { startWatcher } from "../watcher"
10
- import { resolve, dirname } from "path"
11
-
12
- // Brings up the dev server (a spawned flux script serving HTTP/WS) plus the
13
- // initial bundle, repl, and watcher in this process. The `run` command spawns
14
- // a local client on top of this from main.ts.
15
- export async function runServerCommand() {
16
- // Initialize state from args
17
- state.source = source
18
- state.sourceDir = source ? dirname(resolve(source)) : process.cwd()
19
- // With no entry the project is wherever srt was started: walk up to the
20
- // nearest package.json exactly like an entry would, so the projectDir the
21
- // MCP bridge derives for its registry match agrees with ours.
22
- state.projectDir = projectDirFor(source ? resolve(source) : resolve("package.json"))
23
- state.stats = values.stats
24
- state.capture = values.capture ? resolve(values.capture) : undefined
25
-
26
- // Spawns the server process and waits until it answers; it owns the QR and
27
- // address announcements, the capture file, and the proxy cache.
28
- await startServer()
29
-
30
- // Bundle initial code if source file given (after server start so the
31
- // dev base URL is available to the bundler), and latch it on the server
32
- // for the clients about to connect.
33
- if (source && isSource) {
34
- let initialResult = await bundle()
35
- if (initialResult) {
36
- state.currentCode = initialResult.code
37
- state.currentMaps = bundleMaps(initialResult)
38
- state.currentManifest = initialResult.manifest
39
- await sendReload(buildReload({ code: state.currentCode, manifest: state.currentManifest }), {
40
- latch: true,
41
- maps: state.currentMaps,
42
- })
43
- } else {
44
- await showBuildFailure()
45
- }
46
- } else if (source && isPrebuilt && source.endsWith(".srt.js")) {
47
- let path = resolve(source)
48
- state.currentCode = await Bun.file(path).text()
49
- state.currentManifest = prebuiltManifest(state.currentCode, path, state.projectDir)
50
- await sendReload(buildReload({ code: state.currentCode, manifest: state.currentManifest }), { latch: true })
51
- }
52
-
53
- process.on("SIGINT", shutdown)
54
- process.on("SIGTERM", shutdown)
55
-
56
- let version = pkg.version === "0.0.0" ? "" : " version " + pkg.version
57
- console.log(`[cli] Welcome to SolidRT${version}!`)
58
- startRepl()
59
- startWatcher()
60
-
61
- // Startup typecheck, deliberately not awaited: diagnostics print over the
62
- // repl when tsc finishes, and a type error never gates the boot (srt check
63
- // is the hard gate). Once per server lifetime; hot reloads never typecheck.
64
- // Source builds only: a prebuilt .srt.js has no checkable project here.
65
- if (source && isSource) startupTypecheck(source)
66
- }
67
-
68
- async function startupTypecheck(entry: string) {
69
- let root = findProjectRoot(entry)
70
- if (!root) return
71
- let types = await typecheck(root, entry)
72
- if (types) reportTypes(types, print, printErr)
73
- }
@@ -1,176 +0,0 @@
1
- import { networkInterfaces } from "node:os"
2
- import { print, requireAdb } from "./util"
3
- import { resolveApk, ANDROID_PKG_MAP } from "./artifacts"
4
- import { values } from "./args"
5
- import { DEV_PORT } from "./dev-server"
6
-
7
- // Launch component of the "go" dev-client flavor (see lattice/Makefile.android).
8
- let PACKAGE_ACTIVITY = "com.solidrt.go/com.solidrt.app.MainActivity"
9
-
10
- let ipToInt = (ip: string) => ip.split(".").reduce((acc, o) => (acc << 8) + (parseInt(o, 10) & 255), 0) >>> 0
11
-
12
- // This host's IPv4 on the same subnet as `deviceIp`: the interface whose
13
- // (address & netmask) matches the device's. Pure computation over what the OS
14
- // reports (cross-platform, no routing socket, no hardcoded ranges).
15
- function hostIpFor(deviceIp: string): string | null {
16
- let d = ipToInt(deviceIp)
17
- for (let addrs of Object.values(networkInterfaces())) {
18
- for (let a of addrs ?? []) {
19
- if (a.family === "IPv4" && !a.internal && (ipToInt(a.address) & ipToInt(a.netmask)) === (d & ipToInt(a.netmask))) {
20
- return a.address
21
- }
22
- }
23
- }
24
- return null
25
- }
26
-
27
- // The device's own IPv4 on its primary network: from the serial for wireless adb
28
- // (ip:port), else queried over adb. `ip route get` reports the source IP of the
29
- // route to a public address, i.e. the device's main interface IP.
30
- function deviceIp(adb: string, target: string): string | null {
31
- let m = target.match(/^(\d+\.\d+\.\d+\.\d+):\d+$/)
32
- if (m) return m[1] ?? null
33
- let res = Bun.spawnSync([adb, "-s", target, "shell", "ip", "route", "get", "1.1.1.1"], {
34
- stdout: "pipe",
35
- stderr: "pipe",
36
- })
37
- return res.stdout.toString().match(/src (\d+\.\d+\.\d+\.\d+)/)?.[1] ?? null
38
- }
39
-
40
- // The host:port the client on `target` should dial to reach this machine's dev
41
- // server, by adb transport: the emulator reaches the host through its NAT alias
42
- // 10.0.2.2; every other transport shares a LAN with the host, so match the
43
- // device's IP to the host interface on its subnet. Replaces the old adb-reverse
44
- // loopback tunnel, which never worked over wireless adb. Returns null when the
45
- // address cannot be resolved (the client then falls back to QR/recents).
46
- function devServerAddress(adb: string, target: string): string | null {
47
- if (target.startsWith("emulator-")) return `10.0.2.2:${DEV_PORT}`
48
- let dip = deviceIp(adb, target)
49
- if (!dip) return null
50
- let host = hostIpFor(dip)
51
- return host ? `${host}:${DEV_PORT}` : null
52
- }
53
-
54
- // Serials of connected, authorized devices (excludes offline/unauthorized).
55
- function listDevices(adb: string): string[] {
56
- let listed = Bun.spawnSync([adb, "devices"], { stdout: "pipe", stderr: "pipe" })
57
- return listed.stdout
58
- .toString()
59
- .split("\n")
60
- .slice(1)
61
- .map((l) => l.trim())
62
- .filter(Boolean)
63
- .filter((l) => l.endsWith("\tdevice"))
64
- .map((l) => l.split("\t")[0])
65
- .filter((s): s is string => Boolean(s))
66
- }
67
-
68
- // Primary ABI of the connected device (e.g. "arm64-v8a", "armeabi-v7a"), used
69
- // to pick the matching APK -- a fat (multi-ABI) APK reports its 64-bit ABI
70
- // here and still installs fine, so this only matters for single-ABI builds.
71
- function deviceAbi(adb: string, target: string): string {
72
- let res = Bun.spawnSync([adb, "-s", target, "shell", "getprop", "ro.product.cpu.abi"], {
73
- stdout: "pipe",
74
- stderr: "pipe",
75
- })
76
- return res.stdout.toString().trim()
77
- }
78
-
79
- // Always shown up front, regardless of how many devices are connected or
80
- // whether --device narrows it down: one line per device with the ABI its APK
81
- // build would target, then a dev-dependency hint for each ABI that isn't
82
- // resolvable locally yet. Returns the device->abi map so callers don't have
83
- // to re-query it.
84
- function printDeviceStatus(adb: string, devices: string[]): Map<string, string> {
85
- let abiByDevice = new Map<string, string>()
86
- let missingAbis = new Set<string>()
87
- for (let d of devices) {
88
- let abi = deviceAbi(adb, d)
89
- abiByDevice.set(d, abi)
90
- print(`${d} - ${abi}`)
91
- if (!resolveApk(abi)) missingAbis.add(abi)
92
- }
93
- let missingPkgs = [...missingAbis].map((abi) => ANDROID_PKG_MAP[abi]).filter((pkg): pkg is string => Boolean(pkg))
94
- if (missingPkgs.length > 0) print(`Add dev dependencies with: bun add -d ${missingPkgs.join(" ")}`)
95
- return abiByDevice
96
- }
97
-
98
- // Resolve the target device serial (and its ABI). With --device, treat the
99
- // value as a serial prefix and require it to match exactly one connected
100
- // device; without it, use the sole connected device. Exits with a clear
101
- // message on any ambiguity.
102
- function resolveTarget(adb: string): { target: string; abi: string } {
103
- let devices = listDevices(adb)
104
- let abiByDevice = printDeviceStatus(adb, devices)
105
-
106
- if (values.device) {
107
- let prefix = values.device
108
- let matches = devices.filter((d) => d.startsWith(prefix))
109
- if (matches.length > 1) {
110
- console.error(`--device "${prefix}" is ambiguous; matches: ${matches.join(", ")}`)
111
- process.exit(1)
112
- }
113
- let [match] = matches
114
- if (!match) {
115
- console.error(`No connected device matches --device "${prefix}".`)
116
- process.exit(1)
117
- }
118
- return { target: match, abi: abiByDevice.get(match)! }
119
- }
120
-
121
- if (devices.length > 1) {
122
- console.error("Pick one with --device <serial or prefix>.")
123
- process.exit(1)
124
- }
125
- let [only] = devices
126
- if (!only) {
127
- console.error("No authorized Android device found. Enable USB debugging and check `adb devices`.")
128
- process.exit(1)
129
- }
130
- return { target: only, abi: abiByDevice.get(only)! }
131
- }
132
-
133
- // Install + launch the Android client on a connected device over adb, passing it
134
- // the dev-server address to dial as a launch-intent extra (see devServerAddress).
135
- // Fire-and-forget: the client's lifecycle is tracked via WS connect/disconnect in
136
- // dev-server.ts, not as a child process here.
137
- export async function spawnAndroidClient() {
138
- let adb = requireAdb()
139
-
140
- let { target, abi } = resolveTarget(adb)
141
-
142
- let apk = resolveApk(abi)
143
- if (!apk) {
144
- console.error(`Could not find a SolidRT-Go APK for ABI "${abi}".`)
145
- let pkg = ANDROID_PKG_MAP[abi]
146
- if (pkg) console.error(`Add it with: bun add -d ${pkg}`)
147
- process.exit(1)
148
- }
149
-
150
- print(`[cli] Installing SolidRT-Go on ${target}`)
151
- let install = Bun.spawn([adb, "-s", target, "install", "-r", apk], { stdout: "pipe", stderr: "pipe" })
152
- if ((await install.exited) !== 0) {
153
- console.error("adb install failed:\n" + (await new Response(install.stderr).text()))
154
- process.exit(1)
155
- }
156
-
157
- // Hand the client the dev-server address to dial, as a launch-intent extra that
158
- // MainActivity forwards to native argv (--dev-server); the client auto-connects
159
- // to it. Replaces adb reverse, which never worked over wireless adb.
160
- let devServer = devServerAddress(adb, target)
161
- let launchArgs = [adb, "-s", target, "shell", "am", "start", "-n", PACKAGE_ACTIVITY]
162
- if (devServer) {
163
- print(`[cli] Client will dial dev server at ${devServer}`)
164
- launchArgs.push("--es", "srt_dev_server", devServer)
165
- } else {
166
- print("[cli] Could not resolve a host address for the device; client will need a manual/QR connect")
167
- }
168
-
169
- let start = Bun.spawn(launchArgs, { stdout: "pipe", stderr: "pipe" })
170
- if ((await start.exited) !== 0) {
171
- console.error("adb start failed:\n" + (await new Response(start.stderr).text()))
172
- process.exit(1)
173
- }
174
-
175
- print(`[cli] Launched SolidRT-Go on ${target}; waiting for it to connect to the dev server...`)
176
- }
package/src/dev-client.ts DELETED
@@ -1,29 +0,0 @@
1
- import { state, print, requireBinary, pipeAbovePrompt, shutdown } from "./util"
2
- import { DEV_HOST, DEV_PORT, getClients, shutdownWhenEmpty } from "./dev-server"
3
- import { values, clientStorageArgs } from "./args"
4
-
5
- export function spawnClient() {
6
- let runner = requireBinary("solidrt-go")
7
- // The local client and dev server share this machine, so connect straight to
8
- // the loopback server: no mDNS discovery or recents lookup is needed for `run`.
9
- let args: string[] = ["--dev-server", `${DEV_HOST}:${DEV_PORT}`, ...clientStorageArgs()]
10
- if (values.size) args.push("--size", values.size)
11
- state.child = Bun.spawn([runner, ...args], {
12
- stdio: ["ignore", "pipe", "pipe"],
13
- })
14
-
15
- if (state.child.stdout && typeof state.child.stdout !== "number")
16
- pipeAbovePrompt(state.child.stdout, process.stdout)
17
- if (state.child.stderr && typeof state.child.stderr !== "number")
18
- pipeAbovePrompt(state.child.stderr, process.stderr)
19
-
20
- state.child.exited.then(async () => {
21
- let clients = await getClients().catch(() => [])
22
- if (clients.length === 0) {
23
- shutdown()
24
- }
25
- print(`[cli] Local client exited, ${clients.length} remote client(s) still connected`)
26
- // From here, exit once the last remote client disconnects.
27
- shutdownWhenEmpty()
28
- })
29
- }
package/src/dev-server.ts DELETED
@@ -1,302 +0,0 @@
1
- import { resolve, join } from "path"
2
- import { tmpdir, networkInterfaces } from "node:os"
3
- import { fileURLToPath } from "node:url"
4
- import { mkdirSync, writeFileSync, unlinkSync } from "node:fs"
5
- import { state, print, printErr, requireBinary, pipeAbovePrompt, shutdown } from "./util"
6
- import { appArgs, values, session, DEFAULT_DEV_PORT } from "./args"
7
- import { serverDir } from "./dev-dir"
8
-
9
- export const DEV_HOST = "127.0.0.1"
10
-
11
- // The port every dev-server consumer dials: the spawned server, the local and
12
- // Android clients' --dev-server address, and the MCP bridge's control base.
13
- // Resolved once here, so --port/--session need no threading through those
14
- // call sites. An explicit --port wins over the session; the server folder is
15
- // keyed by the port actually bound either way.
16
- function resolveDevPort(): number {
17
- let raw = values.port
18
- if (raw === undefined) return DEFAULT_DEV_PORT + session
19
- let port = Number(raw)
20
- if (!/^\d+$/.test(raw) || port < 1 || port > 65535) {
21
- console.error(`Invalid --port value "${raw}": expected a port number between 1 and 65535`)
22
- process.exit(1)
23
- }
24
- return port
25
- }
26
-
27
- export let DEV_PORT = resolveDevPort()
28
-
29
- // The dev server itself is a flux script (packages/cli/server/), spawned by
30
- // srt: bundling, file watching, and the repl stay here and drive the server
31
- // process over its loopback-only /__internal__/ routes.
32
-
33
- const INTERNAL_BASE = `http://${DEV_HOST}:${DEV_PORT}/__internal__`
34
-
35
- // Build the reload message for the client protocol. The server latches a
36
- // broadcast reload verbatim for late-joining clients, so srt owns the message
37
- // shape (including the proxy flags) end to end. `manifest` is the bundle's
38
- // version manifest JSON string; when present, clients install the push into
39
- // their version store before applying it (absent for bytecode one-shots and
40
- // the BSOD trigger, which must not be installed).
41
- export function buildReload(payload: { code?: string | null; bytecode?: string; manifest?: string | null }) {
42
- let { manifest, ...rest } = payload
43
- return {
44
- type: "reload",
45
- proxyHttp: values["proxy-http"],
46
- // The session's app arguments ride every push (flux:process argv), so
47
- // remote clients see the same vector as the local one.
48
- args: appArgs,
49
- ...(manifest ? { manifest } : {}),
50
- ...rest,
51
- }
52
- }
53
-
54
- async function post(path: string, body: object) {
55
- let resp = await fetch(`${INTERNAL_BASE}${path}`, { method: "POST", body: JSON.stringify(body) })
56
- if (!resp.ok) throw new Error(`Dev server ${path} failed: ${resp.status}`)
57
- }
58
-
59
- /**
60
- * Send a client-protocol message through the server: to the given client ids,
61
- * or to every client when omitted. `latch` keeps the message for late-joining
62
- * clients (code reloads latch, one-shot bytecode loads do not); `sourceDir`
63
- * moves the server's file-serving root (repl `load`; the project root is
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
- */
69
- export async function sendReload(
70
- message: object,
71
- opts: {
72
- clients?: number[]
73
- latch?: boolean
74
- sourceDir?: string
75
- entry?: string
76
- maps?: Record<string, string> | null
77
- } = {},
78
- ) {
79
- await post("/reload", { message, ...opts })
80
- }
81
-
82
- /** Send a stop to the given client ids, or all. A broadcast stop clears the server's latched reload. */
83
- export async function sendStop(clients?: number[]) {
84
- await post("/stop", clients ? { clients } : {})
85
- }
86
-
87
- /** Latch the stats-overlay flag on the server (for welcome) and broadcast it. */
88
- export async function sendStats(stats: boolean) {
89
- await post("/stats", { stats })
90
- }
91
-
92
- /** Latch the auto-reload flag on the server (repl `watch on|off`). */
93
- export async function sendWatch(enabled: boolean) {
94
- await post("/watch", { enabled })
95
- }
96
-
97
- /**
98
- * Whether the watcher may auto-reload: agents pause it via the MCP watch
99
- * tool, latched on the server. Fails open so an unreachable server surfaces
100
- * as a reload error, not a silently ignored change.
101
- */
102
- export async function watchAllowed(): Promise<boolean> {
103
- try {
104
- let resp = await fetch(`${INTERNAL_BASE}/watch`)
105
- if (!resp.ok) return true
106
- let data = (await resp.json()) as { enabled?: boolean }
107
- return data.enabled !== false
108
- } catch {
109
- return true
110
- }
111
- }
112
-
113
- export type ClientEntry = {
114
- id: number
115
- platform: string
116
- version: string
117
- capabilities: string[]
118
- address: string | null
119
- }
120
-
121
- /** The connected-client list, in connect order. */
122
- export async function getClients(): Promise<ClientEntry[]> {
123
- let resp = await fetch(`${INTERNAL_BASE}/clients`)
124
- if (!resp.ok) throw new Error(`Dev server /clients failed: ${resp.status}`)
125
- return resp.json() as Promise<ClientEntry[]>
126
- }
127
-
128
- // Reload code that fails to start the engine on purpose. The runtime treats a
129
- // startup error like any app that never called render() and falls back to its
130
- // baked-in BSOD screen, so a build that doesn't compile shows the BSOD instead
131
- // of leaving the previous app frozen on screen.
132
- const BSOD_TRIGGER = `throw new Error("SolidRT: build failed")`
133
-
134
- // Called when a bundle fails to compile. Latches the BSOD trigger as the
135
- // current code (so a client connecting after the failure gets it too) and
136
- // pushes it to every connected client.
137
- export async function showBuildFailure() {
138
- state.currentCode = BSOD_TRIGGER
139
- // No manifest: the BSOD trigger is not a version and must never be installed.
140
- state.currentManifest = null
141
- await sendReload(buildReload({ code: BSOD_TRIGGER }), { latch: true })
142
- }
143
-
144
- // The Bun-hosted server used to exit from its ws close handler once the
145
- // spawned local client had exited and the last remote client disconnected.
146
- // The server process cannot see the child, so the policy lives here: called
147
- // after the local client exits, poll the client list and shut down when it
148
- // empties.
149
- export function shutdownWhenEmpty() {
150
- let timer = setInterval(async () => {
151
- let clients = await getClients().catch(() => null)
152
- if (clients && clients.length === 0) {
153
- clearInterval(timer)
154
- print("[cli] All clients disconnected, shutting down")
155
- shutdown()
156
- }
157
- }, 2000)
158
- }
159
-
160
- // Bundle the server script to one plain-JS file the flux binary can run. Bun
161
- // is already the bundler; the browser target keeps node builtins out, and the
162
- // flux: capability modules stay external (the runtime provides them).
163
- async function bundleServer(): Promise<string> {
164
- let entry = fileURLToPath(new URL("../server/main.ts", import.meta.url))
165
- let outfile = resolve(tmpdir(), `srt-dev-server-${process.pid}.js`)
166
- let result = await Bun.build({
167
- entrypoints: [entry],
168
- target: "browser",
169
- format: "esm",
170
- external: ["flux:*"],
171
- })
172
- if (!result.success) {
173
- printErr("[cli] Failed to bundle the dev server:")
174
- for (let log of result.logs) printErr(String(log))
175
- process.exit(1)
176
- }
177
- await Bun.write(outfile, result.outputs[0]!)
178
- return outfile
179
- }
180
-
181
- // The server runs as a separate flux process, so a port clash surfaces there as
182
- // a bare non-zero exit ("Dev server exited unexpectedly (1)"). Claim the port
183
- // here first to turn that into the actual reason, and into the fix.
184
- function requireFreePort(port: number) {
185
- try {
186
- // Default hostname: the server binds every interface, so probe the same way.
187
- let probe = Bun.serve({ port, fetch: () => new Response() })
188
- probe.stop(true)
189
- } catch {
190
- printErr(`[cli] Port ${port} is already in use; start on another session with -s <N> (or --port <P>)`)
191
- process.exit(1)
192
- }
193
- }
194
-
195
- // The server's registry record: written once the server answers, removed at
196
- // exit, so MCP bridges can resolve a project to a port without any per-project
197
- // config (okf/backlog/parallel-dev-servers.md). The pid is the flux server's
198
- // (the process owning the port), so a record left behind by a crash fails the
199
- // bridge's liveness check; the record is a hint either way - the bridge's
200
- // /__control__/clients probe is authoritative.
201
- function writeLiveRecord() {
202
- let record = {
203
- pid: state.serverProc?.pid,
204
- port: DEV_PORT,
205
- projectDir: state.projectDir,
206
- entry: state.source ?? null,
207
- started: new Date().toISOString(),
208
- }
209
- writeFileSync(join(serverDir(DEV_PORT), "live.json"), JSON.stringify(record))
210
- }
211
-
212
- function removeLiveRecord() {
213
- try {
214
- unlinkSync(join(serverDir(DEV_PORT), "live.json"))
215
- } catch {}
216
- }
217
-
218
- export async function startServer() {
219
- let flux = requireBinary("flux")
220
- requireFreePort(DEV_PORT)
221
- let script = await bundleServer()
222
- // The server's own folder (tunnel.key lands there, written by the flux
223
- // process, which does not create directories).
224
- mkdirSync(serverDir(DEV_PORT), { recursive: true })
225
-
226
- let lanAddress = Object.values(networkInterfaces())
227
- .flat()
228
- .find((i) => i?.family === "IPv4" && !i.internal)?.address
229
- let address = lanAddress ?? DEV_HOST
230
- // The address clients can reach us on; also the dev base URL the bundler
231
- // rewrites asset imports against. The server has no OS module, so srt
232
- // computes it and passes it down.
233
- state.serverUrl = `${address}:${DEV_PORT}`
234
-
235
- // How the server rebuilds on an MCP-triggered reload: it cannot call
236
- // Bun.build itself (it is a flux process), so it spawns srt's own bun on the
237
- // standalone bundle-cli entry. Both paths are known here at spawn time.
238
- let bundleCli = fileURLToPath(new URL("./bundle-cli.ts", import.meta.url))
239
-
240
- let config = {
241
- port: DEV_PORT,
242
- sourceDir: state.sourceDir,
243
- projectDir: state.projectDir,
244
- address,
245
- proxyHttp: values["proxy-http"],
246
- entry: state.source,
247
- args: appArgs,
248
- minify: values.minify,
249
- bundlerCmd: [process.execPath, bundleCli],
250
- cache: values["proxy-http"],
251
- cacheDir: resolve(state.projectDir, ".srt-data"),
252
- keyDir: serverDir(DEV_PORT),
253
- capture: state.capture,
254
- stats: state.stats,
255
- tunnel: values.tunnel,
256
- }
257
-
258
- state.serverProc = Bun.spawn([flux, script, JSON.stringify(config)], {
259
- stdio: ["ignore", "pipe", "pipe"],
260
- })
261
- if (state.serverProc.stdout && typeof state.serverProc.stdout !== "number")
262
- pipeAbovePrompt(state.serverProc.stdout, process.stdout)
263
- if (state.serverProc.stderr && typeof state.serverProc.stderr !== "number")
264
- pipeAbovePrompt(state.serverProc.stderr, process.stderr)
265
-
266
- state.serverProc.exited.then((code) => {
267
- if (!state.shuttingDown) {
268
- printErr(`[cli] Dev server exited unexpectedly (${code})`)
269
- process.exit(1)
270
- }
271
- })
272
-
273
- // Wait until the server answers on the internal API before anything else
274
- // (the initial bundle needs the dev base URL, clients need the port bound).
275
- for (let i = 0; ; i++) {
276
- try {
277
- await getClients()
278
- break
279
- } catch {
280
- if (i >= 100) {
281
- printErr("[cli] Dev server did not start")
282
- process.exit(1)
283
- }
284
- await Bun.sleep(100)
285
- }
286
- }
287
-
288
- writeLiveRecord()
289
- // shutdown() exits via process.exit, so the exit hook covers every orderly
290
- // path; only a kill -9 leaves the record behind, for the pid check to catch.
291
- process.on("exit", removeLiveRecord)
292
-
293
- // mDNS advertise (dropped, code kept for future use - see
294
- // okf/backlog/mdns-discovery.md): the p2p ticket is the cross-device connect
295
- // story now. If advertise returns, it belongs next to the server (a flux
296
- // capability), not here.
297
- //
298
- // import { Bonjour } from "bonjour-service" (top of file)
299
- // state.bonjour = new Bonjour()
300
- // state.bonjour.publish({ name: "SolidRT Dev Server", type: "solidrt", protocol: "tcp", port: DEV_PORT })
301
- // print(`[cli] Advertising _solidrt._tcp on port ${DEV_PORT} via mDNS`)
302
- }