@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/package.json +13 -8
- package/scaffold/AGENTS.md +60 -0
- package/scaffold/package.json +5 -5
- package/{src → server}/cache.ts +23 -51
- package/server/control.ts +187 -0
- package/server/main.ts +301 -0
- package/server/proxy.ts +72 -0
- package/server/qr.ts +35 -0
- package/server/rebuild.ts +54 -0
- package/server/remap.ts +47 -0
- package/server/state.ts +62 -0
- package/server/tsconfig.json +11 -0
- package/server/tunnel.ts +53 -0
- package/src/args.ts +2 -0
- package/src/bundle-cli.ts +12 -0
- package/src/bundler.ts +69 -23
- package/src/commands/bundle.ts +4 -7
- package/src/commands/mcp.ts +151 -43
- package/src/commands/server.ts +16 -20
- package/src/dev-client.ts +10 -21
- package/src/dev-server.ts +153 -300
- package/src/repl.ts +67 -45
- package/src/util.ts +30 -16
- package/src/watcher.ts +6 -8
- package/src/control.ts +0 -119
package/server/main.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// The srt dev server as a flux script. srt (Bun) spawns this with one JSON
|
|
2
|
+
// config argument; bundling, file watching, and the repl stay in srt, which
|
|
3
|
+
// drives this process over the loopback-only /__internal__/ routes. The
|
|
4
|
+
// shutdown-when-empty policy also lives in srt (it polls /__internal__/clients);
|
|
5
|
+
// this process runs until srt kills it. See docs/flux-dev-server-plan.md.
|
|
6
|
+
|
|
7
|
+
import { serve } from "flux:http"
|
|
8
|
+
import type { FluxRequest, Server } from "flux:http"
|
|
9
|
+
import { file, dir } from "flux:fs"
|
|
10
|
+
import { argv } from "flux:process"
|
|
11
|
+
import { resolveWithin, join } from "flux:path"
|
|
12
|
+
import { state, type Config } from "./state"
|
|
13
|
+
import * as cache from "./cache"
|
|
14
|
+
import { handleProxy } from "./proxy"
|
|
15
|
+
import { appendLog, clientList, handleControl, resolveQuery } from "./control"
|
|
16
|
+
import { printQr } from "./qr"
|
|
17
|
+
import { createTunnelEndpoint, TUNNEL_PROTOCOL } from "./tunnel"
|
|
18
|
+
|
|
19
|
+
// argv layout differs between hosts; the config JSON is always the last argument.
|
|
20
|
+
let config: Config = JSON.parse(argv[argv.length - 1]!)
|
|
21
|
+
state.config = config
|
|
22
|
+
state.sourceDir = config.sourceDir
|
|
23
|
+
state.stats = config.stats
|
|
24
|
+
state.serverUrl = `${config.address}:${config.port}`
|
|
25
|
+
|
|
26
|
+
if (config.cache) {
|
|
27
|
+
await cache.initCache({ dir: config.cacheDir })
|
|
28
|
+
console.log("[cli] HTTP cache enabled")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (config.capture) {
|
|
32
|
+
// Start each capture from an empty file: appends would otherwise tack onto
|
|
33
|
+
// whatever a previous run left behind.
|
|
34
|
+
await file(config.capture).write("")
|
|
35
|
+
state.captureStartMs = Date.now()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Split an origin-form request URL ("/path?a=1&b=2") into its decoded path and
|
|
39
|
+
// query parameters. flux has no URL global; this covers what the routes need.
|
|
40
|
+
function splitQuery(url: string): { path: string; query: Map<string, string> } {
|
|
41
|
+
let i = url.indexOf("?")
|
|
42
|
+
let path = i < 0 ? url : url.slice(0, i)
|
|
43
|
+
let query = new Map<string, string>()
|
|
44
|
+
if (i >= 0) {
|
|
45
|
+
for (let pair of url.slice(i + 1).split("&")) {
|
|
46
|
+
if (!pair) continue
|
|
47
|
+
let j = pair.indexOf("=")
|
|
48
|
+
let k = j < 0 ? pair : pair.slice(0, j)
|
|
49
|
+
let v = j < 0 ? "" : pair.slice(j + 1)
|
|
50
|
+
query.set(decodeURIComponent(k), decodeURIComponent(v.replace(/\+/g, " ")))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { path: decodeURIComponent(path), query }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Send `text` to the clients with the given ids, or to every client when
|
|
57
|
+
// `ids` is omitted.
|
|
58
|
+
function sendTo(ids: number[] | undefined, text: string) {
|
|
59
|
+
for (let [ws, info] of state.clients) {
|
|
60
|
+
if (!ids || ids.includes(info.id)) ws.send(text)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The srt -> server IPC under /__internal__/: only the srt process on this
|
|
65
|
+
// machine may drive it, so reject any peer that is not loopback.
|
|
66
|
+
async function handleInternal(req: FluxRequest, server: Server, path: string): Promise<Response> {
|
|
67
|
+
let ip = server.requestIP(req)
|
|
68
|
+
let loopback = ip && (ip.address === "127.0.0.1" || ip.address === "::1" || ip.address === "::ffff:127.0.0.1")
|
|
69
|
+
if (!loopback) return new Response("Forbidden", { status: 403 })
|
|
70
|
+
|
|
71
|
+
if (path === "/__internal__/clients") return Response.json(clientList(true))
|
|
72
|
+
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 })
|
|
73
|
+
|
|
74
|
+
switch (path) {
|
|
75
|
+
case "/__internal__/reload": {
|
|
76
|
+
// { message, clients?, latch?, sourceDir?, map? }: send `message` (a full
|
|
77
|
+
// client-protocol message, built by srt) to the listed client ids, or to
|
|
78
|
+
// all when omitted. `latch` keeps it for late-joining clients (code
|
|
79
|
+
// reloads latch, one-shot bytecode loads do not); `sourceDir` moves the
|
|
80
|
+
// file-serving root (repl `load`); `map` is the bundle's sourcemap for
|
|
81
|
+
// log remapping, replaced on every reload (absent means none).
|
|
82
|
+
let body = await req.json()
|
|
83
|
+
if (typeof body.sourceDir === "string") state.sourceDir = body.sourceDir
|
|
84
|
+
// Keep the rebuild entry in sync when `load` moves it, so a later MCP
|
|
85
|
+
// reload bundles the newly loaded file, not the launch-time one.
|
|
86
|
+
if (typeof body.entry === "string") state.config.entry = body.entry
|
|
87
|
+
state.currentMap = typeof body.map === "string" ? body.map : null
|
|
88
|
+
let text = JSON.stringify(body.message)
|
|
89
|
+
if (body.latch) state.currentReload = text
|
|
90
|
+
sendTo(body.clients, text)
|
|
91
|
+
return new Response("", { status: 204 })
|
|
92
|
+
}
|
|
93
|
+
case "/__internal__/stop": {
|
|
94
|
+
let body = await req.json()
|
|
95
|
+
// A broadcast stop also forgets the latched reload, so a client that
|
|
96
|
+
// connects afterwards starts clean.
|
|
97
|
+
if (!body.clients) {
|
|
98
|
+
state.currentReload = null
|
|
99
|
+
state.currentMap = null
|
|
100
|
+
}
|
|
101
|
+
sendTo(body.clients, JSON.stringify({ type: "stop" }))
|
|
102
|
+
return new Response("", { status: 204 })
|
|
103
|
+
}
|
|
104
|
+
case "/__internal__/stats": {
|
|
105
|
+
let body = await req.json()
|
|
106
|
+
state.stats = !!body.stats
|
|
107
|
+
sendTo(undefined, JSON.stringify({ type: "stats", stats: state.stats }))
|
|
108
|
+
return new Response("", { status: 204 })
|
|
109
|
+
}
|
|
110
|
+
default:
|
|
111
|
+
return Response.json({ error: "Unknown internal endpoint" }, { status: 404 })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The file routes: GET file (with single-range 206 support) or directory
|
|
116
|
+
// listing, PUT file write. All paths are contained in the source directory.
|
|
117
|
+
async function handleFiles(req: FluxRequest, path: string): Promise<Response> {
|
|
118
|
+
let filePath = resolveWithin(state.sourceDir, "." + path)
|
|
119
|
+
if (!filePath) {
|
|
120
|
+
return new Response("Forbidden", { status: 403 })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (req.method === "PUT") {
|
|
124
|
+
console.log("[cli] put " + path)
|
|
125
|
+
let bytes = await req.bytes()
|
|
126
|
+
await file(filePath).write(bytes)
|
|
127
|
+
return new Response("", { status: 204 })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log("[cli] get " + path)
|
|
131
|
+
|
|
132
|
+
let stat
|
|
133
|
+
try {
|
|
134
|
+
stat = await file(filePath).stat()
|
|
135
|
+
} catch {
|
|
136
|
+
console.log(`[cli] file not found ${path}`)
|
|
137
|
+
return new Response("Not found", { status: 404 })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (stat.type === "directory") {
|
|
141
|
+
let dirents = await dir(filePath).entries()
|
|
142
|
+
let entries = await Promise.all(
|
|
143
|
+
dirents.map(async (d) => {
|
|
144
|
+
let entry = { name: d.name, type: d.type === "directory" ? 2 : 1, size: 0, modified: 0 }
|
|
145
|
+
if (d.type !== "directory") {
|
|
146
|
+
try {
|
|
147
|
+
let s = await file(join(filePath, d.name)).stat()
|
|
148
|
+
entry.size = s.size
|
|
149
|
+
entry.modified = Math.floor(s.mtime ?? 0)
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
return entry
|
|
153
|
+
}),
|
|
154
|
+
)
|
|
155
|
+
entries.sort((a, b) => a.name.localeCompare(b.name))
|
|
156
|
+
return Response.json(entries, { headers: { "X-SRT-Type": "directory" } })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let baseHeaders: Record<string, string> = { "X-SRT-Type": "file", "Accept-Ranges": "bytes" }
|
|
160
|
+
|
|
161
|
+
// Honor a single byte-range request (e.g. streaming audio decoding on the
|
|
162
|
+
// client, which seeks and reads on demand). Only the common "bytes=a-b" /
|
|
163
|
+
// "bytes=a-" / "bytes=-n" forms; anything else falls through to the whole
|
|
164
|
+
// file. Range makes proxied streaming viable without pulling the whole
|
|
165
|
+
// track over the wire.
|
|
166
|
+
let range = req.headers.get("range")
|
|
167
|
+
let match = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null
|
|
168
|
+
if (match) {
|
|
169
|
+
let size = stat.size
|
|
170
|
+
let start: number
|
|
171
|
+
let end: number
|
|
172
|
+
if (match[1] === "") {
|
|
173
|
+
// Suffix range: the last N bytes.
|
|
174
|
+
let n = parseInt(match[2]!, 10)
|
|
175
|
+
start = isNaN(n) ? 0 : Math.max(0, size - n)
|
|
176
|
+
end = size - 1
|
|
177
|
+
} else {
|
|
178
|
+
start = parseInt(match[1]!, 10)
|
|
179
|
+
end = match[2] === "" ? size - 1 : Math.min(parseInt(match[2]!, 10), size - 1)
|
|
180
|
+
}
|
|
181
|
+
if (start > end || start >= size) {
|
|
182
|
+
return new Response("Range not satisfiable", {
|
|
183
|
+
status: 416,
|
|
184
|
+
headers: { ...baseHeaders, "Content-Range": `bytes */${size}` },
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
return new Response(await file(filePath).read(start, end - start + 1), {
|
|
188
|
+
status: 206,
|
|
189
|
+
headers: {
|
|
190
|
+
...baseHeaders,
|
|
191
|
+
"Content-Range": `bytes ${start}-${end}/${size}`,
|
|
192
|
+
"Content-Length": String(end - start + 1),
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return new Response(await file(filePath).bytes(), { headers: baseHeaders })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Ticket-paired clients connect through this endpoint; serve() accepts its
|
|
201
|
+
// connections directly alongside the TCP listener.
|
|
202
|
+
let tunnel = config.tunnel ? await createTunnelEndpoint(config.port, config.cacheDir) : null
|
|
203
|
+
|
|
204
|
+
serve({
|
|
205
|
+
port: config.port,
|
|
206
|
+
p2p: tunnel ? { endpoint: tunnel, protocol: TUNNEL_PROTOCOL } : undefined,
|
|
207
|
+
async fetch(req, server) {
|
|
208
|
+
if (server.upgrade(req)) return
|
|
209
|
+
|
|
210
|
+
let { path, query } = splitQuery(req.url)
|
|
211
|
+
|
|
212
|
+
if (path === "/__proxy__") {
|
|
213
|
+
return handleProxy(req)
|
|
214
|
+
}
|
|
215
|
+
if (path.startsWith("/__control__/")) {
|
|
216
|
+
return handleControl(req, path, query)
|
|
217
|
+
}
|
|
218
|
+
if (path.startsWith("/__internal__/")) {
|
|
219
|
+
return handleInternal(req, server, path)
|
|
220
|
+
}
|
|
221
|
+
return handleFiles(req, path)
|
|
222
|
+
},
|
|
223
|
+
websocket: {
|
|
224
|
+
open(ws) {
|
|
225
|
+
let id = state.nextClientId++
|
|
226
|
+
state.clients.set(ws, { platform: "unknown", version: "unknown", profile: "unknown", id, capabilities: [] })
|
|
227
|
+
console.log(`[cli] Client connected ${ws.remoteAddress ?? "unknown"}`)
|
|
228
|
+
// Advertise our real LAN address so clients dialed over a loopback hop
|
|
229
|
+
// can show/remember the directly reachable address (see connection.rs).
|
|
230
|
+
ws.send(
|
|
231
|
+
JSON.stringify({ type: "welcome", address: state.serverUrl, stats: state.stats, capture: !!config.capture }),
|
|
232
|
+
)
|
|
233
|
+
if (state.currentReload) {
|
|
234
|
+
ws.send(state.currentReload)
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
close(ws) {
|
|
238
|
+
let info = state.clients.get(ws)
|
|
239
|
+
state.clients.delete(ws)
|
|
240
|
+
console.log(`[cli] Client disconnected: ${info?.platform ?? "unknown"}`)
|
|
241
|
+
},
|
|
242
|
+
message(ws, msg) {
|
|
243
|
+
try {
|
|
244
|
+
let data = JSON.parse(typeof msg === "string" ? msg : new TextDecoder().decode(msg))
|
|
245
|
+
if (data.type === "info") {
|
|
246
|
+
let existing = state.clients.get(ws)
|
|
247
|
+
state.clients.set(ws, {
|
|
248
|
+
platform: data.platform ?? "unknown",
|
|
249
|
+
version: data.version ?? "unknown",
|
|
250
|
+
profile: data.profile ?? "unknown",
|
|
251
|
+
id: existing?.id ?? state.nextClientId++,
|
|
252
|
+
capabilities: Array.isArray(data.capabilities) ? data.capabilities.map(String) : [],
|
|
253
|
+
})
|
|
254
|
+
console.log(`[cli] Client info ${ws.remoteAddress ?? "unknown"} ${data.platform} (${data.version})`)
|
|
255
|
+
} else if (data.type === "log") {
|
|
256
|
+
// Forwarded console output / runtime errors from the client's
|
|
257
|
+
// engine logger, buffered for the control API (see control.ts).
|
|
258
|
+
// Not printed here: the local client already writes to this
|
|
259
|
+
// terminal, so echoing would duplicate every line.
|
|
260
|
+
let device = state.clients.get(ws)?.id ?? -1
|
|
261
|
+
appendLog(device, String(data.level ?? "log"), String(data.text ?? ""))
|
|
262
|
+
} else if (data.type === "result") {
|
|
263
|
+
// Reply to a query the control API forwarded to this client.
|
|
264
|
+
resolveQuery(data)
|
|
265
|
+
} else if (data.type === "capture" && config.capture) {
|
|
266
|
+
let device = state.clients.get(ws)?.id ?? -1
|
|
267
|
+
// Milliseconds, integer: Date.now() is already integer ms, so the
|
|
268
|
+
// delta needs no rounding.
|
|
269
|
+
let at = Date.now() - state.captureStartMs
|
|
270
|
+
let after = at - state.captureLastAt
|
|
271
|
+
state.captureLastAt = at
|
|
272
|
+
// JSON Lines: one event object per line, streamed to disk as it
|
|
273
|
+
// arrives rather than buffered - no in-memory growth for a long
|
|
274
|
+
// capture, and the file is always complete on disk mid-session.
|
|
275
|
+
// Appends are chained so events land in arrival order.
|
|
276
|
+
let line = JSON.stringify({ after, type: data.kind, key: data.key, device }) + "\n"
|
|
277
|
+
state.captureChain = state.captureChain.then(() => file(config.capture!).append(line))
|
|
278
|
+
}
|
|
279
|
+
} catch {}
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// One QR on screen: with the tunnel on, the ticket QR (printed by
|
|
285
|
+
// createTunnelEndpoint) is the pairing story and the address stays text-only;
|
|
286
|
+
// without it, the address QR is the scan target as before.
|
|
287
|
+
if (!config.tunnel) {
|
|
288
|
+
console.log("")
|
|
289
|
+
printQr(state.serverUrl)
|
|
290
|
+
console.log("")
|
|
291
|
+
}
|
|
292
|
+
console.log(`[cli] WebSocket server on ws://${state.serverUrl}`)
|
|
293
|
+
// mDNS advertise is intentionally not implemented here: the p2p ticket is the
|
|
294
|
+
// cross-device connect story (see docs/flux-dev-server-plan.md).
|
|
295
|
+
|
|
296
|
+
// Keepalive
|
|
297
|
+
setInterval(() => {
|
|
298
|
+
for (let ws of state.clients.keys()) {
|
|
299
|
+
ws.ping()
|
|
300
|
+
}
|
|
301
|
+
}, 5000)
|
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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { command } from "flux:subprocess"
|
|
2
|
+
import { state } from "./state"
|
|
3
|
+
|
|
4
|
+
// Server-owned "rebuild and push": the single place the running app is rebuilt
|
|
5
|
+
// from source on demand (an MCP reload). The srt repl still bundles in-process
|
|
6
|
+
// for its own keystroke reloads, but both routes call the same bundle-cli, so
|
|
7
|
+
// the bundling logic cannot drift. Making the server the rebuild authority is
|
|
8
|
+
// the interim step toward folding the whole CLI into flux (see
|
|
9
|
+
// okf/backlog/cli-flux-migration.md).
|
|
10
|
+
|
|
11
|
+
// Build the reload message the same way srt's buildReload does, so a
|
|
12
|
+
// server-triggered reload is indistinguishable from a repl-triggered one to
|
|
13
|
+
// clients. proxyFiles/proxyHttp are message flags, not build inputs.
|
|
14
|
+
function buildReload(code: string) {
|
|
15
|
+
let config = state.config
|
|
16
|
+
return { type: "reload", proxyFiles: config.proxyFiles, proxyHttp: config.proxyHttp, code }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Rebuild from state.config.entry via the external bundle-cli subprocess, then
|
|
20
|
+
// latch (for late-joining clients) and broadcast the reload to every connected
|
|
21
|
+
// client. Resolves with an error message on failure (no entry configured, or a
|
|
22
|
+
// build error), or null on success.
|
|
23
|
+
export async function rebuildAndBroadcast(): Promise<string | null> {
|
|
24
|
+
let config = state.config
|
|
25
|
+
if (!config.entry) {
|
|
26
|
+
return "No app entry to rebuild. Start srt with a source file (srt run src/index.tsx)."
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let params = JSON.stringify({
|
|
30
|
+
entry: config.entry,
|
|
31
|
+
devBase: state.serverUrl,
|
|
32
|
+
dev: true,
|
|
33
|
+
minify: config.minify,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
let result = await command(config.bundlerCmd[0]!, [...config.bundlerCmd.slice(1), params]).output()
|
|
37
|
+
if (!result.success) {
|
|
38
|
+
let stderr = typeof result.stderr === "string" ? result.stderr : ""
|
|
39
|
+
return `Rebuild failed:\n${stderr.trim()}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// bundle-cli writes one JSON object { code, map } to stdout.
|
|
43
|
+
let bundle: { code?: string; map?: string | null }
|
|
44
|
+
try {
|
|
45
|
+
bundle = JSON.parse(typeof result.stdout === "string" ? result.stdout : "")
|
|
46
|
+
} catch {
|
|
47
|
+
return "Rebuild failed: unreadable bundler output"
|
|
48
|
+
}
|
|
49
|
+
state.currentMap = bundle.map ?? null
|
|
50
|
+
let text = JSON.stringify(buildReload(bundle.code ?? ""))
|
|
51
|
+
state.currentReload = text
|
|
52
|
+
for (let ws of state.clients.keys()) ws.send(text)
|
|
53
|
+
return null
|
|
54
|
+
}
|
package/server/remap.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping"
|
|
2
|
+
|
|
3
|
+
// Stack-trace remapping for forwarded client logs. The runtime evaluates the
|
|
4
|
+
// bundle as module "main", so QuickJS frames cite bundle positions like
|
|
5
|
+
// "at boom (main:212:9)". With the current reload's sourcemap latched on the
|
|
6
|
+
// server (state.currentMap), those positions are rewritten to the original
|
|
7
|
+
// .tsx sources before a log entry is buffered.
|
|
8
|
+
|
|
9
|
+
// The parsed map is cached per map text; a reload swaps the text and the next
|
|
10
|
+
// lookup rebuilds the tracer.
|
|
11
|
+
let cachedText: string | null = null
|
|
12
|
+
let tracer: TraceMap | null = null
|
|
13
|
+
|
|
14
|
+
function tracerFor(map: string | null): TraceMap | null {
|
|
15
|
+
if (map !== cachedText) {
|
|
16
|
+
cachedText = map
|
|
17
|
+
tracer = null
|
|
18
|
+
if (map) {
|
|
19
|
+
try {
|
|
20
|
+
tracer = new TraceMap(JSON.parse(map))
|
|
21
|
+
} catch {
|
|
22
|
+
// A malformed map disables remapping until the next reload.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return tracer
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Rewrite every "main:LINE:COL" (or "main:LINE") position in `text` to its
|
|
31
|
+
* original source position, e.g. "src/app.tsx:42:7". Positions the map has no
|
|
32
|
+
* entry for, and all text when `map` is null, pass through unchanged.
|
|
33
|
+
* QuickJS lines and columns are 1-based; sourcemap columns are 0-based.
|
|
34
|
+
*/
|
|
35
|
+
export function remapPositions(text: string, map: string | null): string {
|
|
36
|
+
if (!map || !text.includes("main:")) return text
|
|
37
|
+
let t = tracerFor(map)
|
|
38
|
+
if (!t) return text
|
|
39
|
+
return text.replace(/\bmain:(\d+)(?::(\d+))?\b/g, (frame, line, column) => {
|
|
40
|
+
let pos = originalPositionFor(t, {
|
|
41
|
+
line: parseInt(line, 10),
|
|
42
|
+
column: column ? Math.max(parseInt(column, 10) - 1, 0) : 0,
|
|
43
|
+
})
|
|
44
|
+
if (pos.source == null || pos.line == null) return frame
|
|
45
|
+
return `${pos.source}:${pos.line}:${(pos.column ?? 0) + 1}`
|
|
46
|
+
})
|
|
47
|
+
}
|
package/server/state.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
/** The app entry (absolute .tsx/.jsx path) the server rebuilds on an
|
|
15
|
+
* MCP-triggered reload, or undefined when srt was started without a source.
|
|
16
|
+
* Moved by the repl `load` command via /__internal__/reload. */
|
|
17
|
+
entry?: string
|
|
18
|
+
/** Minify the rebuild output, mirroring the srt --minify flag. */
|
|
19
|
+
minify: boolean
|
|
20
|
+
/** How the server invokes the external bundler: [bunPath, bundleCliPath],
|
|
21
|
+
* spawned with a JSON params argument appended (see rebuild.ts). */
|
|
22
|
+
bundlerCmd: string[]
|
|
23
|
+
/** Enable the sqlite-backed proxy cache. */
|
|
24
|
+
cache: boolean
|
|
25
|
+
/** Directory holding .srt-cache.db. */
|
|
26
|
+
cacheDir: string
|
|
27
|
+
/** Destination for captured key events, or unset when off. */
|
|
28
|
+
capture?: string
|
|
29
|
+
stats: boolean
|
|
30
|
+
/** Accept ticket-paired clients through the p2p tunnel. */
|
|
31
|
+
tunnel: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ClientInfo = { platform: string; version: string; profile: string; id: number; capabilities: string[] }
|
|
35
|
+
|
|
36
|
+
export let state = {
|
|
37
|
+
config: undefined as unknown as Config,
|
|
38
|
+
clients: new Map<ServerWebSocket, ClientInfo>(),
|
|
39
|
+
nextClientId: 0,
|
|
40
|
+
/**
|
|
41
|
+
* The latched reload message (JSON text), replayed to late-joining clients.
|
|
42
|
+
* Set by /__internal__/reload posts with `latch`, cleared by a broadcast stop.
|
|
43
|
+
*/
|
|
44
|
+
currentReload: null as string | null,
|
|
45
|
+
/**
|
|
46
|
+
* The running bundle's sourcemap (JSON text, bundle -> .tsx sources), used
|
|
47
|
+
* to remap stack traces in forwarded client logs (see control.ts). Replaced
|
|
48
|
+
* on every reload; a reload without a map clears it so frames are never
|
|
49
|
+
* remapped against a stale map.
|
|
50
|
+
*/
|
|
51
|
+
currentMap: null as string | null,
|
|
52
|
+
sourceDir: "",
|
|
53
|
+
serverUrl: "",
|
|
54
|
+
stats: false,
|
|
55
|
+
// Capture events from all connected clients share one clock (captureStartMs,
|
|
56
|
+
// integer milliseconds) so they merge into one coherent timeline, tagged by
|
|
57
|
+
// `device`. Streamed to disk as JSON Lines - see main.ts's "capture" handling.
|
|
58
|
+
captureStartMs: 0,
|
|
59
|
+
captureLastAt: 0, // ms, same clock as captureStartMs
|
|
60
|
+
/** Serializes capture appends so events land on disk in arrival order. */
|
|
61
|
+
captureChain: Promise.resolve(),
|
|
62
|
+
}
|
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" },
|
|
@@ -84,6 +85,7 @@ run/server options:
|
|
|
84
85
|
--proxy-files Route file/dir access through the dev server
|
|
85
86
|
--proxy-http Route fetch calls through the dev server (HTTP cache enabled)
|
|
86
87
|
--capture <file> Record connected clients' key events to a script file
|
|
88
|
+
--tunnel Accept ticket-paired clients through the p2p tunnel
|
|
87
89
|
|
|
88
90
|
run/client options:
|
|
89
91
|
--size <WxH> Window size (default: 1280x720)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Standalone bundler entry, spawned by the dev server (a flux process) as a
|
|
2
|
+
// Bun subprocess to rebuild the app on an MCP-triggered reload. flux cannot call
|
|
3
|
+
// Bun.build, so the server shells out to this. Params arrive as one JSON
|
|
4
|
+
// argument; one JSON object { code, map } goes to stdout and diagnostics to
|
|
5
|
+
// stderr. On a build failure it exits non-zero with an empty stdout.
|
|
6
|
+
|
|
7
|
+
import { bundleWith, type BundleOptions } from "./bundler"
|
|
8
|
+
|
|
9
|
+
let params = JSON.parse(process.argv[2] ?? "{}") as BundleOptions
|
|
10
|
+
let result = await bundleWith(params)
|
|
11
|
+
if (!result) process.exit(1)
|
|
12
|
+
process.stdout.write(JSON.stringify(result))
|