@solidrt/cli 0.0.24 → 0.0.26
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/package.json +10 -7
- package/scaffold/AGENTS.md +16 -0
- package/scaffold/mcp.json +8 -0
- package/scaffold/package.json +5 -5
- package/{src → server}/cache.ts +23 -51
- package/server/control.ts +132 -0
- package/server/main.ts +292 -0
- package/server/proxy.ts +72 -0
- package/server/qr.ts +35 -0
- package/server/state.ts +46 -0
- package/server/tsconfig.json +11 -0
- package/server/tunnel.ts +53 -0
- package/src/args.ts +3 -0
- package/src/commands/init.ts +1 -0
- package/src/commands/mcp.ts +158 -0
- package/src/commands/server.ts +13 -18
- package/src/dev-client.ts +10 -21
- package/src/dev-server.ts +141 -246
- package/src/main.ts +3 -0
- package/src/repl.ts +54 -42
- package/src/util.ts +27 -16
- package/src/watcher.ts +3 -6
package/server/proxy.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as cache from "./cache"
|
|
2
|
+
|
|
3
|
+
// The /__proxy__ endpoint: forward a request to the URL in X-SRT-Proxy-Url,
|
|
4
|
+
// buffering the upstream response, with the opt-in sqlite cache in front.
|
|
5
|
+
|
|
6
|
+
function headersToObject(h: Headers): Record<string, string> {
|
|
7
|
+
let out: Record<string, string> = {}
|
|
8
|
+
h.forEach((v, k) => {
|
|
9
|
+
out[k] = v
|
|
10
|
+
})
|
|
11
|
+
return out
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function handleProxy(req: Request): Promise<Response> {
|
|
15
|
+
let target = req.headers.get("x-srt-proxy-url")
|
|
16
|
+
if (!target) {
|
|
17
|
+
return new Response("Missing X-SRT-Proxy-Url", { status: 400 })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let forwardHeaders = new Headers(req.headers)
|
|
21
|
+
forwardHeaders.delete("host")
|
|
22
|
+
forwardHeaders.delete("x-srt-proxy-url")
|
|
23
|
+
forwardHeaders.delete("x-srt-cache")
|
|
24
|
+
forwardHeaders.delete("content-length")
|
|
25
|
+
|
|
26
|
+
let cacheStatus: cache.Decision = "skip"
|
|
27
|
+
let cacheable = !cache.shouldConsider(req.method, req.headers).skip
|
|
28
|
+
let bypass = cacheable && cache.isBypass(req.headers)
|
|
29
|
+
|
|
30
|
+
if (cacheable && !bypass) {
|
|
31
|
+
let hit = await cache.get(req.method, target)
|
|
32
|
+
if (hit) {
|
|
33
|
+
console.log(`[cli] proxy ${req.method} ${target} [cache hit]`)
|
|
34
|
+
let respHeaders = new Headers(hit.headers)
|
|
35
|
+
respHeaders.set("x-srt-cache", "hit")
|
|
36
|
+
return new Response(hit.body, { status: hit.status, headers: respHeaders })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let hasBody = req.method !== "GET" && req.method !== "HEAD"
|
|
41
|
+
if (cacheable) {
|
|
42
|
+
cacheStatus = bypass ? "bypass" : "miss"
|
|
43
|
+
console.log(`[cli] proxy ${req.method} ${target} [${cacheStatus}]`)
|
|
44
|
+
} else {
|
|
45
|
+
console.log(`[cli] proxy ${req.method} ${target}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
let upstream = await fetch(target, {
|
|
50
|
+
method: req.method,
|
|
51
|
+
headers: forwardHeaders,
|
|
52
|
+
body: hasBody ? await req.bytes() : undefined,
|
|
53
|
+
})
|
|
54
|
+
let respHeaders = new Headers(upstream.headers)
|
|
55
|
+
respHeaders.delete("content-encoding")
|
|
56
|
+
respHeaders.delete("transfer-encoding")
|
|
57
|
+
|
|
58
|
+
let bodyBytes = await upstream.bytes()
|
|
59
|
+
if (cacheable) {
|
|
60
|
+
await cache.put(req.method, target, upstream.status, headersToObject(respHeaders), bodyBytes)
|
|
61
|
+
respHeaders.set("x-srt-cache", cacheStatus)
|
|
62
|
+
}
|
|
63
|
+
return new Response(bodyBytes, {
|
|
64
|
+
status: upstream.status,
|
|
65
|
+
statusText: upstream.statusText,
|
|
66
|
+
headers: respHeaders,
|
|
67
|
+
})
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.log(`[cli] proxy error ${target}: ${String(e)}`)
|
|
70
|
+
return new Response(`Proxy error: ${String(e)}`, { status: 502 })
|
|
71
|
+
}
|
|
72
|
+
}
|
package/server/qr.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import qrcode from "qrcode-generator"
|
|
2
|
+
|
|
3
|
+
// Render `text` as a terminal QR code: a white tile with black modules
|
|
4
|
+
// (explicit ANSI colors) plus the quiet zone the QR spec requires. Drawing with
|
|
5
|
+
// the terminal's default foreground inverts the code on dark themes, which
|
|
6
|
+
// standard decoders reject.
|
|
7
|
+
export function printQr(text: string) {
|
|
8
|
+
let qr = qrcode(0, "L")
|
|
9
|
+
qr.addData(text)
|
|
10
|
+
qr.make()
|
|
11
|
+
let modCount = qr.getModuleCount()
|
|
12
|
+
const QR_INK = "\x1b[30;107m" // black modules on bright-white tile (tile = background)
|
|
13
|
+
const QR_TILE_FG = "\x1b[97m" // bright-white as foreground over the default background
|
|
14
|
+
const QR_RESET = "\x1b[0m"
|
|
15
|
+
const QUIET_ZONE = 2 // modules (spec says 4, but scanners cope and it reads tighter)
|
|
16
|
+
let qrWidth = modCount + 2 * QUIET_ZONE
|
|
17
|
+
let dark = (y: number, x: number) => y >= 0 && y < modCount && x >= 0 && x < modCount && qr.isDark(y, x)
|
|
18
|
+
// modCount is always odd, so the tile is a half-line taller than an even row
|
|
19
|
+
// count. The loop packs two module-rows per line via half-blocks and stops on
|
|
20
|
+
// the last content row, leaving the bottom quiet zone half a line short of the
|
|
21
|
+
// full-line top quiet zone.
|
|
22
|
+
for (let y = -QUIET_ZONE; y < modCount + QUIET_ZONE - 1; y += 2) {
|
|
23
|
+
let row = " " + QR_INK
|
|
24
|
+
for (let x = -QUIET_ZONE; x < modCount + QUIET_ZONE; x++) {
|
|
25
|
+
let top = dark(y, x)
|
|
26
|
+
let bot = dark(y + 1, x)
|
|
27
|
+
row += top && bot ? "\u2588" : top ? "\u2580" : bot ? "\u2584" : " "
|
|
28
|
+
}
|
|
29
|
+
console.log(row + QR_RESET)
|
|
30
|
+
}
|
|
31
|
+
// Close that gap with a half-height tile line: upper half painted in the tile
|
|
32
|
+
// color (foreground), lower half the terminal background. The 0.5 here plus
|
|
33
|
+
// the 0.5 already under the last content row equal the full-line top margin.
|
|
34
|
+
console.log(" " + QR_TILE_FG + "\u2580".repeat(qrWidth) + QR_RESET)
|
|
35
|
+
}
|
package/server/state.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Server-side state shared by the route handlers. The srt process keeps the
|
|
2
|
+
// bundler, watcher, and repl; this state is only what the protocol needs.
|
|
3
|
+
|
|
4
|
+
import type { ServerWebSocket } from "flux:http"
|
|
5
|
+
|
|
6
|
+
export type Config = {
|
|
7
|
+
port: number
|
|
8
|
+
/** Directory served by the file routes (updatable via /__internal__/reload). */
|
|
9
|
+
sourceDir: string
|
|
10
|
+
/** The address clients can reach this machine on (LAN IP or 127.0.0.1). */
|
|
11
|
+
address: string
|
|
12
|
+
proxyFiles: boolean
|
|
13
|
+
proxyHttp: boolean
|
|
14
|
+
/** Enable the sqlite-backed proxy cache. */
|
|
15
|
+
cache: boolean
|
|
16
|
+
/** Directory holding .srt-cache.db. */
|
|
17
|
+
cacheDir: string
|
|
18
|
+
/** Destination for captured key events, or unset when off. */
|
|
19
|
+
capture?: string
|
|
20
|
+
stats: boolean
|
|
21
|
+
/** Accept ticket-paired clients through the p2p tunnel. */
|
|
22
|
+
tunnel: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ClientInfo = { platform: string; version: string; id: number; capabilities: string[] }
|
|
26
|
+
|
|
27
|
+
export let state = {
|
|
28
|
+
config: undefined as unknown as Config,
|
|
29
|
+
clients: new Map<ServerWebSocket, ClientInfo>(),
|
|
30
|
+
nextClientId: 0,
|
|
31
|
+
/**
|
|
32
|
+
* The latched reload message (JSON text), replayed to late-joining clients.
|
|
33
|
+
* Set by /__internal__/reload posts with `latch`, cleared by a broadcast stop.
|
|
34
|
+
*/
|
|
35
|
+
currentReload: null as string | null,
|
|
36
|
+
sourceDir: "",
|
|
37
|
+
serverUrl: "",
|
|
38
|
+
stats: false,
|
|
39
|
+
// Capture events from all connected clients share one clock (captureStartMs,
|
|
40
|
+
// integer milliseconds) so they merge into one coherent timeline, tagged by
|
|
41
|
+
// `device`. Streamed to disk as JSON Lines - see main.ts's "capture" handling.
|
|
42
|
+
captureStartMs: 0,
|
|
43
|
+
captureLastAt: 0, // ms, same clock as captureStartMs
|
|
44
|
+
/** Serializes capture appends so events land on disk in arrival order. */
|
|
45
|
+
captureChain: Promise.resolve(),
|
|
46
|
+
}
|
package/server/tunnel.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The p2p tunnel: an iroh endpoint carrying the dev protocol for clients that
|
|
2
|
+
// pair by ticket instead of dialing the TCP port. serve() accepts connections
|
|
3
|
+
// on the endpoint directly (the endpoint/protocol serve options), so the
|
|
4
|
+
// HTTP/WS protocol is spoken straight over each connection's first bi-stream -
|
|
5
|
+
// no pump. Local-only: the endpoint uses no relay and publishes nothing, so
|
|
6
|
+
// the ticket (direct addresses) is the sole carrier of addressing. Off-LAN
|
|
7
|
+
// relay support is a future opt-in flag, not the default.
|
|
8
|
+
|
|
9
|
+
import { Endpoint } from "flux:p2p"
|
|
10
|
+
import { file } from "flux:fs"
|
|
11
|
+
import { join } from "flux:path"
|
|
12
|
+
import { printQr } from "./qr"
|
|
13
|
+
|
|
14
|
+
// The tunnel's ALPN. A protocol change bumps the suffix so old clients fail
|
|
15
|
+
// the handshake instead of desyncing.
|
|
16
|
+
export const TUNNEL_PROTOCOL = "solidrt-dev/0"
|
|
17
|
+
|
|
18
|
+
// The persisted identity file, project-local next to the HTTP cache. Delete it
|
|
19
|
+
// to rotate the tunnel's identity (which invalidates any old ticket).
|
|
20
|
+
const KEY_FILE = ".srt-tunnel-key"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Bind the tunnel endpoint and print its ticket (text + QR). The endpoint is
|
|
24
|
+
* kept stable across restarts so a paired client can re-dial the old ticket
|
|
25
|
+
* without re-scanning: the UDP port is pinned to the dev server's port, and the
|
|
26
|
+
* secret key is persisted in <cacheDir>/.srt-tunnel-key (generated on first
|
|
27
|
+
* run). Both are needed - a moving port or a fresh key each start would change
|
|
28
|
+
* the ticket. Stable across restarts on the same network only; a new machine IP
|
|
29
|
+
* still stales the ticket's addresses (that is the discovery/off-LAN story).
|
|
30
|
+
*/
|
|
31
|
+
export async function createTunnelEndpoint(port: number, cacheDir: string): Promise<Endpoint> {
|
|
32
|
+
let keyPath = join(cacheDir, KEY_FILE)
|
|
33
|
+
|
|
34
|
+
let secretKey: string | undefined
|
|
35
|
+
let keyFile = file(keyPath)
|
|
36
|
+
if (await keyFile.exists()) {
|
|
37
|
+
let saved = (await keyFile.text()).trim()
|
|
38
|
+
if (saved.length === 64) secretKey = saved
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let endpoint = await Endpoint.create({ local: true, protocols: [TUNNEL_PROTOCOL], port, secretKey })
|
|
42
|
+
|
|
43
|
+
// First run (no saved key): persist the freshly generated one so the next run
|
|
44
|
+
// reuses it and the ticket stays the same.
|
|
45
|
+
if (!secretKey) await keyFile.write(endpoint.secretKey)
|
|
46
|
+
|
|
47
|
+
let ticket = await endpoint.ticket()
|
|
48
|
+
console.log("")
|
|
49
|
+
printQr(ticket)
|
|
50
|
+
console.log("")
|
|
51
|
+
console.log(`[cli] Tunnel ticket: ${ticket}`)
|
|
52
|
+
return endpoint
|
|
53
|
+
}
|
package/src/args.ts
CHANGED
|
@@ -15,6 +15,7 @@ export let { values, positionals } = parseArgs({
|
|
|
15
15
|
size: { type: "string" },
|
|
16
16
|
script: { type: "string" },
|
|
17
17
|
capture: { type: "string" },
|
|
18
|
+
tunnel: { type: "boolean", default: false },
|
|
18
19
|
stats: { type: "boolean", default: false },
|
|
19
20
|
android: { type: "boolean", default: false },
|
|
20
21
|
device: { type: "string" },
|
|
@@ -75,6 +76,7 @@ Commands:
|
|
|
75
76
|
bundle <file> Transpile TS/JS/TSX/JSX to JS or bytecode
|
|
76
77
|
render <file.tsx|jsx> Replay a script (optional) and render frames for video generation
|
|
77
78
|
pack <file> Bundle + compile to a standalone executable (experimental)
|
|
79
|
+
mcp MCP server (stdio) exposing the running dev server to coding agents
|
|
78
80
|
|
|
79
81
|
init options:
|
|
80
82
|
-t, --template <name> Start from a named template (skips the interactive picker)
|
|
@@ -83,6 +85,7 @@ run/server options:
|
|
|
83
85
|
--proxy-files Route file/dir access through the dev server
|
|
84
86
|
--proxy-http Route fetch calls through the dev server (HTTP cache enabled)
|
|
85
87
|
--capture <file> Record connected clients' key events to a script file
|
|
88
|
+
--tunnel Accept ticket-paired clients through the p2p tunnel
|
|
86
89
|
|
|
87
90
|
run/client options:
|
|
88
91
|
--size <WxH> Window size (default: 1280x720)
|
package/src/commands/init.ts
CHANGED
|
@@ -16,6 +16,7 @@ const TEMPLATE_FILES: Array<{ from: string; to: string }> = [
|
|
|
16
16
|
{ from: "package.json", to: "package.json" },
|
|
17
17
|
{ from: "tsconfig.json", to: "tsconfig.json" },
|
|
18
18
|
{ from: "gitignore", to: ".gitignore" },
|
|
19
|
+
{ from: "mcp.json", to: ".mcp.json" },
|
|
19
20
|
{ from: "AGENTS.md", to: "AGENTS.md" },
|
|
20
21
|
]
|
|
21
22
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The MCP bridge: a stdio Model Context Protocol server exposing the dev
|
|
2
|
+
// server's control API (/__control__/) as tools for coding agents. Stateless
|
|
3
|
+
// glue: every tool call is one HTTP request to the running dev server, so the
|
|
4
|
+
// bridge works no matter which process (or how many agents) spawned it.
|
|
5
|
+
//
|
|
6
|
+
// stdout is the JSON-RPC channel; nothing here may print to it.
|
|
7
|
+
|
|
8
|
+
import { DEV_PORT } from "../dev-server"
|
|
9
|
+
|
|
10
|
+
const CONTROL_BASE = `http://127.0.0.1:${DEV_PORT}/__control__`
|
|
11
|
+
|
|
12
|
+
type ControlResult = { ok: true; body: any } | { ok: false; message: string }
|
|
13
|
+
|
|
14
|
+
async function control(path: string): Promise<ControlResult> {
|
|
15
|
+
let resp
|
|
16
|
+
try {
|
|
17
|
+
resp = await fetch(CONTROL_BASE + path)
|
|
18
|
+
} catch {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
message: "Dev server not running. Start it in the project first: srt run src/index.tsx (or srt server)",
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
let body: any = null
|
|
25
|
+
try {
|
|
26
|
+
body = await resp.json()
|
|
27
|
+
} catch {}
|
|
28
|
+
if (!resp.ok) return { ok: false, message: String(body?.error ?? `Dev server responded with HTTP ${resp.status}`) }
|
|
29
|
+
return { ok: true, body }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let TOOLS = [
|
|
33
|
+
{
|
|
34
|
+
name: "list_clients",
|
|
35
|
+
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 },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "get_logs",
|
|
41
|
+
description:
|
|
42
|
+
"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
|
+
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,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "get_stats",
|
|
60
|
+
description:
|
|
61
|
+
"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
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "get_render_tree",
|
|
72
|
+
description:
|
|
73
|
+
"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.",
|
|
74
|
+
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,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "get_snapshot",
|
|
84
|
+
description:
|
|
85
|
+
"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.",
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: "object",
|
|
88
|
+
properties: {
|
|
89
|
+
nodeId: { type: "integer", description: "Id of the node to capture, from get_render_tree" },
|
|
90
|
+
client: { type: "integer", description: "Client id from list_clients (default: the only connected client)" },
|
|
91
|
+
},
|
|
92
|
+
required: ["nodeId"],
|
|
93
|
+
additionalProperties: false,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
function clientParam(args: any): string {
|
|
99
|
+
return typeof args?.client === "number" ? `?client=${args.client}` : ""
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function callTool(name: string, args: any): Promise<ControlResult> {
|
|
103
|
+
switch (name) {
|
|
104
|
+
case "list_clients":
|
|
105
|
+
return control("/clients")
|
|
106
|
+
case "get_logs": {
|
|
107
|
+
let params = new URLSearchParams()
|
|
108
|
+
if (typeof args?.since === "number") params.set("since", String(args.since))
|
|
109
|
+
if (typeof args?.wait_ms === "number") params.set("wait", String(args.wait_ms))
|
|
110
|
+
let qs = params.toString()
|
|
111
|
+
return control(qs ? `/logs?${qs}` : "/logs")
|
|
112
|
+
}
|
|
113
|
+
case "get_stats":
|
|
114
|
+
return control(`/stats${clientParam(args)}`)
|
|
115
|
+
case "get_render_tree":
|
|
116
|
+
return control(`/tree${clientParam(args)}`)
|
|
117
|
+
case "get_snapshot": {
|
|
118
|
+
if (typeof args?.nodeId !== "number") return { ok: false, message: "get_snapshot requires a numeric nodeId" }
|
|
119
|
+
let params = new URLSearchParams({ node: String(args.nodeId) })
|
|
120
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
121
|
+
return control(`/snapshot?${params.toString()}`)
|
|
122
|
+
}
|
|
123
|
+
default:
|
|
124
|
+
return { ok: false, message: `Unknown tool: ${name}` }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function runMcpCommand() {
|
|
129
|
+
let { Server } = await import("@modelcontextprotocol/sdk/server/index.js")
|
|
130
|
+
let { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js")
|
|
131
|
+
let { ListToolsRequestSchema, CallToolRequestSchema } = await import("@modelcontextprotocol/sdk/types.js")
|
|
132
|
+
|
|
133
|
+
let server = new Server({ name: "solidrt", version: "0.0.0" }, { capabilities: { tools: {} } })
|
|
134
|
+
|
|
135
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
|
|
136
|
+
|
|
137
|
+
server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
|
|
138
|
+
let name = request.params.name
|
|
139
|
+
let result = await callTool(name, request.params.arguments ?? {})
|
|
140
|
+
if (!result.ok) {
|
|
141
|
+
return { content: [{ type: "text", text: result.message }], isError: true }
|
|
142
|
+
}
|
|
143
|
+
if (name === "get_snapshot") {
|
|
144
|
+
let { pngBase64, width, height } = result.body
|
|
145
|
+
return {
|
|
146
|
+
content: [
|
|
147
|
+
{ type: "image", data: pngBase64, mimeType: "image/png" },
|
|
148
|
+
{ type: "text", text: `Captured node snapshot: ${width}x${height} px` },
|
|
149
|
+
],
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { content: [{ type: "text", text: JSON.stringify(result.body, null, 2) }] }
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// The stdin read keeps the process alive; it exits when the agent host
|
|
156
|
+
// closes the pipe.
|
|
157
|
+
await server.connect(new StdioServerTransport())
|
|
158
|
+
}
|
package/src/commands/server.ts
CHANGED
|
@@ -2,44 +2,39 @@ import pkg from "../../package.json"
|
|
|
2
2
|
import { source, isSource, isPrebuilt, values } from "../args"
|
|
3
3
|
import { state, shutdown } from "../util"
|
|
4
4
|
import { bundle, codeFromOutputs } from "../bundler"
|
|
5
|
-
import { startServer, showBuildFailure } from "../dev-server"
|
|
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 (
|
|
13
|
-
//
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
30
|
state.currentCode = await codeFromOutputs(initialResult.outputs)
|
|
31
|
+
await sendReload(buildReload({ code: state.currentCode }), { latch: true })
|
|
38
32
|
} else {
|
|
39
|
-
showBuildFailure()
|
|
33
|
+
await showBuildFailure()
|
|
40
34
|
}
|
|
41
35
|
} else if (source && isPrebuilt && source.endsWith(".srt.js")) {
|
|
42
36
|
state.currentCode = await Bun.file(resolve(source)).text()
|
|
37
|
+
await sendReload(buildReload({ code: state.currentCode }), { latch: true })
|
|
43
38
|
}
|
|
44
39
|
|
|
45
40
|
process.on("SIGINT", shutdown)
|
|
@@ -49,4 +44,4 @@ export async function runServerCommand() {
|
|
|
49
44
|
console.log(`[cli] Welcome to SolidRT${version}!`)
|
|
50
45
|
startRepl()
|
|
51
46
|
startWatcher()
|
|
52
|
-
}
|
|
47
|
+
}
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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, ${
|
|
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
|
+
}
|