@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 +9 -7
- package/scaffold/package.json +5 -5
- package/{src → server}/cache.ts +23 -51
- package/{src → server}/control.ts +30 -17
- 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 +2 -0
- package/src/commands/mcp.ts +31 -1
- package/src/commands/server.ts +13 -18
- package/src/dev-client.ts +10 -21
- package/src/dev-server.ts +141 -301
- 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
|
],
|
|
@@ -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.
|
|
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.
|
|
27
|
-
"@solidrt/linux-x64-gnu": "0.0.
|
|
28
|
-
"@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"
|
|
29
30
|
},
|
|
30
31
|
"peerDependencies": {
|
|
31
|
-
"@solidrt/core": "0.0.
|
|
32
|
-
"typescript": "^
|
|
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
|
}
|
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
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { state } from "./
|
|
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
|
-
|
|
46
|
-
|
|
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 |
|
|
65
|
+
function findClient(param: string | undefined): { ws: ServerWebSocket } | { error: Response } {
|
|
57
66
|
let entries = [...state.clients.entries()]
|
|
58
|
-
if (param ===
|
|
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(
|
|
70
|
-
let target = findClient(
|
|
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,
|
|
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(
|
|
89
|
-
let since = parseInt(
|
|
90
|
-
let wait = Math.min(parseInt(
|
|
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(
|
|
119
|
+
return handleLogs(query)
|
|
112
120
|
case "/__control__/tree":
|
|
113
|
-
return handleQuery(
|
|
121
|
+
return handleQuery(query, "tree")
|
|
114
122
|
case "/__control__/stats":
|
|
115
|
-
return handleQuery(
|
|
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)
|
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
|
+
}
|