@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/cli",
|
|
3
|
-
"version": "0.0.
|
|
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
|
],
|
|
@@ -17,20 +18,22 @@
|
|
|
17
18
|
"@babel/core": "^7.0.0",
|
|
18
19
|
"@babel/plugin-syntax-jsx": "^7.0.0",
|
|
19
20
|
"@babel/preset-typescript": "^7.0.0",
|
|
20
|
-
"
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
22
|
+
"babel-preset-solid": "2.0.0-beta.17",
|
|
21
23
|
"bonjour-service": "^1.4.0",
|
|
22
24
|
"qrcode-generator": "^2.0.4"
|
|
23
25
|
},
|
|
24
26
|
"optionalDependencies": {
|
|
25
|
-
"@solidrt/darwin-arm64": "0.0.
|
|
26
|
-
"@solidrt/linux-x64-gnu": "0.0.
|
|
27
|
-
"@solidrt/win32-x64-msvc": "0.0.
|
|
27
|
+
"@solidrt/darwin-arm64": "0.0.26",
|
|
28
|
+
"@solidrt/linux-x64-gnu": "0.0.26",
|
|
29
|
+
"@solidrt/win32-x64-msvc": "0.0.26"
|
|
28
30
|
},
|
|
29
31
|
"peerDependencies": {
|
|
30
|
-
"@solidrt/core": "0.0.
|
|
31
|
-
"typescript": "^
|
|
32
|
+
"@solidrt/core": "0.0.26",
|
|
33
|
+
"typescript": "^7"
|
|
32
34
|
},
|
|
33
35
|
"devDependencies": {
|
|
36
|
+
"@solidrt/flux-types": "0.0.26",
|
|
34
37
|
"@types/bun": "latest"
|
|
35
38
|
}
|
|
36
39
|
}
|
package/scaffold/AGENTS.md
CHANGED
|
@@ -84,3 +84,19 @@ Authoritative references ship inside the installed packages - read them:
|
|
|
84
84
|
- bunx srt render src/index.tsx --size 480x640 --duration 1 --fps 2 - headless
|
|
85
85
|
render to PNG frames (proves it renders; see the cli AGENTS.md for where the
|
|
86
86
|
frames land)
|
|
87
|
+
|
|
88
|
+
## MCP: inspect the running app
|
|
89
|
+
|
|
90
|
+
The project ships an MCP server (.mcp.json, `srt mcp`) that talks to the dev
|
|
91
|
+
server `bunx srt run` starts. When it is loaded in your environment, prefer
|
|
92
|
+
its tools over guessing at runtime state:
|
|
93
|
+
|
|
94
|
+
- list_clients: connected app clients, their platform and runtime capabilities
|
|
95
|
+
- get_logs: console output and runtime errors (seq cursor; `wait_ms` long-poll
|
|
96
|
+
to catch output right after a reload)
|
|
97
|
+
- get_render_tree: what the app actually rendered - node kinds, text, and
|
|
98
|
+
window-relative boxes
|
|
99
|
+
- get_stats: fps, CPU/memory, frame phase timings, setProperty rate
|
|
100
|
+
|
|
101
|
+
The tools need a running app: if list_clients is empty, ask the user to start
|
|
102
|
+
`bunx srt run src/index.tsx`.
|
package/scaffold/package.json
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
"android": "srt client --android"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@solidrt/core": "0.0.
|
|
13
|
-
"@solidrt/components": "0.0.
|
|
12
|
+
"@solidrt/core": "0.0.26",
|
|
13
|
+
"@solidrt/components": "0.0.26"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@solidrt/cli": "0.0.
|
|
17
|
-
"@solidrt/flux-types": "0.0.
|
|
18
|
-
"typescript": "^
|
|
16
|
+
"@solidrt/cli": "0.0.26",
|
|
17
|
+
"@solidrt/flux-types": "0.0.26",
|
|
18
|
+
"typescript": "^7"
|
|
19
19
|
}
|
|
20
20
|
}
|
package/{src → server}/cache.ts
RENAMED
|
@@ -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 <
|
|
4
|
-
//
|
|
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
|
-
//
|
|
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 "
|
|
11
|
-
import {
|
|
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 =
|
|
32
|
-
d.
|
|
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
|
-
|
|
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))
|
|
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:
|
|
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
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { state } from "./state"
|
|
2
|
+
import type { ServerWebSocket } from "flux:http"
|
|
3
|
+
|
|
4
|
+
// The control API under /__control__/: read-only introspection of connected
|
|
5
|
+
// app clients, served next to the file routes. The MCP bridge (srt mcp) is the
|
|
6
|
+
// primary consumer. Two shapes: server-held data answered directly (clients,
|
|
7
|
+
// logs) and queries forwarded to a client over its websocket and correlated
|
|
8
|
+
// back by id (tree, stats).
|
|
9
|
+
|
|
10
|
+
export type LogEntry = { seq: number; at: number; client: number; level: string; text: string }
|
|
11
|
+
|
|
12
|
+
// Ring buffer of forwarded client logs. Capped so a chatty app cannot grow the
|
|
13
|
+
// server without bound; readers page through it with the `since` cursor.
|
|
14
|
+
const LOG_CAP = 2000
|
|
15
|
+
const QUERY_TIMEOUT_MS = 5000
|
|
16
|
+
const MAX_WAIT_MS = 30000
|
|
17
|
+
|
|
18
|
+
let logs: LogEntry[] = []
|
|
19
|
+
let logSeq = 0
|
|
20
|
+
// Pending long-poll wakeups (see handleLogs). Flushed on every append; a
|
|
21
|
+
// waiter that already timed out resolves again harmlessly.
|
|
22
|
+
let logWaiters: Array<() => void> = []
|
|
23
|
+
|
|
24
|
+
let nextQueryId = 1
|
|
25
|
+
let pendingQueries = new Map<number, (msg: any) => void>()
|
|
26
|
+
|
|
27
|
+
function sleep(ms: number): Promise<void> {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// A `log` message arrived from a client: buffer it and wake long-polls.
|
|
32
|
+
export function appendLog(client: number, level: string, text: string) {
|
|
33
|
+
logs.push({ seq: ++logSeq, at: Date.now(), client, level, text })
|
|
34
|
+
if (logs.length > LOG_CAP) logs.splice(0, logs.length - LOG_CAP)
|
|
35
|
+
let waiters = logWaiters
|
|
36
|
+
logWaiters = []
|
|
37
|
+
for (let wake of waiters) wake()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// A `result` message arrived from a client: hand it to the awaiting query.
|
|
41
|
+
export function resolveQuery(msg: { id?: number }) {
|
|
42
|
+
if (typeof msg.id !== "number") return
|
|
43
|
+
let resolve = pendingQueries.get(msg.id)
|
|
44
|
+
if (resolve) {
|
|
45
|
+
pendingQueries.delete(msg.id)
|
|
46
|
+
resolve(msg)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
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]) => ({
|
|
55
|
+
id: info.id,
|
|
56
|
+
platform: info.platform,
|
|
57
|
+
version: info.version,
|
|
58
|
+
capabilities: info.capabilities,
|
|
59
|
+
...(withAddress ? { address: ws.remoteAddress ?? null } : {}),
|
|
60
|
+
}))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Resolve the target client for a query: an explicit ?client=<id>, or the only
|
|
64
|
+
// connected client when the parameter is omitted.
|
|
65
|
+
function findClient(param: string | undefined): { ws: ServerWebSocket } | { error: Response } {
|
|
66
|
+
let entries = [...state.clients.entries()]
|
|
67
|
+
if (param === undefined) {
|
|
68
|
+
if (entries.length === 1) return { ws: entries[0]![0] }
|
|
69
|
+
if (entries.length === 0) return { error: Response.json({ error: "No connected clients" }, { status: 503 }) }
|
|
70
|
+
return { error: Response.json({ error: "Multiple clients connected; pass ?client=<id>" }, { status: 400 }) }
|
|
71
|
+
}
|
|
72
|
+
let id = parseInt(param, 10)
|
|
73
|
+
let entry = entries.find(([, info]) => info.id === id)
|
|
74
|
+
if (!entry) return { error: Response.json({ error: `No client with id ${param}` }, { status: 404 }) }
|
|
75
|
+
return { ws: entry[0] }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function handleQuery(query: Map<string, string>, kind: string, extra?: Record<string, unknown>): Promise<Response> {
|
|
79
|
+
let target = findClient(query.get("client"))
|
|
80
|
+
if ("error" in target) return target.error
|
|
81
|
+
let id = nextQueryId++
|
|
82
|
+
let reply = new Promise<any>((resolve) => {
|
|
83
|
+
pendingQueries.set(id, resolve)
|
|
84
|
+
})
|
|
85
|
+
target.ws.send(JSON.stringify({ type: "query", kind, id, ...extra }))
|
|
86
|
+
let msg = await Promise.race([reply, sleep(QUERY_TIMEOUT_MS)])
|
|
87
|
+
pendingQueries.delete(id)
|
|
88
|
+
if (!msg) return Response.json({ error: "Query timed out" }, { status: 504 })
|
|
89
|
+
if (msg.error) return Response.json({ error: msg.error }, { status: 502 })
|
|
90
|
+
return Response.json(msg.data)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// GET /__control__/logs?since=N&wait=MS: entries with seq > since, plus the
|
|
94
|
+
// latest seq as the next cursor. With `wait`, holds the response until a new
|
|
95
|
+
// entry arrives or the timeout passes (long-poll), so a caller can follow the
|
|
96
|
+
// stream without tight polling.
|
|
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)
|
|
100
|
+
let entries = logs.filter((e) => e.seq > since)
|
|
101
|
+
if (entries.length === 0 && wait > 0) {
|
|
102
|
+
await new Promise<void>((resolve) => {
|
|
103
|
+
let timer = setTimeout(resolve, wait)
|
|
104
|
+
logWaiters.push(() => {
|
|
105
|
+
clearTimeout(timer)
|
|
106
|
+
resolve()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
entries = logs.filter((e) => e.seq > since)
|
|
110
|
+
}
|
|
111
|
+
return Response.json({ entries, latest: logSeq })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function handleControl(req: Request, path: string, query: Map<string, string>): Promise<Response> {
|
|
115
|
+
switch (path) {
|
|
116
|
+
case "/__control__/clients":
|
|
117
|
+
return Response.json(clientList())
|
|
118
|
+
case "/__control__/logs":
|
|
119
|
+
return handleLogs(query)
|
|
120
|
+
case "/__control__/tree":
|
|
121
|
+
return handleQuery(query, "tree")
|
|
122
|
+
case "/__control__/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
|
+
}
|
|
129
|
+
default:
|
|
130
|
+
return Response.json({ error: "Unknown control endpoint" }, { status: 404 })
|
|
131
|
+
}
|
|
132
|
+
}
|
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)
|