@solidrt/cli 0.0.25 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/cli",
3
- "version": "0.0.25",
3
+ "version": "0.0.26",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "bin/",
12
12
  "src/",
13
+ "server/",
13
14
  "scaffold/",
14
15
  "AGENTS.md"
15
16
  ],
@@ -18,20 +19,21 @@
18
19
  "@babel/plugin-syntax-jsx": "^7.0.0",
19
20
  "@babel/preset-typescript": "^7.0.0",
20
21
  "@modelcontextprotocol/sdk": "^1.29.0",
21
- "babel-preset-solid": "2.0.0-beta.15",
22
+ "babel-preset-solid": "2.0.0-beta.17",
22
23
  "bonjour-service": "^1.4.0",
23
24
  "qrcode-generator": "^2.0.4"
24
25
  },
25
26
  "optionalDependencies": {
26
- "@solidrt/darwin-arm64": "0.0.25",
27
- "@solidrt/linux-x64-gnu": "0.0.25",
28
- "@solidrt/win32-x64-msvc": "0.0.25"
27
+ "@solidrt/darwin-arm64": "0.0.26",
28
+ "@solidrt/linux-x64-gnu": "0.0.26",
29
+ "@solidrt/win32-x64-msvc": "0.0.26"
29
30
  },
30
31
  "peerDependencies": {
31
- "@solidrt/core": "0.0.25",
32
- "typescript": "^6"
32
+ "@solidrt/core": "0.0.26",
33
+ "typescript": "^7"
33
34
  },
34
35
  "devDependencies": {
36
+ "@solidrt/flux-types": "0.0.26",
35
37
  "@types/bun": "latest"
36
38
  }
37
39
  }
@@ -9,12 +9,12 @@
9
9
  "android": "srt client --android"
10
10
  },
11
11
  "dependencies": {
12
- "@solidrt/core": "0.0.25",
13
- "@solidrt/components": "0.0.25"
12
+ "@solidrt/core": "0.0.26",
13
+ "@solidrt/components": "0.0.26"
14
14
  },
15
15
  "devDependencies": {
16
- "@solidrt/cli": "0.0.25",
17
- "@solidrt/flux-types": "0.0.25",
18
- "typescript": "^6"
16
+ "@solidrt/cli": "0.0.26",
17
+ "@solidrt/flux-types": "0.0.26",
18
+ "typescript": "^7"
19
19
  }
20
20
  }
@@ -1,15 +1,16 @@
1
1
  // SQLite-backed HTTP response cache for the dev server's /__proxy__ endpoint.
2
2
  //
3
- // Project-local: stored at <cwd>/.srt-cache.db. Opt-in via the --cache
4
- // flag. Entries live forever; delete .srt-cache.db to drop them.
3
+ // Project-local: stored at <dir>/.srt-cache.db. Entries live forever; delete
4
+ // .srt-cache.db to drop them.
5
5
  //
6
6
  // Cached: GET (and HEAD) 2xx responses with no Authorization on the request
7
7
  // and no Cache-Control: no-store on either side. The cache key is
8
- // sha256(method + "\n" + url); headers are intentionally not part of the key.
8
+ // method + "\n" + url, stored raw; headers are intentionally not part of the
9
+ // key. (The Bun predecessor hashed the key with sha256, which was cosmetic:
10
+ // old hashed entries simply miss and re-populate.)
9
11
 
10
- import { Database } from "bun:sqlite"
11
- import { resolve } from "path"
12
- import { createHash } from "node:crypto"
12
+ import { Database } from "flux:sqlite"
13
+ import { join } from "flux:path"
13
14
 
14
15
  const CACHE_FILE = ".srt-cache.db"
15
16
 
