@solidrt/cli 0.0.25 → 0.0.27

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/src/bundler.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { transformAsync } from "@babel/core"
2
2
  import jsx from "@babel/plugin-syntax-jsx"
3
3
  import ts from "@babel/preset-typescript"
4
+ import remapping from "@jridgewell/remapping"
4
5
  import solid from "babel-preset-solid"
5
6
  import { type BunPlugin, type BuildArtifact } from "bun"
6
7
  import { readFileSync } from "node:fs"
@@ -53,7 +54,7 @@ function binaryImport({ types: t }: { types: any }) {
53
54
  // build, skipping emitted asset outputs. Bun's file loader emits binary assets
54
55
  // as extra outputs; the callers that flatten outputs into a single code string
55
56
  // must not glue those raw bytes onto the program.
56
- export async function codeFromOutputs(outputs: BuildArtifact[]): Promise<string> {
57
+ async function codeFromOutputs(outputs: BuildArtifact[]): Promise<string> {
57
58
  let code = ""
58
59
  for (let o of outputs) {
59
60
  if (o.kind === "entry-point" || o.kind === "chunk") code += await o.text()
@@ -62,61 +63,106 @@ export async function codeFromOutputs(outputs: BuildArtifact[]): Promise<string>
62
63
  }
63
64
 
64
65
  // Bun build plugin that runs JSX/TSX through babel-preset-solid (universal
65
- // generate, targeting @solidrt/core) plus the TS preset.
66
- function solidPlugin(): BunPlugin {
66
+ // generate, targeting @solidrt/core) plus the TS preset. Plain .js/.ts app
67
+ // modules take the same path (solid is a no-op without JSX) so binaryImport
68
+ // can rewrite their `with { type: "binary" }` imports too; dependency code
69
+ // (node_modules) skips the babel detour and keeps Bun's native loaders.
70
+ // With `babelMaps`, each file's transform map (original -> babel output) is
71
+ // collected there, keyed by absolute path, for sourcemap composition later.
72
+ function solidPlugin(babelMaps?: Map<string, object>): BunPlugin {
67
73
  return {
68
74
  name: "bun-plugin-solid",
69
75
  setup: (build) => {
70
- build.onLoad({ filter: /\.(js|ts)x$/ }, async (args) => {
76
+ build.onLoad({ filter: /\.(js|ts)x?$/ }, async (args) => {
77
+ if (!/\.(js|ts)x$/.test(args.path) && args.path.includes("node_modules")) return
71
78
  let file = Bun.file(args.path)
72
79
  let code = await file.text()
73
80
  let transforms = await transformAsync(code, {
74
81
  filename: args.path,
82
+ sourceMaps: !!babelMaps,
75
83
  presets: [[solid, { moduleName: "@solidrt/core", generate: "universal" }], [ts]],
76
84
  plugins: [jsx, binaryImport],
77
85
  })
86
+ if (babelMaps && transforms?.map) babelMaps.set(args.path, transforms.map)
78
87
  return { contents: transforms?.code ?? "", loader: "js" }
79
88
  })
80
89
  },
81
90
  }
82
91
  }
83
92
 
84
- export async function bundle(entry = source) {
85
- let result = null
93
+ export type BundleOptions = { entry: string; devBase?: string; dev: boolean; minify: boolean }
86
94
 
87
- let devBase = state.serverUrl ?? undefined
88
- let dev = !!devBase || values.dev
89
- // Keep stdout clean when the bundle itself is written to stdout.
90
- if (!values.stdout) print(`[cli] Bundling (${dev ? "development" : "production"})`)
95
+ export type BundleResult = {
96
+ code: string
97
+ /** Composed sourcemap JSON (bundle -> original .tsx sources), dev builds only. */
98
+ map: string | null
99
+ }
100
+
101
+ // The pure bundle: every input is explicit, so it runs identically in the srt
102
+ // (Bun) process and in the standalone bundle-cli subprocess the dev server
103
+ // spawns. It never touches the ambient args/state singletons and never prints
104
+ // progress (callers own that), so its stdout stays clean for subprocess use.
105
+ export async function bundleWith(opts: BundleOptions): Promise<BundleResult | null> {
91
106
  let define: Record<string, string> = {
92
- "process.env.NODE_ENV": dev ? "development" : "production",
107
+ "process.env.NODE_ENV": opts.dev ? "development" : "production",
93
108
  }
94
- if (devBase) define.__SRT_DEV_BASE__ = devBase
109
+ if (opts.devBase) define.__SRT_DEV_BASE__ = opts.devBase
95
110
 
111
+ let babelMaps = opts.dev ? new Map<string, object>() : undefined
112
+ let result = null
96
113
  try {
97
114
  result = await Bun.build({
98
- entrypoints: [entry!],
115
+ entrypoints: [opts.entry],
99
116
  target: "browser",
100
117
  format: "esm",
101
- minify: values.minify,
118
+ minify: opts.minify,
102
119
  external: ["flux:*", "srt:*"],
103
120
  define,
104
121
  loader: { ".svg": "text" },
105
- plugins: [solidPlugin()],
122
+ sourcemap: opts.dev ? "external" : "none",
123
+ plugins: [solidPlugin(babelMaps)],
106
124
  })
107
125
  } catch (e) {
108
126
  console.error("[cli] compile error:\n", e)
109
127
  return null
110
128
  }
111
129
 
112
- if (result?.success) {
113
- return result
130
+ if (!result.success) {
131
+ for (let msg of result.logs) console.error(msg)
132
+ return null
114
133
  }
115
134
 
116
- if (result) {
117
- for (let msg of result?.logs) console.error(msg)
118
- }
119
- return null
135
+ return { code: await codeFromOutputs(result.outputs), map: await composeMap(result.outputs, babelMaps) }
136
+ }
137
+
138
+ // Compose Bun's bundle map (babel output -> bundle) with the per-file Babel
139
+ // maps (original source -> babel output) so positions point at the .tsx
140
+ // sources. Bun ignores sourcemaps in plugin onLoad contents, so this second
141
+ // hop has to happen here. Only a single-artifact build gets a map: code
142
+ // splitting is off, and concatenated artifacts would invalidate offsets.
143
+ async function composeMap(outputs: BuildArtifact[], babelMaps?: Map<string, object>): Promise<string | null> {
144
+ if (!babelMaps) return null
145
+ let js = outputs.filter((o) => o.kind === "entry-point" || o.kind === "chunk")
146
+ if (js.length !== 1 || !js[0]!.sourcemap) return null
147
+ let bunMap = JSON.parse(await js[0]!.sourcemap.text())
148
+ let composed = remapping(bunMap, (file: string) => {
149
+ // Bun writes cwd-relative source paths; the babel maps are keyed by the
150
+ // absolute path. Serve each map exactly once: remapping asks again for a
151
+ // served map's own original source, and that lookup must return null.
152
+ let abs = resolvePath(file)
153
+ let map = babelMaps.get(abs)
154
+ babelMaps.delete(abs)
155
+ return (map as any) ?? null
156
+ })
157
+ return composed.toString()
158
+ }
159
+
160
+ export async function bundle(entry = source) {
161
+ let devBase = state.serverUrl ?? undefined
162
+ let dev = !!devBase || values.dev
163
+ // Keep stdout clean when the bundle itself is written to stdout.
164
+ if (!values.stdout) print(`[cli] Bundling (${dev ? "development" : "production"})`)
165
+ return bundleWith({ entry: entry!, devBase, dev, minify: values.minify })
120
166
  }
121
167
 
122
168
  export async function bundleTo(outfile: string) {
@@ -125,7 +171,7 @@ export async function bundleTo(outfile: string) {
125
171
  console.error("Build failed")
126
172
  process.exit(1)
127
173
  }
128
- await Bun.write(outfile, await codeFromOutputs(result.outputs))
174
+ await Bun.write(outfile, result.code)
129
175
  return result
130
176
  }
131
177
 
@@ -153,7 +199,7 @@ export async function bundleSolid(): Promise<string> {
153
199
  console.error("Build failed")
154
200
  process.exit(1)
155
201
  }
156
- return codeFromOutputs(result.outputs)
202
+ return result.code
157
203
  }
158
204
 
159
205
  // Compile JS source to QuickJS bytecode via the fluxc binary.
@@ -1,5 +1,5 @@
1
1
  import { values, source, isPrebuilt } from "../args"
2
- import { bundle, bundleTo, bundleFlux, compileToBytecode, codeFromOutputs } from "../bundler"
2
+ import { bundle, bundleTo, bundleFlux, compileToBytecode } from "../bundler"
3
3
  import { resolve } from "path"
4
4
 
5
5
  // Write to stdout and resolve only once the whole payload is flushed.
@@ -55,7 +55,7 @@ export async function runBundleCommand() {
55
55
  console.error("Build failed")
56
56
  process.exit(1)
57
57
  }
58
- await writeStdout(await codeFromOutputs(result.outputs))
58
+ await writeStdout(result.code)
59
59
  process.exit()
60
60
  }
61
61
 
@@ -65,15 +65,12 @@ export async function runBundleCommand() {
65
65
  console.error("Build failed")
66
66
  process.exit(1)
67
67
  }
68
- let jsCode = await codeFromOutputs(result.outputs)
69
- await writeBytecode(jsCode, baseName + ".srt.bin")
68
+ await writeBytecode(result.code, baseName + ".srt.bin")
70
69
  process.exit()
71
70
  }
72
71
 
73
72
  let jsOutfile = baseName + ".srt.js"
74
73
  let result = await bundleTo(jsOutfile)
75
- for (let output of result.outputs) {
76
- console.log(`>> wrote ${output.size} bytes to ${jsOutfile}`)
77
- }
74
+ console.log(`>> wrote ${result.code.length} bytes to ${jsOutfile}`)
78
75
  process.exit()
79
76
  }
@@ -5,16 +5,25 @@
5
5
  //
6
6
  // stdout is the JSON-RPC channel; nothing here may print to it.
7
7
 
8
+ import { z } from "zod"
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
8
12
  import { DEV_PORT } from "../dev-server"
9
13
 
10
14
  const CONTROL_BASE = `http://127.0.0.1:${DEV_PORT}/__control__`
11
15
 
12
16
  type ControlResult = { ok: true; body: any } | { ok: false; message: string }
13
17
 
14
- async function control(path: string): Promise<ControlResult> {
18
+ async function control(path: string, method: "GET" | "POST" = "GET", payload?: unknown): Promise<ControlResult> {
15
19
  let resp
16
20
  try {
17
- resp = await fetch(CONTROL_BASE + path)
21
+ let init: RequestInit = { method }
22
+ if (payload !== undefined) {
23
+ init.headers = { "content-type": "application/json" }
24
+ init.body = JSON.stringify(payload)
25
+ }
26
+ resp = await fetch(CONTROL_BASE + path, init)
18
27
  } catch {
19
28
  return {
20
29
  ok: false,
@@ -29,56 +38,110 @@ async function control(path: string): Promise<ControlResult> {
29
38
  return { ok: true, body }
30
39
  }
31
40
 
32
- let TOOLS = [
41
+ let CLIENT_ARG = z
42
+ .number()
43
+ .int()
44
+ .describe("Client id from list_clients (default: the only connected client)")
45
+ .optional()
46
+
47
+ let TOOLS: { name: string; description: string; inputSchema: Record<string, z.ZodTypeAny> }[] = [
33
48
  {
34
49
  name: "list_clients",
35
50
  description:
36
- "List the app clients connected to the SolidRT dev server. Each entry has id (pass it as `client` to the other tools), platform, runtime version, and the capability names compiled into that client's runtime.",
37
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
51
+ "List the app clients connected to the SolidRT dev server. Each entry has id (pass it as `client` to the other tools), platform, runtime version (git describe; a -dirty suffix means the binary was built from uncommitted engine changes), build profile (debug/release), and the capability names compiled into that client's runtime. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
52
+ inputSchema: {},
38
53
  },
39
54
  {
40
55
  name: "get_logs",
41
56
  description:
42
57
  "Read console output and runtime errors from connected app clients. Returns entries (seq, at, client, level, text) plus `latest`, the newest seq. Pass `since` (a seq or `latest` from a previous call) to only get newer entries; pass `wait_ms` to hold the call until new output arrives, e.g. right after triggering a reload.",
43
58
  inputSchema: {
44
- type: "object",
45
- properties: {
46
- since: {
47
- type: "integer",
48
- description: "Only return entries with seq greater than this (default 0: the whole buffer)",
49
- },
50
- wait_ms: {
51
- type: "integer",
52
- description: "If nothing is newer than `since`, wait up to this many milliseconds for new output (max 30000)",
53
- },
54
- },
55
- additionalProperties: false,
59
+ since: z
60
+ .number()
61
+ .int()
62
+ .describe("Only return entries with seq greater than this (default 0: the whole buffer)")
63
+ .optional(),
64
+ wait_ms: z
65
+ .number()
66
+ .int()
67
+ .describe("If nothing is newer than `since`, wait up to this many milliseconds for new output (max 30000)")
68
+ .optional(),
56
69
  },
57
70
  },
58
71
  {
59
72
  name: "get_stats",
60
73
  description:
61
74
  "Performance statistics from a running app client: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count.",
62
- inputSchema: {
63
- type: "object",
64
- properties: {
65
- client: { type: "integer", description: "Client id from list_clients (default: the only connected client)" },
66
- },
67
- additionalProperties: false,
68
- },
75
+ inputSchema: { client: CLIENT_ARG },
69
76
  },
70
77
  {
71
78
  name: "get_render_tree",
72
79
  description:
73
80
  "Snapshot of a running app client's render tree: node id, kind, window-relative box (x, y, width, height), text content, and children. Use it to verify what the app actually rendered and where.",
81
+ inputSchema: { client: CLIENT_ARG },
82
+ },
83
+ {
84
+ name: "get_snapshot",
85
+ description:
86
+ "Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. The node must be currently mounted and have a non-zero layout box.",
87
+ inputSchema: {
88
+ nodeId: z.number().int().describe("Id of the node to capture, from get_render_tree"),
89
+ client: CLIENT_ARG,
90
+ },
91
+ },
92
+ {
93
+ name: "get_gpu_resources",
94
+ description:
95
+ "Inventory of a running app client's GPU resources: textures (id, size, whether a shader renders into it), vertex buffers (id, byteLength), and shader/pipeline targets (output textureId, kind, bufferId, topology, drawCount, depth, attribute layout, bound sampler texture ids, last-applied uniform values). Use it when the render tree is just a <texture> leaf and the interesting state lives behind it; follow up with get_texture or get_buffer to see contents.",
96
+ inputSchema: { client: CLIENT_ARG },
97
+ },
98
+ {
99
+ name: "get_texture",
100
+ description:
101
+ "Read back any GPU texture from a running app client as a PNG, by texture id (from get_gpu_resources, or the id returned by createImage/createShader/createPipeline in app code). Works on sampled textures (atlases, data textures) and shader/pipeline render targets alike, without needing a frame. Pass x/y/width/height to crop, e.g. one tile of an atlas.",
102
+ inputSchema: {
103
+ id: z.number().int().describe("Texture id, from get_gpu_resources"),
104
+ x: z.number().int().describe("Crop rect left edge in texture pixels (requires y, width, height)").optional(),
105
+ y: z.number().int().describe("Crop rect top edge in texture pixels").optional(),
106
+ width: z.number().int().describe("Crop rect width in texture pixels").optional(),
107
+ height: z.number().int().describe("Crop rect height in texture pixels").optional(),
108
+ client: CLIENT_ARG,
109
+ },
110
+ },
111
+ {
112
+ name: "get_buffer",
113
+ description:
114
+ "Read back part of a GPU vertex buffer from a running app client, decoded to numbers. Returns values plus byteOffset/byteLength actually read and bufferByteLength. Reads are capped at 64 KiB per call; page through larger buffers with offset. Use it to verify geometry after a writeBuffer, e.g. the dynamic sprite tail of a vertex buffer.",
115
+ inputSchema: {
116
+ id: z.number().int().describe("Buffer id, from get_gpu_resources"),
117
+ offset: z.number().int().describe("Byte offset to start reading at (default 0)").optional(),
118
+ length: z.number().int().describe("Number of values to read (default: the rest of the buffer, capped)").optional(),
119
+ as: z.enum(["f32", "u16", "u8"]).describe("How to decode the bytes (default f32)").optional(),
120
+ client: CLIENT_ARG,
121
+ },
122
+ },
123
+ {
124
+ name: "list_debug",
125
+ description:
126
+ "List the debug commands the running app registered via registerDebug from srt:dev. Returns the command names; call one with call_debug. Empty when the app registered none.",
127
+ inputSchema: { client: CLIENT_ARG },
128
+ },
129
+ {
130
+ name: "call_debug",
131
+ description:
132
+ "Call a debug command the running app registered via registerDebug from srt:dev, by name (from list_debug). `args` is passed to the command's function as its single argument; the command's return value comes back JSON-serialized (undefined as null). Commands run synchronously on the app's JS thread - use them to query app state (positions, counters, internal flags) or trigger app behavior (toggle a mode, open a door) without touching its real input handling.",
74
133
  inputSchema: {
75
- type: "object",
76
- properties: {
77
- client: { type: "integer", description: "Client id from list_clients (default: the only connected client)" },
78
- },
79
- additionalProperties: false,
134
+ name: z.string().describe("Debug command name, from list_debug"),
135
+ args: z.record(z.string(), z.any()).describe("Argument object passed to the command (default: none)").optional(),
136
+ client: CLIENT_ARG,
80
137
  },
81
138
  },
139
+ {
140
+ name: "reload",
141
+ description:
142
+ "Rebuild the app from source and push it to every connected client. Call this after editing the app's .tsx/.jsx source to apply the changes: it bundles once and reloads all clients, so a burst of edits becomes a single explicit reload. Returns the number of clients reloaded, or a build error if the source failed to compile. Follow with get_logs to see runtime output from the reloaded app.",
143
+ inputSchema: {},
144
+ },
82
145
  ]
83
146
 
84
147
  function clientParam(args: any): string {
@@ -100,27 +163,72 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
100
163
  return control(`/stats${clientParam(args)}`)
101
164
  case "get_render_tree":
102
165
  return control(`/tree${clientParam(args)}`)
166
+ case "reload":
167
+ return control("/reload", "POST")
168
+ case "get_snapshot": {
169
+ if (typeof args?.nodeId !== "number") return { ok: false, message: "get_snapshot requires a numeric nodeId" }
170
+ let params = new URLSearchParams({ node: String(args.nodeId) })
171
+ if (typeof args?.client === "number") params.set("client", String(args.client))
172
+ return control(`/snapshot?${params.toString()}`)
173
+ }
174
+ case "get_gpu_resources":
175
+ return control(`/gpu${clientParam(args)}`)
176
+ case "list_debug":
177
+ return control(`/debug${clientParam(args)}`)
178
+ case "call_debug": {
179
+ if (typeof args?.name !== "string") return { ok: false, message: "call_debug requires a command name" }
180
+ let params = new URLSearchParams({ name: args.name })
181
+ if (typeof args?.client === "number") params.set("client", String(args.client))
182
+ return control(`/debug?${params.toString()}`, "POST", args?.args)
183
+ }
184
+ case "get_texture": {
185
+ if (typeof args?.id !== "number") return { ok: false, message: "get_texture requires a numeric id" }
186
+ let params = new URLSearchParams({ id: String(args.id) })
187
+ for (let key of ["x", "y", "width", "height"]) {
188
+ if (typeof args?.[key] === "number") params.set(key, String(args[key]))
189
+ }
190
+ if (typeof args?.client === "number") params.set("client", String(args.client))
191
+ return control(`/texture?${params.toString()}`)
192
+ }
193
+ case "get_buffer": {
194
+ if (typeof args?.id !== "number") return { ok: false, message: "get_buffer requires a numeric id" }
195
+ let params = new URLSearchParams({ id: String(args.id) })
196
+ if (typeof args?.offset === "number") params.set("offset", String(args.offset))
197
+ if (typeof args?.length === "number") params.set("length", String(args.length))
198
+ if (typeof args?.as === "string") params.set("as", args.as)
199
+ if (typeof args?.client === "number") params.set("client", String(args.client))
200
+ return control(`/buffer?${params.toString()}`)
201
+ }
103
202
  default:
104
203
  return { ok: false, message: `Unknown tool: ${name}` }
105
204
  }
106
205
  }
107
206
 
108
- export async function runMcpCommand() {
109
- let { Server } = await import("@modelcontextprotocol/sdk/server/index.js")
110
- let { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js")
111
- let { ListToolsRequestSchema, CallToolRequestSchema } = await import("@modelcontextprotocol/sdk/types.js")
112
-
113
- let server = new Server({ name: "solidrt", version: "0.0.0" }, { capabilities: { tools: {} } })
207
+ function toContent(name: string, result: ControlResult): CallToolResult {
208
+ if (!result.ok) return { content: [{ type: "text", text: result.message }], isError: true }
209
+ if (name === "get_snapshot" || name === "get_texture") {
210
+ let { pngBase64, width, height } = result.body
211
+ let label = name === "get_snapshot" ? "Captured node snapshot" : "Texture contents"
212
+ return {
213
+ content: [
214
+ { type: "image", data: pngBase64, mimeType: "image/png" },
215
+ { type: "text", text: `${label}: ${width}x${height} px` },
216
+ ],
217
+ }
218
+ }
219
+ return { content: [{ type: "text", text: JSON.stringify(result.body, null, 2) }] }
220
+ }
114
221
 
115
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
222
+ export async function runMcpCommand() {
223
+ let server = new McpServer({ name: "solidrt", version: "0.0.0" })
116
224
 
117
- server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
118
- let result = await callTool(request.params.name, request.params.arguments ?? {})
119
- if (!result.ok) {
120
- return { content: [{ type: "text", text: result.message }], isError: true }
121
- }
122
- return { content: [{ type: "text", text: JSON.stringify(result.body, null, 2) }] }
123
- })
225
+ for (let tool of TOOLS) {
226
+ server.registerTool(
227
+ tool.name,
228
+ { description: tool.description, inputSchema: tool.inputSchema },
229
+ async (args: any) => toContent(tool.name, await callTool(tool.name, args ?? {})),
230
+ )
231
+ }
124
232
 
125
233
  // The stdin read keeps the process alive; it exits when the agent host
126
234
  // closes the pipe.
@@ -1,45 +1,41 @@
1
1
  import pkg from "../../package.json"
2
2
  import { source, isSource, isPrebuilt, values } from "../args"
3
3
  import { state, shutdown } from "../util"
4
- import { bundle, codeFromOutputs } from "../bundler"
5
- import { startServer, showBuildFailure } from "../dev-server"
4
+ import { bundle } from "../bundler"
5
+ import { startServer, buildReload, sendReload, showBuildFailure } from "../dev-server"
6
6
  import { startRepl } from "../repl"
7
7
  import { startWatcher } from "../watcher"
8
- import * as cache from "../cache"
9
8
  import { resolve, dirname } from "path"
10
- import { writeFileSync } from "node:fs"
11
9
 
12
- // Brings up the dev server (HTTP/WS + initial bundle + repl + watcher). The
13
- // `run` command spawns a local client on top of this from main.ts.
10
+ // Brings up the dev server (a spawned flux script serving HTTP/WS) plus the
11
+ // initial bundle, repl, and watcher in this process. The `run` command spawns
12
+ // a local client on top of this from main.ts.
14
13
  export async function runServerCommand() {
15
14
  // Initialize state from args
16
15
  state.source = source
17
16
  state.sourceDir = source ? dirname(resolve(source)) : process.cwd()
18
17
  state.stats = values.stats
19
18
  state.capture = values.capture ? resolve(values.capture) : undefined
20
- state.captureStartMs = Date.now()
21
- // Start each capture from an empty file: appendFileSync (dev-server.ts)
22
- // would otherwise tack onto whatever a previous run left behind.
23
- if (state.capture) writeFileSync(state.capture, "")
24
19
 
25
- if (values["proxy-http"]) {
26
- cache.initCache({ dir: process.cwd() })
27
- console.log("[cli] HTTP cache enabled")
28
- }
29
-
30
- startServer()
20
+ // Spawns the server process and waits until it answers; it owns the QR and
21
+ // address announcements, the capture file, and the proxy cache.
22
+ await startServer()
31
23
 
32
24
  // Bundle initial code if source file given (after server start so the
33
- // dev base URL is available to the bundler).
25
+ // dev base URL is available to the bundler), and latch it on the server
26
+ // for the clients about to connect.
34
27
  if (source && isSource) {
35
28
  let initialResult = await bundle()
36
29
  if (initialResult) {
37
- state.currentCode = await codeFromOutputs(initialResult.outputs)
30
+ state.currentCode = initialResult.code
31
+ state.currentMap = initialResult.map
32
+ await sendReload(buildReload({ code: state.currentCode }), { latch: true, map: state.currentMap })
38
33
  } else {
39
- showBuildFailure()
34
+ await showBuildFailure()
40
35
  }
41
36
  } else if (source && isPrebuilt && source.endsWith(".srt.js")) {
42
37
  state.currentCode = await Bun.file(resolve(source)).text()
38
+ await sendReload(buildReload({ code: state.currentCode }), { latch: true })
43
39
  }
44
40
 
45
41
  process.on("SIGINT", shutdown)
@@ -49,4 +45,4 @@ export async function runServerCommand() {
49
45
  console.log(`[cli] Welcome to SolidRT${version}!`)
50
46
  startRepl()
51
47
  startWatcher()
52
- }
48
+ }
package/src/dev-client.ts CHANGED
@@ -1,20 +1,7 @@
1
- import { state, print, requireBinary } from "./util"
2
- import { DEV_HOST, DEV_PORT } from "./dev-server"
1
+ import { state, print, requireBinary, pipeAbovePrompt, shutdown } from "./util"
2
+ import { DEV_HOST, DEV_PORT, getClients, shutdownWhenEmpty } from "./dev-server"
3
3
  import { values } from "./args"
4
4
 
5
- function pipeAbovePrompt(stream: ReadableStream<Uint8Array>, out: NodeJS.WriteStream) {
6
- let reader = stream.getReader()
7
- ;(async () => {
8
- while (true) {
9
- let { done, value } = await reader.read()
10
- if (done || !value) break
11
- process.stdout.write("\r\x1b[K")
12
- out.write(value)
13
- state.rl?.prompt(true)
14
- }
15
- })()
16
- }
17
-
18
5
  export function spawnClient() {
19
6
  let runner = requireBinary("solidrt-go")
20
7
  // The local client and dev server share this machine, so connect straight to
@@ -30,11 +17,13 @@ export function spawnClient() {
30
17
  if (state.child.stderr && typeof state.child.stderr !== "number")
31
18
  pipeAbovePrompt(state.child.stderr, process.stderr)
32
19
 
33
- state.child.exited.then(() => {
34
- if (state.clients.size === 0) {
35
- state.server?.stop()
36
- process.exit(0)
20
+ state.child.exited.then(async () => {
21
+ let clients = await getClients().catch(() => [])
22
+ if (clients.length === 0) {
23
+ shutdown()
37
24
  }
38
- print(`[cli] Local client exited, ${state.clients.size} remote client(s) still connected`)
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()
39
28
  })
40
- }
29
+ }