@solidrt/cli 0.0.51 → 0.0.52
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/AGENTS.md +68 -38
- package/README.md +86 -15
- package/agents/assets.md +19 -5
- package/agents/debugging.md +169 -17
- package/dist/console.srtapp +94123 -73
- package/dist/server.js +3936 -0
- package/package.json +10 -11
- package/src/android/docs.md +21 -0
- package/src/android/main.ts +286 -0
- package/src/{bundler.ts → bundle/bundler.ts} +67 -55
- package/src/bundle/docs.md +12 -0
- package/src/{commands/bundle.ts → bundle/main.ts} +67 -25
- package/src/check/docs.md +10 -0
- package/src/check/main.ts +85 -0
- package/src/{commands/check.ts → check/typecheck.ts} +11 -41
- package/src/client/docs.md +9 -0
- package/src/client/main.ts +33 -0
- package/src/console/docs.md +14 -0
- package/src/console/main.ts +21 -0
- package/src/demo/docs.md +27 -0
- package/src/demo/main.ts +67 -0
- package/src/init/docs.md +11 -0
- package/src/{commands/init.ts → init/main.ts} +21 -15
- package/src/init/scaffold/AGENTS.md +98 -0
- package/src/init/scaffold/package.json +23 -0
- package/{scaffold → src/init/scaffold}/templates/components/index.tsx +2 -3
- package/{scaffold → src/init/scaffold}/templates/default/index.tsx +2 -3
- package/src/lib/args.ts +194 -0
- package/src/{artifacts.ts → lib/artifacts.ts} +41 -1
- package/src/{dev-dir.ts → lib/dev-dir.ts} +10 -8
- package/src/{fonts.ts → lib/fonts.ts} +12 -26
- package/src/lib/mode.ts +77 -0
- package/src/{project.ts → lib/project.ts} +109 -61
- package/src/lib/registry.ts +120 -0
- package/src/lib/server-bundle.ts +24 -0
- package/src/lib/usage.ts +117 -0
- package/src/lib/util.ts +36 -0
- package/src/main.ts +109 -31
- package/src/mcp/docs.md +22 -0
- package/src/mcp/main.ts +719 -0
- package/src/pack/docs.md +18 -0
- package/src/{pack-folder.ts → pack/layout.ts} +13 -22
- package/src/{commands/pack.ts → pack/main.ts} +28 -17
- package/src/{packer.ts → pack/trailer.ts} +32 -42
- package/src/render/docs.md +19 -0
- package/src/{commands/render.ts → render/main.ts} +17 -15
- package/src/server/args.ts +126 -0
- package/src/server/binaries.ts +47 -0
- package/src/server/config.ts +54 -0
- package/{server → src/server}/control.ts +239 -81
- package/src/server/docs.md +51 -0
- package/src/server/line-editor.ts +200 -0
- package/src/server/main.ts +473 -0
- package/src/server/mode.ts +92 -0
- package/src/server/rebuild.ts +90 -0
- package/src/server/registry.ts +138 -0
- package/src/server/repl.ts +223 -0
- package/src/server/state.ts +54 -0
- package/{server → src/server}/tsconfig.json +1 -1
- package/{server → src/server}/tunnel.ts +6 -6
- package/src/server/watcher.ts +121 -0
- package/src/tool/main.ts +70 -0
- package/src/types/bundle.d.ts +24 -0
- package/src/types/control.d.ts +90 -0
- package/src/types/registry.d.ts +16 -0
- package/scaffold/AGENTS.md +0 -185
- package/scaffold/package.json +0 -22
- package/scaffold/templates/components/icon.tsx +0 -48
- package/scaffold/templates/default/icon.tsx +0 -48
- package/server/main.ts +0 -308
- package/server/rebuild.ts +0 -76
- package/server/state.ts +0 -94
- package/src/args.ts +0 -210
- package/src/bundle-cli.ts +0 -13
- package/src/commands/client.ts +0 -34
- package/src/commands/mcp.ts +0 -617
- package/src/commands/server.ts +0 -73
- package/src/dev-android.ts +0 -176
- package/src/dev-client.ts +0 -29
- package/src/dev-server.ts +0 -302
- package/src/repl.ts +0 -249
- package/src/util.ts +0 -122
- package/src/watcher.ts +0 -73
- /package/src/{untyped-deps.d.ts → bundle/untyped-deps.d.ts} +0 -0
- /package/src/{prompt.ts → init/prompt.ts} +0 -0
- /package/{scaffold → src/init/scaffold}/gitignore +0 -0
- /package/{scaffold → src/init/scaffold}/icon.svg +0 -0
- /package/{scaffold → src/init/scaffold}/mcp.json +0 -0
- /package/{scaffold → src/init/scaffold}/tsconfig.json +0 -0
- /package/{server → src/server}/cache.ts +0 -0
- /package/{server → src/server}/proxy.ts +0 -0
- /package/{server → src/server}/qr.ts +0 -0
- /package/{server → src/server}/remap.ts +0 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { on, setRawMode, write } from "flux:tty"
|
|
2
|
+
import type { Key } from "flux:tty"
|
|
3
|
+
|
|
4
|
+
// A line editor over flux:tty raw mode: the terminal echoes nothing, every
|
|
5
|
+
// key arrives as an event, and this redraws the prompt line itself. Cursor
|
|
6
|
+
// movement, history (this session) and Tab completion; Ctrl-C, or Ctrl-D on
|
|
7
|
+
// an empty line, quits. While a submitted line runs, keys still edit the
|
|
8
|
+
// buffer, and the prompt comes back when it is done. Log lines written by
|
|
9
|
+
// console.* while the editor is up are placed above the prompt line: the
|
|
10
|
+
// line is cleared, the message printed, the prompt redrawn.
|
|
11
|
+
|
|
12
|
+
export type Completion = {
|
|
13
|
+
/** The candidates for the trailing part of the line. */
|
|
14
|
+
matches: string[]
|
|
15
|
+
/** The trailing part of the line a candidate replaces. */
|
|
16
|
+
replace: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type EditorOptions = {
|
|
20
|
+
prompt: string
|
|
21
|
+
/** A submitted line; the prompt returns once it settles. */
|
|
22
|
+
onLine: (line: string) => Promise<void> | void
|
|
23
|
+
onQuit: () => void
|
|
24
|
+
complete?: (line: string) => Promise<Completion>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Clear the line and return to column 0, then the prompt and the buffer; the
|
|
28
|
+
// cursor ends after the buffer, so move it back for a cursor inside it.
|
|
29
|
+
const CLEAR_LINE = "\r\x1b[K"
|
|
30
|
+
|
|
31
|
+
function commonPrefix(items: string[]): string {
|
|
32
|
+
let prefix = items[0] ?? ""
|
|
33
|
+
for (let item of items) {
|
|
34
|
+
let i = 0
|
|
35
|
+
while (i < prefix.length && i < item.length && prefix[i] === item[i]) i++
|
|
36
|
+
prefix = prefix.slice(0, i)
|
|
37
|
+
}
|
|
38
|
+
return prefix
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Start the editor; returns the function that stops it (restores console,
|
|
42
|
+
// ends the line so the shell prompt starts fresh, leaves raw mode).
|
|
43
|
+
export function startLineEditor(opts: EditorOptions): () => void {
|
|
44
|
+
let buffer = ""
|
|
45
|
+
let cursor = 0
|
|
46
|
+
let history: string[] = []
|
|
47
|
+
let historyAt = -1
|
|
48
|
+
let draft = ""
|
|
49
|
+
let busy = false
|
|
50
|
+
let stopped = false
|
|
51
|
+
|
|
52
|
+
let redraw = () => {
|
|
53
|
+
if (stopped || busy) return
|
|
54
|
+
let back = buffer.length - cursor
|
|
55
|
+
write(CLEAR_LINE + opts.prompt + buffer + (back > 0 ? `\x1b[${back}D` : ""))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let submit = () => {
|
|
59
|
+
let line = buffer
|
|
60
|
+
write("\r\n")
|
|
61
|
+
if (line.trim() && history[history.length - 1] !== line) history.push(line)
|
|
62
|
+
historyAt = -1
|
|
63
|
+
buffer = ""
|
|
64
|
+
cursor = 0
|
|
65
|
+
busy = true
|
|
66
|
+
Promise.resolve()
|
|
67
|
+
.then(() => opts.onLine(line))
|
|
68
|
+
.catch((e) => console.error(`[cli] ${String(e)}`))
|
|
69
|
+
.then(() => {
|
|
70
|
+
busy = false
|
|
71
|
+
redraw()
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let recall = (at: number) => {
|
|
76
|
+
if (historyAt === -1) draft = buffer
|
|
77
|
+
historyAt = at
|
|
78
|
+
buffer = at === -1 ? draft : history[at]!
|
|
79
|
+
cursor = buffer.length
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let complete = async () => {
|
|
83
|
+
if (!opts.complete) return
|
|
84
|
+
let head = buffer.slice(0, cursor)
|
|
85
|
+
let asked = buffer
|
|
86
|
+
let { matches, replace } = await opts.complete(head)
|
|
87
|
+
// Typed on (or submitted) while the candidates were being looked up:
|
|
88
|
+
// they answer a line that no longer exists.
|
|
89
|
+
if (buffer !== asked || cursor !== head.length) return
|
|
90
|
+
if (matches.length === 0) return
|
|
91
|
+
let insert = matches.length === 1 ? matches[0]! : commonPrefix(matches)
|
|
92
|
+
if (insert.length > replace.length) {
|
|
93
|
+
buffer = head.slice(0, head.length - replace.length) + insert + buffer.slice(cursor)
|
|
94
|
+
cursor += insert.length - replace.length
|
|
95
|
+
} else if (matches.length > 1) {
|
|
96
|
+
write("\r\n" + matches.join(" ") + "\r\n")
|
|
97
|
+
}
|
|
98
|
+
redraw()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let onKey = (key: Key) => {
|
|
102
|
+
if (key.ctrl) {
|
|
103
|
+
switch (key.name) {
|
|
104
|
+
case "c":
|
|
105
|
+
opts.onQuit()
|
|
106
|
+
return
|
|
107
|
+
case "d":
|
|
108
|
+
if (buffer.length === 0) opts.onQuit()
|
|
109
|
+
else if (cursor < buffer.length) buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1)
|
|
110
|
+
break
|
|
111
|
+
case "a":
|
|
112
|
+
cursor = 0
|
|
113
|
+
break
|
|
114
|
+
case "e":
|
|
115
|
+
cursor = buffer.length
|
|
116
|
+
break
|
|
117
|
+
case "u":
|
|
118
|
+
buffer = buffer.slice(cursor)
|
|
119
|
+
cursor = 0
|
|
120
|
+
break
|
|
121
|
+
default:
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
redraw()
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (key.meta) return
|
|
128
|
+
switch (key.name) {
|
|
129
|
+
case "return":
|
|
130
|
+
submit()
|
|
131
|
+
return
|
|
132
|
+
case "backspace":
|
|
133
|
+
if (cursor > 0) {
|
|
134
|
+
buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor)
|
|
135
|
+
cursor--
|
|
136
|
+
}
|
|
137
|
+
break
|
|
138
|
+
case "delete":
|
|
139
|
+
if (cursor < buffer.length) buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1)
|
|
140
|
+
break
|
|
141
|
+
case "left":
|
|
142
|
+
if (cursor > 0) cursor--
|
|
143
|
+
break
|
|
144
|
+
case "right":
|
|
145
|
+
if (cursor < buffer.length) cursor++
|
|
146
|
+
break
|
|
147
|
+
case "home":
|
|
148
|
+
cursor = 0
|
|
149
|
+
break
|
|
150
|
+
case "end":
|
|
151
|
+
cursor = buffer.length
|
|
152
|
+
break
|
|
153
|
+
case "up":
|
|
154
|
+
if (history.length && historyAt !== 0) recall(historyAt === -1 ? history.length - 1 : historyAt - 1)
|
|
155
|
+
break
|
|
156
|
+
case "down":
|
|
157
|
+
if (historyAt !== -1) recall(historyAt === history.length - 1 ? -1 : historyAt + 1)
|
|
158
|
+
break
|
|
159
|
+
case "tab":
|
|
160
|
+
complete()
|
|
161
|
+
return
|
|
162
|
+
default:
|
|
163
|
+
if (key.char === undefined) return
|
|
164
|
+
buffer = buffer.slice(0, cursor) + key.char + buffer.slice(cursor)
|
|
165
|
+
cursor += key.char.length
|
|
166
|
+
}
|
|
167
|
+
redraw()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
setRawMode(true)
|
|
171
|
+
let offKey = on("key", onKey)
|
|
172
|
+
let offClose = on("close", opts.onQuit)
|
|
173
|
+
|
|
174
|
+
// console.* output lands above the prompt line: the runtime writes the
|
|
175
|
+
// message with "\r\n" line breaks in raw mode, this side clears the prompt
|
|
176
|
+
// first and redraws it after.
|
|
177
|
+
let original = { log: console.log, warn: console.warn, error: console.error, debug: console.debug }
|
|
178
|
+
let wrap =
|
|
179
|
+
(print: (...args: unknown[]) => void) =>
|
|
180
|
+
(...args: unknown[]) => {
|
|
181
|
+
if (!busy) write(CLEAR_LINE)
|
|
182
|
+
print(...args)
|
|
183
|
+
redraw()
|
|
184
|
+
}
|
|
185
|
+
console.log = wrap(original.log)
|
|
186
|
+
console.warn = wrap(original.warn)
|
|
187
|
+
console.error = wrap(original.error)
|
|
188
|
+
console.debug = wrap(original.debug)
|
|
189
|
+
|
|
190
|
+
redraw()
|
|
191
|
+
return () => {
|
|
192
|
+
if (stopped) return
|
|
193
|
+
stopped = true
|
|
194
|
+
Object.assign(console, original)
|
|
195
|
+
offKey()
|
|
196
|
+
offClose()
|
|
197
|
+
write("\r\n")
|
|
198
|
+
setRawMode(false)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// The srt dev server as a flux script, complete on its own: started as
|
|
2
|
+
// `flux server.js [flags]` from a project root (or with a file), by `srt run`
|
|
3
|
+
// / `srt server` (bun launchers that only resolve the binaries and spawn it)
|
|
4
|
+
// or by the console. It resolves its mode (mode.ts), owns everything with a
|
|
5
|
+
// lifetime - the port (bound here: remembered, given, or the first free one),
|
|
6
|
+
// the registry record, the local client, the latched bundle, the control API -
|
|
7
|
+
// and spawns the two things only bun can do, the bundle (`srt bundle --json`,
|
|
8
|
+
// rebuild.ts) and the startup typecheck (`srt check`), by command name
|
|
9
|
+
// (okf/done/srt-command-folders.md).
|
|
10
|
+
|
|
11
|
+
import { serve } from "flux:http"
|
|
12
|
+
import type { FluxRequest, Server, ServerWebSocket } from "flux:http"
|
|
13
|
+
import { file, realpath } from "flux:fs"
|
|
14
|
+
import { on as onSignal } from "flux:process"
|
|
15
|
+
import { join, resolveWithin } from "flux:path"
|
|
16
|
+
import { command } from "flux:subprocess"
|
|
17
|
+
import { interfaces, probe } from "flux:net"
|
|
18
|
+
import type { Child } from "flux:subprocess"
|
|
19
|
+
import { state } from "./state"
|
|
20
|
+
import { fail, parseArgs } from "./args"
|
|
21
|
+
import type { ServerConfig } from "./config"
|
|
22
|
+
import { absolute, resolveMode, sourceDirOf } from "./mode"
|
|
23
|
+
import { requireBinary, srtCommand } from "./binaries"
|
|
24
|
+
import * as cache from "./cache"
|
|
25
|
+
import { handleProxy } from "./proxy"
|
|
26
|
+
import { appendLog, handleControl, resolveQuery } from "./control"
|
|
27
|
+
import { printQr } from "./qr"
|
|
28
|
+
import { createTunnelEndpoint, TUNNEL_PROTOCOL } from "./tunnel"
|
|
29
|
+
import { rebuildAndBroadcast, showBuildFailure } from "./rebuild"
|
|
30
|
+
import { stopWatcher } from "./watcher"
|
|
31
|
+
import { startRepl } from "./repl"
|
|
32
|
+
import { devDir, pruneDeadRecords, rememberedPort, removeRecord, runningFor, serverDirFor, writeRecord } from "./registry"
|
|
33
|
+
|
|
34
|
+
let args = parseArgs()
|
|
35
|
+
let mode = await resolveMode(args)
|
|
36
|
+
|
|
37
|
+
// Records left behind by crashed servers are fossils; clear them first.
|
|
38
|
+
await pruneDeadRecords()
|
|
39
|
+
|
|
40
|
+
// One server per key: a second run in the same project (or on the same
|
|
41
|
+
// file) points at the running one instead of racing it.
|
|
42
|
+
let running = await runningFor(mode.key)
|
|
43
|
+
if (running) {
|
|
44
|
+
fail(`A dev server already serves ${mode.key} on port ${running.port} (pid ${running.pid}). Stop it first, or attach a client with srt client.`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let srt = srtCommand()
|
|
48
|
+
let runner = args.client !== null ? await requireBinary("solidrt-go") : null
|
|
49
|
+
let serverDir = await serverDirFor(mode.key)
|
|
50
|
+
|
|
51
|
+
// The LAN address (for --lan): the IPv4 of the interface holding the default
|
|
52
|
+
// route, which is the one other hosts reach; VPN and bridge interfaces (wg0,
|
|
53
|
+
// docker0) are up too, so first-up would announce them at random. Without a
|
|
54
|
+
// default route, the first up, non-loopback IPv4.
|
|
55
|
+
let lanAddress = args.lan ? lanAddressOf(interfaces()) : undefined
|
|
56
|
+
function lanAddressOf(ifaces: ReturnType<typeof interfaces>): string | undefined {
|
|
57
|
+
let v4 = (list: typeof ifaces) => list.flatMap((i) => i.addrs).find((a) => a.family === "v4")?.ip
|
|
58
|
+
let reachable = ifaces.filter((i) => i.up && !i.loopback)
|
|
59
|
+
return v4(reachable.filter((i) => i.default)) ?? v4(reachable)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Storage flags for the local client: dev client trees live under
|
|
63
|
+
// ~/.solidrt/clients/client<N>/ (an explicit --data-root wins), passed
|
|
64
|
+
// absolute because the client chdirs into its app sandbox at startup.
|
|
65
|
+
let clientArgs: string[] = []
|
|
66
|
+
if (args.client !== null) {
|
|
67
|
+
clientArgs.push("--data-root", args.dataRoot ? absolute(args.dataRoot, await realpath(".")) : devDir("clients"))
|
|
68
|
+
clientArgs.push("--client", String(args.client))
|
|
69
|
+
if (args.size) clientArgs.push("--size", args.size)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let config: ServerConfig = {
|
|
73
|
+
mode: mode.mode,
|
|
74
|
+
key: mode.key,
|
|
75
|
+
serverDir,
|
|
76
|
+
entry: mode.entry,
|
|
77
|
+
sourceDir: sourceDirOf(mode),
|
|
78
|
+
projectDir: mode.projectDir,
|
|
79
|
+
cwd: mode.projectDir ?? sourceDirOf(mode),
|
|
80
|
+
entryArgs: [mode.entry, mode.mode === "project" ? "--project" : "--file"],
|
|
81
|
+
srt,
|
|
82
|
+
port: args.port,
|
|
83
|
+
lan: args.lan,
|
|
84
|
+
address: lanAddress ?? "127.0.0.1",
|
|
85
|
+
proxyHttp: args.proxyHttp,
|
|
86
|
+
args: args.appArgs,
|
|
87
|
+
minify: args.minify,
|
|
88
|
+
cache: args.proxyHttp,
|
|
89
|
+
// Build outputs and the proxy cache: the project's .srt-data, or the
|
|
90
|
+
// server folder for a file served on its own (nothing else owns it).
|
|
91
|
+
cacheDir: mode.projectDir ? join(mode.projectDir, ".srt-data") : join(serverDir, "data"),
|
|
92
|
+
capture: args.capture,
|
|
93
|
+
stats: args.stats,
|
|
94
|
+
tunnel: args.tunnel,
|
|
95
|
+
client: runner ? { cmd: runner, args: clientArgs } : null,
|
|
96
|
+
}
|
|
97
|
+
state.config = config
|
|
98
|
+
state.stats = config.stats
|
|
99
|
+
|
|
100
|
+
if (config.cache) {
|
|
101
|
+
await cache.initCache({ dir: config.cacheDir })
|
|
102
|
+
console.log("[cli] HTTP cache enabled")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (config.capture) {
|
|
106
|
+
// Start each capture from an empty file: appends would otherwise tack onto
|
|
107
|
+
// whatever a previous run left behind.
|
|
108
|
+
await file(config.capture).write("")
|
|
109
|
+
state.captureStartMs = Date.now()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Split an origin-form request URL ("/path?a=1&b=2") into its decoded path and
|
|
113
|
+
// query parameters. flux has no URL global; this covers what the routes need.
|
|
114
|
+
function splitQuery(url: string): { path: string; query: Map<string, string> } {
|
|
115
|
+
let i = url.indexOf("?")
|
|
116
|
+
let path = i < 0 ? url : url.slice(0, i)
|
|
117
|
+
let query = new Map<string, string>()
|
|
118
|
+
if (i >= 0) {
|
|
119
|
+
for (let pair of url.slice(i + 1).split("&")) {
|
|
120
|
+
if (!pair) continue
|
|
121
|
+
let j = pair.indexOf("=")
|
|
122
|
+
let k = j < 0 ? pair : pair.slice(0, j)
|
|
123
|
+
let v = j < 0 ? "" : pair.slice(j + 1)
|
|
124
|
+
query.set(decodeURIComponent(k), decodeURIComponent(v.replace(/\+/g, " ")))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { path: decodeURIComponent(path), query }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The file routes: GET file with single-range 206 support. All paths are
|
|
131
|
+
// contained in `root` (the source directory, or the project dir for the
|
|
132
|
+
// /assets/ convention route).
|
|
133
|
+
async function handleFiles(req: FluxRequest, path: string, root: string): Promise<Response> {
|
|
134
|
+
let filePath = resolveWithin(root, "." + path)
|
|
135
|
+
if (!filePath) {
|
|
136
|
+
return new Response("Forbidden", { status: 403 })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log("[cli] get " + path)
|
|
140
|
+
|
|
141
|
+
let stat
|
|
142
|
+
try {
|
|
143
|
+
stat = await file(filePath).stat()
|
|
144
|
+
} catch {
|
|
145
|
+
console.log(`[cli] file not found ${path}`)
|
|
146
|
+
return new Response("Not found", { status: 404 })
|
|
147
|
+
}
|
|
148
|
+
if (stat.type === "directory") {
|
|
149
|
+
return new Response("Not found", { status: 404 })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let baseHeaders: Record<string, string> = { "Accept-Ranges": "bytes" }
|
|
153
|
+
|
|
154
|
+
// Honor a single byte-range request (e.g. streaming audio decoding on the
|
|
155
|
+
// client, which seeks and reads on demand). Only the common "bytes=a-b" /
|
|
156
|
+
// "bytes=a-" / "bytes=-n" forms; anything else falls through to the whole
|
|
157
|
+
// file. Range makes proxied streaming viable without pulling the whole
|
|
158
|
+
// track over the wire.
|
|
159
|
+
let range = req.headers.get("range")
|
|
160
|
+
let match = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null
|
|
161
|
+
if (match) {
|
|
162
|
+
let size = stat.size
|
|
163
|
+
let start: number
|
|
164
|
+
let end: number
|
|
165
|
+
if (match[1] === "") {
|
|
166
|
+
// Suffix range: the last N bytes.
|
|
167
|
+
let n = parseInt(match[2]!, 10)
|
|
168
|
+
start = isNaN(n) ? 0 : Math.max(0, size - n)
|
|
169
|
+
end = size - 1
|
|
170
|
+
} else {
|
|
171
|
+
start = parseInt(match[1]!, 10)
|
|
172
|
+
end = match[2] === "" ? size - 1 : Math.min(parseInt(match[2]!, 10), size - 1)
|
|
173
|
+
}
|
|
174
|
+
if (start > end || start >= size) {
|
|
175
|
+
return new Response("Range not satisfiable", {
|
|
176
|
+
status: 416,
|
|
177
|
+
headers: { ...baseHeaders, "Content-Range": `bytes */${size}` },
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
return new Response(await file(filePath).read(start, end - start + 1), {
|
|
181
|
+
status: 206,
|
|
182
|
+
headers: {
|
|
183
|
+
...baseHeaders,
|
|
184
|
+
"Content-Range": `bytes ${start}-${end}/${size}`,
|
|
185
|
+
"Content-Length": String(end - start + 1),
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return new Response(await file(filePath).bytes(), { headers: baseHeaders })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function handleRequest(req: FluxRequest, server: Server): Promise<Response | undefined> {
|
|
194
|
+
if (server.upgrade(req)) return
|
|
195
|
+
|
|
196
|
+
let { path, query } = splitQuery(req.url)
|
|
197
|
+
|
|
198
|
+
if (path === "/__proxy__") {
|
|
199
|
+
return handleProxy(req)
|
|
200
|
+
}
|
|
201
|
+
if (path.startsWith("/__control__/")) {
|
|
202
|
+
// Every control response names the key this server serves, so a caller
|
|
203
|
+
// that resolved the port from the registry can confirm it reached the
|
|
204
|
+
// server it meant.
|
|
205
|
+
let resp = await handleControl(req, path, query)
|
|
206
|
+
resp.headers.set("x-solidrt-project", config.key)
|
|
207
|
+
resp.headers.set("x-solidrt-generation", String(state.generation))
|
|
208
|
+
return resp
|
|
209
|
+
}
|
|
210
|
+
// The assets/ convention roots at the project dir (package.json), which is
|
|
211
|
+
// not necessarily the entry's dir the file routes serve; clients fetch
|
|
212
|
+
// manifest asset paths here (live proxy reads and store installs alike).
|
|
213
|
+
// File mode has no project and so no assets.
|
|
214
|
+
if (path === "/assets" || path.startsWith("/assets/")) {
|
|
215
|
+
if (!config.projectDir) return new Response("Not found", { status: 404 })
|
|
216
|
+
return handleFiles(req, path, config.projectDir)
|
|
217
|
+
}
|
|
218
|
+
// Isolate bundles are build outputs, not project files: the rebuild writes
|
|
219
|
+
// them under <cacheDir>/isolates/, and the manifest lists them as
|
|
220
|
+
// isolates/<id>.js.
|
|
221
|
+
if (path.startsWith("/isolates/")) {
|
|
222
|
+
return handleFiles(req, path, config.cacheDir)
|
|
223
|
+
}
|
|
224
|
+
return handleFiles(req, path, config.sourceDir)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// A string field of a client's `info`, or null when absent or malformed
|
|
228
|
+
// (an older runtime, or one without the fact).
|
|
229
|
+
function text(value: unknown): string | null {
|
|
230
|
+
return typeof value === "string" ? value : null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function onOpen(ws: ServerWebSocket) {
|
|
234
|
+
let id = state.nextClientId++
|
|
235
|
+
state.clients.set(ws, {
|
|
236
|
+
platform: "unknown",
|
|
237
|
+
version: "unknown",
|
|
238
|
+
profile: "unknown",
|
|
239
|
+
id,
|
|
240
|
+
capabilities: [],
|
|
241
|
+
queries: [],
|
|
242
|
+
stats: state.stats,
|
|
243
|
+
timeScale: 1,
|
|
244
|
+
clientDir: null,
|
|
245
|
+
pid: null,
|
|
246
|
+
execPath: null,
|
|
247
|
+
host: null,
|
|
248
|
+
os: null,
|
|
249
|
+
kernel: null,
|
|
250
|
+
videoDriver: null,
|
|
251
|
+
gpu: null,
|
|
252
|
+
})
|
|
253
|
+
console.log(`[cli] Client connected ${ws.remoteAddr ?? "unknown"}`)
|
|
254
|
+
// Advertise the address we are reachable on, so clients dialed over a
|
|
255
|
+
// loopback hop can show/remember it (see connection.rs).
|
|
256
|
+
ws.send(JSON.stringify({ type: "welcome", address: state.serverUrl, stats: state.stats, capture: !!config.capture, mute: state.userInputMuted }))
|
|
257
|
+
if (state.currentReload) {
|
|
258
|
+
ws.send(state.currentReload)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function onClose(ws: ServerWebSocket) {
|
|
263
|
+
let info = state.clients.get(ws)
|
|
264
|
+
state.clients.delete(ws)
|
|
265
|
+
console.log(`[cli] Client disconnected: ${info?.platform ?? "unknown"}`)
|
|
266
|
+
// `srt run` lives as long as its clients: once the local client is gone,
|
|
267
|
+
// the last remote disconnect ends the server. `srt server` runs until
|
|
268
|
+
// stopped.
|
|
269
|
+
if (config.client && localClientExited && state.clients.size === 0) shutdown()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function onMessage(ws: ServerWebSocket, msg: string | Uint8Array) {
|
|
273
|
+
try {
|
|
274
|
+
let data = JSON.parse(typeof msg === "string" ? msg : new TextDecoder().decode(msg))
|
|
275
|
+
if (data.type === "info") {
|
|
276
|
+
let existing = state.clients.get(ws)
|
|
277
|
+
state.clients.set(ws, {
|
|
278
|
+
platform: data.platform ?? "unknown",
|
|
279
|
+
version: data.version ?? "unknown",
|
|
280
|
+
profile: data.profile ?? "unknown",
|
|
281
|
+
id: existing?.id ?? state.nextClientId++,
|
|
282
|
+
capabilities: Array.isArray(data.capabilities) ? data.capabilities.map(String) : [],
|
|
283
|
+
queries: Array.isArray(data.queries) ? data.queries.map(String) : [],
|
|
284
|
+
stats: existing?.stats ?? state.stats,
|
|
285
|
+
timeScale: existing?.timeScale ?? 1,
|
|
286
|
+
clientDir: text(data.clientDir),
|
|
287
|
+
pid: typeof data.pid === "number" ? data.pid : null,
|
|
288
|
+
execPath: text(data.execPath),
|
|
289
|
+
host: text(data.host),
|
|
290
|
+
os: text(data.os),
|
|
291
|
+
kernel: text(data.kernel),
|
|
292
|
+
videoDriver: text(data.videoDriver),
|
|
293
|
+
gpu:
|
|
294
|
+
data.gpu && typeof data.gpu === "object"
|
|
295
|
+
? { vendor: text(data.gpu.vendor) ?? "", renderer: text(data.gpu.renderer) ?? "", version: text(data.gpu.version) ?? "" }
|
|
296
|
+
: null,
|
|
297
|
+
})
|
|
298
|
+
console.log(`[cli] Client info ${ws.remoteAddr ?? "unknown"} ${data.platform} (${data.version})`)
|
|
299
|
+
} else if (data.type === "log") {
|
|
300
|
+
// Forwarded console output / runtime errors from the client's engine
|
|
301
|
+
// logger, buffered for the control API (see control.ts). Not printed
|
|
302
|
+
// here: the local client already writes to this terminal, so echoing
|
|
303
|
+
// would duplicate every line.
|
|
304
|
+
let device = state.clients.get(ws)?.id ?? -1
|
|
305
|
+
appendLog(device, String(data.level ?? "log"), String(data.text ?? ""))
|
|
306
|
+
} else if (data.type === "result") {
|
|
307
|
+
// Reply to a query the control API forwarded to this client.
|
|
308
|
+
resolveQuery(data)
|
|
309
|
+
} else if (data.type === "capture" && config.capture) {
|
|
310
|
+
let device = state.clients.get(ws)?.id ?? -1
|
|
311
|
+
// Milliseconds, integer: Date.now() is already integer ms, so the
|
|
312
|
+
// delta needs no rounding.
|
|
313
|
+
let at = Date.now() - state.captureStartMs
|
|
314
|
+
let after = at - state.captureLastAt
|
|
315
|
+
state.captureLastAt = at
|
|
316
|
+
// JSON Lines: one event object per line, streamed to disk as it
|
|
317
|
+
// arrives rather than buffered - no in-memory growth for a long
|
|
318
|
+
// capture, and the file is always complete on disk mid-session.
|
|
319
|
+
// Appends are chained so events land in arrival order.
|
|
320
|
+
let line = JSON.stringify({ after, type: data.kind, key: data.key, device }) + "\n"
|
|
321
|
+
state.captureChain = state.captureChain.then(() => file(config.capture!).append(line))
|
|
322
|
+
}
|
|
323
|
+
} catch {}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// The port: an explicit --port, else the one this server bound last time
|
|
327
|
+
// (so a project keeps its port in practice), else the first free one from
|
|
328
|
+
// DEFAULT_PORT upward, so servers on one machine read as 34884, 34885, ...
|
|
329
|
+
// A remembered or default port that is taken is skipped; an explicit one
|
|
330
|
+
// that is taken is the user's problem to see.
|
|
331
|
+
const DEFAULT_PORT = 0x8844
|
|
332
|
+
const PORT_TRIES = 100
|
|
333
|
+
let host = config.lan ? "0.0.0.0" : "127.0.0.1"
|
|
334
|
+
let remembered = config.port ?? (await rememberedPort(config.serverDir))
|
|
335
|
+
|
|
336
|
+
// Ticket-paired clients connect through this endpoint; serve() accepts its
|
|
337
|
+
// connections directly alongside the TCP listener. Its UDP port follows the
|
|
338
|
+
// remembered port so a ticket stays stable across restarts.
|
|
339
|
+
let tunnel = config.tunnel ? await createTunnelEndpoint(remembered, config.serverDir) : null
|
|
340
|
+
|
|
341
|
+
function bind(port: number): Server {
|
|
342
|
+
return serve({
|
|
343
|
+
host,
|
|
344
|
+
port,
|
|
345
|
+
p2p: tunnel ? { endpoint: tunnel, protocol: TUNNEL_PROTOCOL } : undefined,
|
|
346
|
+
fetch: handleRequest,
|
|
347
|
+
websocket: { open: onOpen, close: onClose, message: onMessage },
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// A bind alone does not prove a port free: with SO_REUSEADDR (the default
|
|
352
|
+
// on a listener) Linux lets a loopback bind coexist with another process's
|
|
353
|
+
// all-interfaces listener on the same port, and the newcomer then silently
|
|
354
|
+
// takes the loopback traffic. So each candidate is dialed first; only a
|
|
355
|
+
// refusal means free.
|
|
356
|
+
async function bindFirstFree(): Promise<Server> {
|
|
357
|
+
if (config.port !== undefined) return bind(config.port)
|
|
358
|
+
let candidates: number[] = remembered !== null ? [remembered] : []
|
|
359
|
+
for (let p = DEFAULT_PORT; p < DEFAULT_PORT + PORT_TRIES; p++) candidates.push(p)
|
|
360
|
+
let last: unknown = null
|
|
361
|
+
for (let port of candidates) {
|
|
362
|
+
if ((await probe("127.0.0.1", port, { timeoutMs: 200 })) !== "closed") continue
|
|
363
|
+
try {
|
|
364
|
+
return bind(port)
|
|
365
|
+
} catch (e) {
|
|
366
|
+
last = e
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
throw last ?? new Error(`No free port between ${DEFAULT_PORT} and ${DEFAULT_PORT + PORT_TRIES - 1}`)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let server = await bindFirstFree()
|
|
373
|
+
|
|
374
|
+
let address = config.lan ? config.address : "127.0.0.1"
|
|
375
|
+
state.serverUrl = `${address}:${server.port}`
|
|
376
|
+
await writeRecord(config, server.port, address)
|
|
377
|
+
|
|
378
|
+
// One QR on screen: with the tunnel on, the ticket QR (printed by
|
|
379
|
+
// createTunnelEndpoint) is the pairing story and the address stays text-only;
|
|
380
|
+
// on the LAN without it, the address QR is the scan target. Loopback-only has
|
|
381
|
+
// nothing to scan.
|
|
382
|
+
if (config.lan && !config.tunnel) {
|
|
383
|
+
console.log("")
|
|
384
|
+
printQr(state.serverUrl)
|
|
385
|
+
console.log("")
|
|
386
|
+
}
|
|
387
|
+
console.log(`[cli] Dev server on http://${state.serverUrl} serving ${config.mode} ${config.key}`)
|
|
388
|
+
|
|
389
|
+
// Keepalive
|
|
390
|
+
let keepalive = setInterval(() => {
|
|
391
|
+
for (let ws of state.clients.keys()) {
|
|
392
|
+
ws.ping()
|
|
393
|
+
}
|
|
394
|
+
}, 5000)
|
|
395
|
+
|
|
396
|
+
let shuttingDown = false
|
|
397
|
+
let stopRepl = () => {}
|
|
398
|
+
let localClient: Child | null = null
|
|
399
|
+
let localClientExited = false
|
|
400
|
+
let signalOffs = ["SIGINT", "SIGTERM"].map((signal) =>
|
|
401
|
+
onSignal(signal, () => {
|
|
402
|
+
shutdown()
|
|
403
|
+
}),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
// Orderly exit: drop the record, stop the client, close the listeners and
|
|
407
|
+
// release every handle that keeps the loop alive, so the process ends on
|
|
408
|
+
// its own (flux has no exit call; an idle loop is the exit).
|
|
409
|
+
async function shutdown() {
|
|
410
|
+
if (shuttingDown) return
|
|
411
|
+
shuttingDown = true
|
|
412
|
+
clearInterval(keepalive)
|
|
413
|
+
for (let off of signalOffs) off()
|
|
414
|
+
stopRepl()
|
|
415
|
+
stopWatcher()
|
|
416
|
+
await removeRecord(config.serverDir)
|
|
417
|
+
if (localClient) localClient.kill()
|
|
418
|
+
server.close()
|
|
419
|
+
if (tunnel) await tunnel.close()
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Print a child's output line by line as it arrives.
|
|
423
|
+
async function pump(stream: AsyncIterable<Uint8Array>, print: (line: string) => void) {
|
|
424
|
+
let decoder = new TextDecoder()
|
|
425
|
+
let rest = ""
|
|
426
|
+
for await (let chunk of stream) {
|
|
427
|
+
rest += decoder.decode(chunk, { stream: true })
|
|
428
|
+
let lines = rest.split("\n")
|
|
429
|
+
rest = lines.pop() ?? ""
|
|
430
|
+
for (let line of lines) print(line)
|
|
431
|
+
}
|
|
432
|
+
if (rest) print(rest)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// The initial bundle, latched for the clients about to connect. A failed
|
|
436
|
+
// build shows the BSOD rather than nothing; the next reload retries. The
|
|
437
|
+
// rebuild arms reload-on-save from the bundle's inputs (watcher.ts).
|
|
438
|
+
console.log("[cli] Bundling (development)")
|
|
439
|
+
let buildError = await rebuildAndBroadcast()
|
|
440
|
+
if (buildError) {
|
|
441
|
+
console.error(buildError)
|
|
442
|
+
showBuildFailure()
|
|
443
|
+
}
|
|
444
|
+
console.log("[cli] Reload on save is on (pause it with the MCP pause_watch tool)")
|
|
445
|
+
stopRepl = startRepl(shutdown)
|
|
446
|
+
|
|
447
|
+
// Startup typecheck (`srt check <entry>`), deliberately not awaited: the
|
|
448
|
+
// report prints when tsc finishes, and a type error never gates the boot
|
|
449
|
+
// (srt check is the hard gate). Once per server lifetime; reloads never
|
|
450
|
+
// typecheck. A prebuilt .srt.js has no checkable program.
|
|
451
|
+
if (!config.entry.endsWith(".srt.js")) {
|
|
452
|
+
let check = command(config.srt[0]!, [...config.srt.slice(1), "check", config.entry], { cwd: config.cwd }).spawn()
|
|
453
|
+
pump(check.stdout, (line) => console.log(line))
|
|
454
|
+
pump(check.stderr, (line) => console.error(line))
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (config.client) {
|
|
458
|
+
// The local client dials the port bound above; srt could not know it.
|
|
459
|
+
let child = command(config.client.cmd, [...config.client.args, "--dev-server", `127.0.0.1:${server.port}`]).spawn()
|
|
460
|
+
localClient = child
|
|
461
|
+
pump(child.stdout, (line) => console.log(line))
|
|
462
|
+
pump(child.stderr, (line) => console.error(line))
|
|
463
|
+
child.status().then(() => {
|
|
464
|
+
localClient = null
|
|
465
|
+
localClientExited = true
|
|
466
|
+
if (shuttingDown) return
|
|
467
|
+
if (state.clients.size === 0) {
|
|
468
|
+
shutdown()
|
|
469
|
+
} else {
|
|
470
|
+
console.log(`[cli] Local client exited, ${state.clients.size} remote client(s) still connected`)
|
|
471
|
+
}
|
|
472
|
+
})
|
|
473
|
+
}
|