@@ -27,9 +28,9 @@ export type Entry = {
27
28
  let db: Database | null = null
28
29
  let enabled = false
29
30
 
30
- export function initCache(opts: { dir: string }) {
31
- let d = new Database(resolve(opts.dir, CACHE_FILE), { create: true })
32
- d.run(`CREATE TABLE IF NOT EXISTS entries (
31
+ export async function initCache(opts: { dir: string }) {
32
+ let d = await Database.connect(join(opts.dir, CACHE_FILE), "rw+")
33
+ await d.exec(`CREATE TABLE IF NOT EXISTS entries (
33
34
  key TEXT PRIMARY KEY,
34
35
  method TEXT NOT NULL,
35
36
  url TEXT NOT NULL,
@@ -46,28 +47,8 @@ export function isEnabled(): boolean {
46
47
  return enabled
47
48
  }
48
49
 
49
- // Strings returned by bun:sqlite carry an internal representation that
50
- // Headers.set() rejects, even when value-identical to an acceptable string
51
- // (Bun bug oven-sh/bun#28266, present through 1.3.14). Rebuilding each char
52
- // yields a clean string.
53
- function reflatten(s: string): string {
54
- return Array.from(s, (c) => String.fromCharCode(c.charCodeAt(0))).join("")
55
- }
56
-
57
- function reflattenHeaders(obj: Record<string, string>): Record<string, string> {
58
- let out: Record<string, string> = {}
59
- for (let key in obj) {
60
- out[reflatten(key)] = reflatten(obj[key]!)
61
- }
62
- return out
63
- }
64
-
65
50
  function keyFor(method: string, url: string): string {
66
- let h = createHash("sha256")
67
- h.update(method)
68
- h.update("\n")
69
- h.update(url)
70
- return h.digest("hex")
51
+ return method + "\n" + url
71
52
  }
72
53
 
73
54
  function cacheableMethod(method: string): boolean {
@@ -98,32 +79,23 @@ export function isBypass(reqHeaders: Headers): boolean {
98
79
  return false
99
80
  }
100
81
 
101
- export function get(method: string, url: string): Entry | null {
82
+ export async function get(method: string, url: string): Promise<Entry | null> {
102
83
  if (!db || !enabled) return null
103
- let row = db
84
+ let row = await db
104
85
  .query("SELECT method, url, status, headers, body, cached_at FROM entries WHERE key = ?")
105
- .get(keyFor(method, url)) as
106
- | {
107
- method: string
108
- url: string
109
- status: number
110
- headers: string
111
- body: Uint8Array
112
- cached_at: number
113
- }
114
- | null
86
+ .get([keyFor(method, url)])
115
87
  if (!row) return null
116
88
  return {
117
- method: row.method,
118
- url: row.url,
119
- status: row.status,
120
- headers: reflattenHeaders(JSON.parse(row.headers)),
121
- body: row.body,
122
- cachedAt: row.cached_at,
89
+ method: row.method as string,
90
+ url: row.url as string,
91
+ status: row.status as number,
92
+ headers: JSON.parse(row.headers as string),
93
+ body: row.body as Uint8Array,
94
+ cachedAt: row.cached_at as number,
123
95
  }
124
96
  }
125
97
 
126
- export function put(
98
+ export async function put(
127
99
  method: string,
128
100
  url: string,
129
101
  status: number,
@@ -133,10 +105,10 @@ export function put(
133
105
  if (!db || !enabled) return
134
106
  if (status < 200 || status >= 300) return
135
107
  if (hasNoStore(headers["cache-control"] ?? null)) return
136
- db.run(
108
+ await db.run(
137
109
  `INSERT OR REPLACE INTO entries
138
110
  (key, method, url, status, headers, body, cached_at)
139
111
  VALUES (?, ?, ?, ?, ?, ?, ?)`,
140
112
  [keyFor(method, url), method, url, status, JSON.stringify(headers), body, Date.now()],
141
113
  )
142
- }
114
+ }
@@ -1,4 +1,5 @@
1
- import { state } from "./util"
1
+ import { state } from "./state"
2
+ import type { ServerWebSocket } from "flux:http"
2
3
 
3
4
  // The control API under /__control__/: read-only introspection of connected
4
5
  // app clients, served next to the file routes. The MCP bridge (srt mcp) is the
@@ -23,6 +24,10 @@ let logWaiters: Array<() => void> = []
23
24
  let nextQueryId = 1
24
25
  let pendingQueries = new Map<number, (msg: any) => void>()
25
26
 
27
+ function sleep(ms: number): Promise<void> {
28
+ return new Promise((resolve) => setTimeout(resolve, ms))
29
+ }
30
+
26
31
  /// A `log` message arrived from a client: buffer it and wake long-polls.
27
32
  export function appendLog(client: number, level: string, text: string) {
28
33
  logs.push({ seq: ++logSeq, at: Date.now(), client, level, text })
@@ -42,20 +47,24 @@ export function resolveQuery(msg: { id?: number }) {
42
47
  }
43
48
  }
44
49
 
45
- function clientList() {
46
- return [...state.clients.values()].map((info) => ({
50
+ // The connected-client list. `withAddress` adds each socket's peer address for
51
+ // the internal API (the repl `list` display); the public control shape stays
52
+ // without it.
53
+ export function clientList(withAddress = false) {
54
+ return [...state.clients.entries()].map(([ws, info]) => ({
47
55
  id: info.id,
48
56
  platform: info.platform,
49
57
  version: info.version,
50
58
  capabilities: info.capabilities,
59
+ ...(withAddress ? { address: ws.remoteAddress ?? null } : {}),
51
60
  }))
52
61
  }
53
62
 
54
63
  // Resolve the target client for a query: an explicit ?client=<id>, or the only
55
64
  // connected client when the parameter is omitted.
56
- function findClient(param: string | null): { ws: any } | { error: Response } {
65
+ function findClient(param: string | undefined): { ws: ServerWebSocket } | { error: Response } {
57
66
  let entries = [...state.clients.entries()]
58
- if (param === null) {
67
+ if (param === undefined) {
59
68
  if (entries.length === 1) return { ws: entries[0]![0] }
60
69
  if (entries.length === 0) return { error: Response.json({ error: "No connected clients" }, { status: 503 }) }
61
70
  return { error: Response.json({ error: "Multiple clients connected; pass ?client=<id>" }, { status: 400 }) }
@@ -66,15 +75,15 @@ function findClient(param: string | null): { ws: any } | { error: Response } {
66
75
  return { ws: entry[0] }
67
76
  }
68
77
 
69
- async function handleQuery(url: URL, kind: string): Promise<Response> {
70
- let target = findClient(url.searchParams.get("client"))
78
+ async function handleQuery(query: Map<string, string>, kind: string, extra?: Record<string, unknown>): Promise<Response> {
79
+ let target = findClient(query.get("client"))
71
80
  if ("error" in target) return target.error
72
81
  let id = nextQueryId++
73
82
  let reply = new Promise<any>((resolve) => {
74
83
  pendingQueries.set(id, resolve)
75
84
  })
76
- target.ws.send(JSON.stringify({ type: "query", kind, id }))
77
- let msg = await Promise.race([reply, Bun.sleep(QUERY_TIMEOUT_MS)])
85
+ target.ws.send(JSON.stringify({ type: "query", kind, id, ...extra }))
86
+ let msg = await Promise.race([reply, sleep(QUERY_TIMEOUT_MS)])
78
87
  pendingQueries.delete(id)
79
88
  if (!msg) return Response.json({ error: "Query timed out" }, { status: 504 })
80
89
  if (msg.error) return Response.json({ error: msg.error }, { status: 502 })
@@ -85,9 +94,9 @@ async function handleQuery(url: URL, kind: string): Promise<Response> {
85
94
  // latest seq as the next cursor. With `wait`, holds the response until a new
86
95
  // entry arrives or the timeout passes (long-poll), so a caller can follow the
87
96
  // stream without tight polling.
88
- async function handleLogs(url: URL): Promise<Response> {
89
- let since = parseInt(url.searchParams.get("since") ?? "0", 10) || 0
90
- let wait = Math.min(parseInt(url.searchParams.get("wait") ?? "0", 10) || 0, MAX_WAIT_MS)
97
+ async function handleLogs(query: Map<string, string>): Promise<Response> {
98
+ let since = parseInt(query.get("since") ?? "0", 10) || 0
99
+ let wait = Math.min(parseInt(query.get("wait") ?? "0", 10) || 0, MAX_WAIT_MS)
91
100
  let entries = logs.filter((e) => e.seq > since)
92
101
  if (entries.length === 0 && wait > 0) {
93
102
  await new Promise<void>((resolve) => {
@@ -102,17 +111,21 @@ async function handleLogs(url: URL): Promise<Response> {
102
111
  return Response.json({ entries, latest: logSeq })
103
112
  }
104
113
 
105
- export async function handleControl(req: Request, path: string): Promise<Response> {
106
- let url = new URL(req.url)
114
+ export async function handleControl(req: Request, path: string, query: Map<string, string>): Promise<Response> {
107
115
  switch (path) {
108
116
  case "/__control__/clients":
109
117
  return Response.json(clientList())
110
118
  case "/__control__/logs":
111
- return handleLogs(url)
119
+ return handleLogs(query)
112
120
  case "/__control__/tree":
113
- return handleQuery(url, "tree")
121
+ return handleQuery(query, "tree")
114
122
  case "/__control__/stats":
115
- return handleQuery(url, "stats")
123
+ return handleQuery(query, "stats")
124
+ case "/__control__/snapshot": {
125
+ let nodeId = parseInt(query.get("node") ?? "", 10)
126
+ if (!Number.isFinite(nodeId)) return Response.json({ error: "Snapshot requires ?node=<id>" }, { status: 400 })
127
+ return handleQuery(query, "snapshot", { nodeId })
128
+ }
116
129
  default:
117
130
  return Response.json({ error: "Unknown control endpoint" }, { status: 404 })
118
131
  }
package/server/main.ts ADDED
@@ -0,0 +1,292 @@
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? }: 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`).
81
+ let body = await req.json()
82
+ if (typeof body.sourceDir === "string") state.sourceDir = body.sourceDir
83
+ let text = JSON.stringify(body.message)
84
+ if (body.latch) state.currentReload = text
85
+ sendTo(body.clients, text)
86
+ return new Response("", { status: 204 })
87
+ }
88
+ case "/__internal__/stop": {
89
+ let body = await req.json()
90
+ // A broadcast stop also forgets the latched reload, so a client that
91
+ // connects afterwards starts clean.
92
+ if (!body.clients) state.currentReload = null
93
+ sendTo(body.clients, JSON.stringify({ type: "stop" }))
94
+ return new Response("", { status: 204 })
95
+ }
96
+ case "/__internal__/stats": {
97
+ let body = await req.json()
98
+ state.stats = !!body.stats
99
+ sendTo(undefined, JSON.stringify({ type: "stats", stats: state.stats }))
100
+ return new Response("", { status: 204 })
101
+ }
102
+ default:
103
+ return Response.json({ error: "Unknown internal endpoint" }, { status: 404 })
104
+ }
105
+ }
106
+
107
+ // The file routes: GET file (with single-range 206 support) or directory
108
+ // listing, PUT file write. All paths are contained in the source directory.
109
+ async function handleFiles(req: FluxRequest, path: string): Promise<Response> {
110
+ let filePath = resolveWithin(state.sourceDir, "." + path)
111
+ if (!filePath) {
112
+ return new Response("Forbidden", { status: 403 })
113
+ }
114
+
115
+ if (req.method === "PUT") {
116
+ console.log("[cli] put " + path)
117
+ let bytes = await req.bytes()
118
+ await file(filePath).write(bytes)
119
+ return new Response("", { status: 204 })
120
+ }
121
+
122
+ console.log("[cli] get " + path)
123
+
124
+ let stat
125
+ try {
126
+ stat = await file(filePath).stat()
127
+ } catch {
128
+ console.log(`[cli] file not found ${path}`)
129
+ return new Response("Not found", { status: 404 })
130
+ }
131
+
132
+ if (stat.type === "directory") {
133
+ let dirents = await dir(filePath).entries()
134
+ let entries = await Promise.all(
135
+ dirents.map(async (d) => {
136
+ let entry = { name: d.name, type: d.type === "directory" ? 2 : 1, size: 0, modified: 0 }
137
+ if (d.type !== "directory") {
138
+ try {
139
+ let s = await file(join(filePath, d.name)).stat()
140
+ entry.size = s.size
141
+ entry.modified = Math.floor(s.mtime ?? 0)
142
+ } catch {}
143
+ }
144
+ return entry
145
+ }),
146
+ )
147
+ entries.sort((a, b) => a.name.localeCompare(b.name))
148
+ return Response.json(entries, { headers: { "X-SRT-Type": "directory" } })
149
+ }
150
+
151
+ let baseHeaders: Record<string, string> = { "X-SRT-Type": "file", "Accept-Ranges": "bytes" }
152
+
153
+ // Honor a single byte-range request (e.g. streaming audio decoding on the
154
+ // client, which seeks and reads on demand). Only the common "bytes=a-b" /
155
+ // "bytes=a-" / "bytes=-n" forms; anything else falls through to the whole
156
+ // file. Range makes proxied streaming viable without pulling the whole
157
+ // track over the wire.
158
+ let range = req.headers.get("range")
159
+ let match = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null
160
+ if (match) {
161
+ let size = stat.size
162
+ let start: number
163
+ let end: number
164
+ if (match[1] === "") {
165
+ // Suffix range: the last N bytes.
166
+ let n = parseInt(match[2]!, 10)
167
+ start = isNaN(n) ? 0 : Math.max(0, size - n)
168
+ end = size - 1
169
+ } else {
170
+ start = parseInt(match[1]!, 10)
171
+ end = match[2] === "" ? size - 1 : Math.min(parseInt(match[2]!, 10), size - 1)
172
+ }
173
+ if (start > end || start >= size) {
174
+ return new Response("Range not satisfiable", {
175
+ status: 416,
176
+ headers: { ...baseHeaders, "Content-Range": `bytes */${size}` },
177
+ })
178
+ }
179
+ return new Response(await file(filePath).read(start, end - start + 1), {
180
+ status: 206,
181
+ headers: {
182
+ ...baseHeaders,
183
+ "Content-Range": `bytes ${start}-${end}/${size}`,
184
+ "Content-Length": String(end - start + 1),
185
+ },
186
+ })
187
+ }
188
+
189
+ return new Response(await file(filePath).bytes(), { headers: baseHeaders })
190
+ }
191
+
192
+ // Ticket-paired clients connect through this endpoint; serve() accepts its
193
+ // connections directly alongside the TCP listener.
194
+ let tunnel = config.tunnel ? await createTunnelEndpoint(config.port, config.cacheDir) : null
195
+
196
+ serve({
197
+ port: config.port,
198
+ p2p: tunnel ? { endpoint: tunnel, protocol: TUNNEL_PROTOCOL } : undefined,
199
+ async fetch(req, server) {
200
+ if (server.upgrade(req)) return
201
+
202
+ let { path, query } = splitQuery(req.url)
203
+
204
+ if (path === "/__proxy__") {
205
+ return handleProxy(req)
206
+ }
207
+ if (path.startsWith("/__control__/")) {
208
+ return handleControl(req, path, query)
209
+ }
210
+ if (path.startsWith("/__internal__/")) {
211
+ return handleInternal(req, server, path)
212
+ }
213
+ return handleFiles(req, path)
214
+ },
215
+ websocket: {
216
+ open(ws) {
217
+ let id = state.nextClientId++
218
+ state.clients.set(ws, { platform: "unknown", version: "unknown", id, capabilities: [] })
219
+ console.log(`[cli] Client connected ${ws.remoteAddress ?? "unknown"}`)
220
+ // Advertise our real LAN address so clients dialed over a loopback hop
221
+ // can show/remember the directly reachable address (see connection.rs).
222
+ ws.send(
223
+ JSON.stringify({ type: "welcome", address: state.serverUrl, stats: state.stats, capture: !!config.capture }),
224
+ )
225
+ if (state.currentReload) {
226
+ ws.send(state.currentReload)
227
+ }
228
+ },
229
+ close(ws) {
230
+ let info = state.clients.get(ws)
231
+ state.clients.delete(ws)
232
+ console.log(`[cli] Client disconnected: ${info?.platform ?? "unknown"}`)
233
+ },
234
+ message(ws, msg) {
235
+ try {
236
+ let data = JSON.parse(typeof msg === "string" ? msg : new TextDecoder().decode(msg))
237
+ if (data.type === "info") {
238
+ let existing = state.clients.get(ws)
239
+ state.clients.set(ws, {
240
+ platform: data.platform ?? "unknown",
241
+ version: data.version ?? "unknown",
242
+ id: existing?.id ?? state.nextClientId++,
243
+ capabilities: Array.isArray(data.capabilities) ? data.capabilities.map(String) : [],
244
+ })
245
+ console.log(`[cli] Client info ${ws.remoteAddress ?? "unknown"} ${data.platform} (${data.version})`)
246
+ } else if (data.type === "log") {
247
+ // Forwarded console output / runtime errors from the client's
248
+ // engine logger, buffered for the control API (see control.ts).
249
+ // Not printed here: the local client already writes to this
250
+ // terminal, so echoing would duplicate every line.
251
+ let device = state.clients.get(ws)?.id ?? -1
252
+ appendLog(device, String(data.level ?? "log"), String(data.text ?? ""))
253
+ } else if (data.type === "result") {
254
+ // Reply to a query the control API forwarded to this client.
255
+ resolveQuery(data)
256
+ } else if (data.type === "capture" && config.capture) {
257
+ let device = state.clients.get(ws)?.id ?? -1
258
+ // Milliseconds, integer: Date.now() is already integer ms, so the
259
+ // delta needs no rounding.
260
+ let at = Date.now() - state.captureStartMs
261
+ let after = at - state.captureLastAt
262
+ state.captureLastAt = at
263
+ // JSON Lines: one event object per line, streamed to disk as it
264
+ // arrives rather than buffered - no in-memory growth for a long
265
+ // capture, and the file is always complete on disk mid-session.
266
+ // Appends are chained so events land in arrival order.
267
+ let line = JSON.stringify({ after, type: data.kind, key: data.key, device }) + "\n"
268
+ state.captureChain = state.captureChain.then(() => file(config.capture!).append(line))
269
+ }
270
+ } catch {}
271
+ },
272
+ },
273
+ })
274
+
275
+ // One QR on screen: with the tunnel on, the ticket QR (printed by
276
+ // createTunnelEndpoint) is the pairing story and the address stays text-only;
277
+ // without it, the address QR is the scan target as before.
278
+ if (!config.tunnel) {
279
+ console.log("")
280
+ printQr(state.serverUrl)
281
+ console.log("")
282
+ }
283
+ console.log(`[cli] WebSocket server on ws://${state.serverUrl}`)
284
+ // mDNS advertise is intentionally not implemented here: the p2p ticket is the
285
+ // cross-device connect story (see docs/flux-dev-server-plan.md).
286
+
287
+ // Keepalive
288
+ setInterval(() => {
289
+ for (let ws of state.clients.keys()) {
290
+ ws.ping()
291
+ }
292
+ }, 5000)
@@ -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
+ }