@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.
- package/AGENTS.md +68 -38
- package/README.md +86 -15
- package/agents/assets.md +19 -5
- package/agents/debugging.md +173 -17
- package/dist/console.srtapp +73916 -51
- package/dist/demos/3d/assets/icon.svg +23 -0
- package/dist/demos/3d/package.json +9 -0
- package/dist/demos/3d/the-third-dimension/the-third-dimension.srt.js +7528 -0
- package/dist/demos/components/assets/icon.png +0 -0
- package/dist/demos/components/assets/icon.svg +23 -0
- package/dist/demos/components/gallery/gallery.srt.js +14087 -0
- package/dist/demos/components/package.json +9 -0
- package/dist/server.js +3936 -0
- package/package.json +10 -11
- package/src/android/docs.md +21 -0
- package/src/android/main.ts +286 -0
- package/src/{bundler.ts → bundle/bundler.ts} +70 -58
- package/src/bundle/docs.md +12 -0
- package/src/{commands/bundle.ts → bundle/main.ts} +67 -25
- package/src/{untyped-deps.d.ts → bundle/untyped-deps.d.ts} +3 -3
- package/src/check/docs.md +10 -0
- package/src/check/main.ts +85 -0
- package/src/{commands/check.ts → check/typecheck.ts} +11 -41
- package/src/client/docs.md +9 -0
- package/src/client/main.ts +33 -0
- package/src/console/docs.md +14 -0
- package/src/console/main.ts +21 -0
- package/src/demo/docs.md +33 -0
- package/src/demo/main.ts +69 -0
- package/src/init/docs.md +11 -0
- package/src/{commands/init.ts → init/main.ts} +21 -15
- package/src/init/scaffold/AGENTS.md +100 -0
- package/src/init/scaffold/package.json +23 -0
- package/{scaffold → src/init/scaffold}/templates/components/index.tsx +2 -3
- package/{scaffold → src/init/scaffold}/templates/default/index.tsx +2 -3
- package/src/lib/args.ts +194 -0
- package/src/{artifacts.ts → lib/artifacts.ts} +41 -1
- package/src/{dev-dir.ts → lib/dev-dir.ts} +10 -8
- package/src/{fonts.ts → lib/fonts.ts} +12 -26
- package/src/lib/mode.ts +77 -0
- package/src/{project.ts → lib/project.ts} +109 -61
- package/src/lib/registry.ts +120 -0
- package/src/lib/server-bundle.ts +24 -0
- package/src/lib/usage.ts +117 -0
- package/src/lib/util.ts +36 -0
- package/src/main.ts +109 -31
- package/src/mcp/docs.md +22 -0
- package/src/mcp/main.ts +719 -0
- package/src/pack/docs.md +18 -0
- package/src/{pack-folder.ts → pack/layout.ts} +13 -22
- package/src/{commands/pack.ts → pack/main.ts} +28 -17
- package/src/{packer.ts → pack/trailer.ts} +32 -42
- package/src/render/docs.md +19 -0
- package/src/{commands/render.ts → render/main.ts} +17 -15
- package/src/server/args.ts +126 -0
- package/src/server/binaries.ts +47 -0
- package/src/server/config.ts +54 -0
- package/{server → src/server}/control.ts +239 -81
- package/src/server/docs.md +51 -0
- package/src/server/line-editor.ts +200 -0
- package/src/server/main.ts +476 -0
- package/src/server/mode.ts +92 -0
- package/src/server/rebuild.ts +90 -0
- package/src/server/registry.ts +138 -0
- package/src/server/repl.ts +223 -0
- package/src/server/state.ts +54 -0
- package/{server → src/server}/tsconfig.json +1 -1
- package/{server → src/server}/tunnel.ts +6 -6
- package/src/server/watcher.ts +121 -0
- package/src/tool/main.ts +70 -0
- package/src/types/bundle.d.ts +24 -0
- package/src/types/control.d.ts +90 -0
- package/src/types/registry.d.ts +16 -0
- package/scaffold/AGENTS.md +0 -185
- package/scaffold/package.json +0 -22
- package/scaffold/templates/components/icon.tsx +0 -48
- package/scaffold/templates/default/icon.tsx +0 -48
- package/server/main.ts +0 -308
- package/server/rebuild.ts +0 -76
- package/server/state.ts +0 -94
- package/src/args.ts +0 -210
- package/src/bundle-cli.ts +0 -13
- package/src/commands/client.ts +0 -34
- package/src/commands/mcp.ts +0 -617
- package/src/commands/server.ts +0 -73
- package/src/dev-android.ts +0 -176
- package/src/dev-client.ts +0 -29
- package/src/dev-server.ts +0 -302
- package/src/repl.ts +0 -249
- package/src/util.ts +0 -122
- package/src/watcher.ts +0 -73
- /package/src/{prompt.ts → init/prompt.ts} +0 -0
- /package/{scaffold → src/init/scaffold}/gitignore +0 -0
- /package/{scaffold → src/init/scaffold}/icon.svg +0 -0
- /package/{scaffold → src/init/scaffold}/mcp.json +0 -0
- /package/{scaffold → src/init/scaffold}/tsconfig.json +0 -0
- /package/{server → src/server}/cache.ts +0 -0
- /package/{server → src/server}/proxy.ts +0 -0
- /package/{server → src/server}/qr.ts +0 -0
- /package/{server → src/server}/remap.ts +0 -0
package/src/commands/server.ts
DELETED
|
@@ -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
|
-
}
|
package/src/dev-android.ts
DELETED
|
@@ -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
|
-
}
|