@solidrt/cli 0.0.50 → 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 +79 -36
- package/README.md +86 -15
- package/agents/assets.md +46 -0
- package/agents/debugging.md +294 -0
- package/dist/console.srtapp +94123 -73
- package/dist/server.js +3936 -0
- package/package.json +11 -10
- package/src/android/docs.md +21 -0
- package/src/android/main.ts +286 -0
- package/src/{bundler.ts → bundle/bundler.ts} +163 -76
- package/src/bundle/docs.md +12 -0
- package/src/bundle/main.ts +203 -0
- package/src/check/docs.md +10 -0
- package/src/check/main.ts +85 -0
- package/src/{commands/check.ts → check/typecheck.ts} +11 -34
- 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} +31 -25
- 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/pack/main.ts +86 -0
- package/src/pack/trailer.ts +97 -0
- package/src/render/docs.md +19 -0
- package/src/render/main.ts +49 -0
- 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 +246 -78
- 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/remap.ts +60 -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 -630
- 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 -68
- package/server/remap.ts +0 -47
- package/server/state.ts +0 -93
- package/src/args.ts +0 -212
- package/src/bundle-cli.ts +0 -13
- package/src/commands/bundle.ts +0 -76
- package/src/commands/client.ts +0 -34
- package/src/commands/mcp.ts +0 -603
- package/src/commands/pack.ts +0 -65
- package/src/commands/render.ts +0 -24
- 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/packer.ts +0 -103
- package/src/repl.ts +0 -233
- package/src/util.ts +0 -121
- package/src/watcher.ts +0 -69
- /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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// What this server serves, decided by the cwd and the entry argument and
|
|
2
|
+
// never by searching upward. The rule and its table live in
|
|
3
|
+
// packages/cli/src/lib/mode.ts (the bun copy the one-shot commands use); this is
|
|
4
|
+
// the same table for the server, over flux:fs - two honest copies rather
|
|
5
|
+
// than an fs shim (okf/done/srt-command-folders.md):
|
|
6
|
+
//
|
|
7
|
+
// cwd has package.json argument mode
|
|
8
|
+
// yes none project (entry = solidrt.entry, default src/index.tsx)
|
|
9
|
+
// no none error
|
|
10
|
+
// no file file
|
|
11
|
+
// yes file error, unless --project (project at cwd,
|
|
12
|
+
// entry overridden) or --file (ignore the project)
|
|
13
|
+
//
|
|
14
|
+
// The key (the canonical project root, or the canonical file path) is what
|
|
15
|
+
// the registry and every control response name.
|
|
16
|
+
|
|
17
|
+
import { file, realpath } from "flux:fs"
|
|
18
|
+
import { join } from "flux:path"
|
|
19
|
+
import { fail } from "./args"
|
|
20
|
+
|
|
21
|
+
export type Mode =
|
|
22
|
+
| { mode: "project"; key: string; projectDir: string; entry: string }
|
|
23
|
+
| { mode: "file"; key: string; projectDir: null; entry: string }
|
|
24
|
+
|
|
25
|
+
const DEFAULT_ENTRY = "src/index.tsx"
|
|
26
|
+
// A prebuilt .srt.js ends with .js, so it is admitted by the same list.
|
|
27
|
+
export const ENTRY_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"]
|
|
28
|
+
|
|
29
|
+
export async function resolveMode(args: { entry: string | undefined; project: boolean; file: boolean }): Promise<Mode> {
|
|
30
|
+
let cwd = await realpath(".")
|
|
31
|
+
let hasPkg = await file(join(cwd, "package.json")).exists()
|
|
32
|
+
let source = args.entry
|
|
33
|
+
|
|
34
|
+
if (source !== undefined && !ENTRY_EXTENSIONS.some((ext) => source.endsWith(ext))) {
|
|
35
|
+
fail(`Not an app entry: ${source} (expected .tsx, .jsx, .ts, .js or .srt.js)`)
|
|
36
|
+
}
|
|
37
|
+
if (source !== undefined && !(await file(source).exists())) fail(`Entry not found: ${source}`)
|
|
38
|
+
if (args.file && args.project) fail("--file and --project exclude each other")
|
|
39
|
+
|
|
40
|
+
if (source === undefined) {
|
|
41
|
+
if (args.file || args.project) fail("--file and --project need an entry file")
|
|
42
|
+
if (!hasPkg) fail(`No package.json in ${cwd}. Run from the project root, or pass a file to use on its own.`)
|
|
43
|
+
let declared = await declaredEntry(cwd)
|
|
44
|
+
let entry = join(cwd, declared ?? DEFAULT_ENTRY)
|
|
45
|
+
if (!(await file(entry).exists())) {
|
|
46
|
+
fail(`Entry not found: ${entry}${declared ? "" : ' (set "solidrt": { "entry": ... } in package.json)'}`)
|
|
47
|
+
}
|
|
48
|
+
return { mode: "project", key: cwd, projectDir: cwd, entry }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let entry = await realpath(source)
|
|
52
|
+
if (hasPkg && !args.file && !args.project) {
|
|
53
|
+
fail(
|
|
54
|
+
`${cwd} is a project (it has a package.json) and ${source} is a file: pass --project to use the project with this entry, or --file to use the file on its own.`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
if (hasPkg && args.project) return { mode: "project", key: cwd, projectDir: cwd, entry }
|
|
58
|
+
if (!hasPkg && args.project) fail(`--project needs a package.json in ${cwd}`)
|
|
59
|
+
return { mode: "file", key: entry, projectDir: null, entry }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The project's declared entry (`"solidrt": { "entry" }` in package.json),
|
|
63
|
+
// relative to the project root. Only the one field the server needs is
|
|
64
|
+
// checked here; the bun commands validate the whole key (src/lib/project.ts).
|
|
65
|
+
async function declaredEntry(dir: string): Promise<string | undefined> {
|
|
66
|
+
let pkg = await file(join(dir, "package.json"))
|
|
67
|
+
.json()
|
|
68
|
+
.catch(() => fail(`Unreadable package.json in ${dir}`))
|
|
69
|
+
let config = pkg?.solidrt
|
|
70
|
+
if (config === undefined) return undefined
|
|
71
|
+
if (typeof config !== "object" || config === null || Array.isArray(config)) {
|
|
72
|
+
fail('"solidrt" in package.json must be an object')
|
|
73
|
+
}
|
|
74
|
+
if ("entry" in config && typeof config.entry !== "string") fail('"solidrt": "entry" must be a string')
|
|
75
|
+
return config.entry
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The directory the file routes serve: the entry's directory. */
|
|
79
|
+
export function sourceDirOf(mode: Mode): string {
|
|
80
|
+
return dirname(mode.entry)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The parent of a path (flux:path has no dirname); "." for a bare name. */
|
|
84
|
+
export function dirname(path: string): string {
|
|
85
|
+
let i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
|
|
86
|
+
return i <= 0 ? (i === 0 ? path.slice(0, 1) : ".") : path.slice(0, i)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `path` made absolute against `base` when it is not already. */
|
|
90
|
+
export function absolute(path: string, base: string): string {
|
|
91
|
+
return /^([A-Za-z]:)?[\\/]/.test(path) ? path : join(base, path)
|
|
92
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { command } from "flux:subprocess"
|
|
2
|
+
import { dir, file } from "flux:fs"
|
|
3
|
+
import { state } from "./state"
|
|
4
|
+
import { armWatcher } from "./watcher"
|
|
5
|
+
import type { BundleOutput } from "../types/bundle"
|
|
6
|
+
|
|
7
|
+
// The single rebuild-and-push path: the initial bundle at start, every
|
|
8
|
+
// MCP reload and every reload-on-save go through here. The bundle itself
|
|
9
|
+
// runs in a bun subprocess (`srt bundle --json`, the one thing only bun can
|
|
10
|
+
// do); this side latches the result for late-joining clients, broadcasts
|
|
11
|
+
// it, and re-arms the watcher from the bundle's inputs (watcher.ts).
|
|
12
|
+
|
|
13
|
+
// Build the reload message for the client protocol. `manifest` is the
|
|
14
|
+
// bundle's version manifest JSON string; when present, clients install the
|
|
15
|
+
// push into their version store before applying it (absent for the
|
|
16
|
+
// build-failure trigger, which must never be installed). proxyHttp is a
|
|
17
|
+
// message flag, not a build input.
|
|
18
|
+
function buildReload(code: string, manifest?: string) {
|
|
19
|
+
let config = state.config
|
|
20
|
+
return {
|
|
21
|
+
type: "reload",
|
|
22
|
+
proxyHttp: config.proxyHttp,
|
|
23
|
+
args: config.args,
|
|
24
|
+
...(manifest ? { manifest } : {}),
|
|
25
|
+
code,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Reload code that fails to start the engine on purpose. The runtime treats a
|
|
30
|
+
// startup error like any app that never called render() and falls back to its
|
|
31
|
+
// baked-in BSOD screen, so a build that doesn't compile shows the BSOD instead
|
|
32
|
+
// of leaving the previous app frozen on screen.
|
|
33
|
+
const BSOD_TRIGGER = `throw new Error("SolidRT: build failed")`
|
|
34
|
+
|
|
35
|
+
function latchAndSend(text: string) {
|
|
36
|
+
state.currentReload = text
|
|
37
|
+
for (let [ws, info] of state.clients) {
|
|
38
|
+
// The runtime restarts the app's clock with the app.
|
|
39
|
+
info.timeScale = 1
|
|
40
|
+
ws.send(text)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Latch the build-failure trigger and push it, so every client (and any
|
|
45
|
+
* that connects later) shows the BSOD instead of a stale app. */
|
|
46
|
+
export function showBuildFailure() {
|
|
47
|
+
state.currentMaps = null
|
|
48
|
+
latchAndSend(JSON.stringify(buildReload(BSOD_TRIGGER)))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Rebuild from config.entry via `srt bundle --json` (a bun subprocess run
|
|
52
|
+
// in config.cwd with config.entryArgs, so it resolves the mode this server
|
|
53
|
+
// did), then latch (for late-joining clients) and broadcast the reload to
|
|
54
|
+
// every connected client. Resolves with an error message on failure (a build
|
|
55
|
+
// error), or null on success.
|
|
56
|
+
export async function rebuildAndBroadcast(): Promise<string | null> {
|
|
57
|
+
let config = state.config
|
|
58
|
+
let args = [...config.srt.slice(1), "bundle", "--json", "--dev", ...config.entryArgs]
|
|
59
|
+
if (config.minify) args.push("--minify")
|
|
60
|
+
|
|
61
|
+
let result = await command(config.srt[0]!, args, { cwd: config.cwd }).output()
|
|
62
|
+
if (!result.success) {
|
|
63
|
+
let stderr = typeof result.stderr === "string" ? result.stderr : ""
|
|
64
|
+
armWatcher(null)
|
|
65
|
+
return `Rebuild failed:\n${stderr.trim()}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The bundle is one JSON object on stdout (types/bundle.d.ts).
|
|
69
|
+
let bundle: BundleOutput
|
|
70
|
+
try {
|
|
71
|
+
bundle = JSON.parse(typeof result.stdout === "string" ? result.stdout : "")
|
|
72
|
+
} catch {
|
|
73
|
+
armWatcher(null)
|
|
74
|
+
return "Rebuild failed: unreadable bundler output"
|
|
75
|
+
}
|
|
76
|
+
armWatcher(bundle.inputs ?? null)
|
|
77
|
+
// Isolate bundles are manifest assets clients fetch from our /isolates/
|
|
78
|
+
// route (served from cacheDir), so they must be on disk before the push.
|
|
79
|
+
let maps: Record<string, string> = {}
|
|
80
|
+
if (bundle.map) maps.main = bundle.map
|
|
81
|
+
for (let isolate of bundle.isolates ?? []) {
|
|
82
|
+
let path = `${config.cacheDir}/isolates/${isolate.id}.js`
|
|
83
|
+
await dir(path.slice(0, path.lastIndexOf("/"))).create()
|
|
84
|
+
await file(path).write(isolate.code)
|
|
85
|
+
if (isolate.map) maps[isolate.id] = isolate.map
|
|
86
|
+
}
|
|
87
|
+
state.currentMaps = Object.keys(maps).length ? maps : null
|
|
88
|
+
latchAndSend(JSON.stringify(buildReload(bundle.code ?? "", bundle.manifest)))
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The dev server registry: ~/.solidrt/servers/<key hash>/live.json, one
|
|
2
|
+
// folder per server key, written by the server itself once its port is
|
|
3
|
+
// bound and removed when it exits, so `srt client`, `srt mcp` and the
|
|
4
|
+
// console resolve a server without any per-project config
|
|
5
|
+
// (okf/backlog/cli-flux-migration.md). The process that owns the pid and the
|
|
6
|
+
// port owns the record, so a record whose pid is dead is stale and nothing
|
|
7
|
+
// else; readers still confirm with a control call. The bun readers live in
|
|
8
|
+
// packages/cli/src/lib/registry.ts and dev-dir.ts (the same paths and hash).
|
|
9
|
+
//
|
|
10
|
+
// The folder also remembers the last bound port so the next run tries it
|
|
11
|
+
// first: a project keeps its port in practice, and tunnel tickets and client
|
|
12
|
+
// recents stay valid, without anyone choosing a number.
|
|
13
|
+
|
|
14
|
+
import { dir, file, realpath } from "flux:fs"
|
|
15
|
+
import { join } from "flux:path"
|
|
16
|
+
import { alive, homedir, pid } from "flux:process"
|
|
17
|
+
import { fail } from "./args"
|
|
18
|
+
import type { ServerConfig } from "./config"
|
|
19
|
+
import type { LiveRecord } from "../types/registry"
|
|
20
|
+
|
|
21
|
+
const RECORD_FILE = "live.json"
|
|
22
|
+
const PORT_FILE = "port"
|
|
23
|
+
|
|
24
|
+
/** All dev-tooling state lives in ~/.solidrt (one rule on every platform). */
|
|
25
|
+
export function devDir(...parts: string[]): string {
|
|
26
|
+
let home = homedir()
|
|
27
|
+
if (!home) fail("No home directory: the dev server keeps its state under ~/.solidrt")
|
|
28
|
+
return join(home, ".solidrt", ...parts)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A server's folder: `servers/<sha256 of its canonical key, truncated>/`.
|
|
32
|
+
* The name is never parsed back; live.json inside is the record. */
|
|
33
|
+
export async function serverDirFor(key: string): Promise<string> {
|
|
34
|
+
let digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key))
|
|
35
|
+
let hex = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")
|
|
36
|
+
return devDir("servers", hex.slice(0, 16))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Entry = { path: string; record: LiveRecord }
|
|
40
|
+
|
|
41
|
+
// Every well-formed record on disk, alive or not, with its file path.
|
|
42
|
+
// Malformed records are skipped, not fatal.
|
|
43
|
+
async function records(): Promise<Entry[]> {
|
|
44
|
+
let root = devDir("servers")
|
|
45
|
+
let entries = await dir(root)
|
|
46
|
+
.entries()
|
|
47
|
+
.catch(() => [])
|
|
48
|
+
let found: Entry[] = []
|
|
49
|
+
for (let entry of entries) {
|
|
50
|
+
if (entry.type !== "directory") continue
|
|
51
|
+
let path = join(root, entry.name, RECORD_FILE)
|
|
52
|
+
let record = await file(path)
|
|
53
|
+
.json()
|
|
54
|
+
.catch(() => null)
|
|
55
|
+
if (typeof record?.pid === "number" && typeof record?.port === "number" && typeof record?.key === "string") {
|
|
56
|
+
found.push({ path, record })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return found
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every readable record whose process is alive. */
|
|
63
|
+
export async function liveRecords(): Promise<LiveRecord[]> {
|
|
64
|
+
return (await records()).filter((e) => alive(e.record.pid)).map((e) => e.record)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Drop every record whose process is gone. A server removes its own record
|
|
68
|
+
* on exit, so one left behind is a crash's fossil. Runs at server start, the
|
|
69
|
+
* one moment every registry user passes through, so readers never write. */
|
|
70
|
+
export async function pruneDeadRecords() {
|
|
71
|
+
for (let { path, record } of await records()) {
|
|
72
|
+
if (!alive(record.pid)) await file(path).remove().catch(() => {})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Whether the control API on the record's port answers for the record's key:
|
|
77
|
+
// every control response names the key it serves.
|
|
78
|
+
async function serves(record: LiveRecord): Promise<boolean> {
|
|
79
|
+
let control = new AbortController()
|
|
80
|
+
let timer = setTimeout(() => control.abort(), 1000)
|
|
81
|
+
try {
|
|
82
|
+
let resp = await fetch(`http://127.0.0.1:${record.port}/__control__/clients`, { signal: control.signal })
|
|
83
|
+
return resp.headers.get("x-solidrt-project") === record.key
|
|
84
|
+
} catch {
|
|
85
|
+
return false
|
|
86
|
+
} finally {
|
|
87
|
+
clearTimeout(timer)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The running server for the canonical `key`, if any. Keys from different
|
|
92
|
+
* processes agree on the path, not the spelling, so compare canonical. The
|
|
93
|
+
* record is a hint and the server is authoritative: a candidate whose server
|
|
94
|
+
* does not answer for the key is a pid reused by an unrelated process, and
|
|
95
|
+
* is dropped instead of reported as a clash. */
|
|
96
|
+
export async function runningFor(key: string): Promise<LiveRecord | undefined> {
|
|
97
|
+
for (let { path, record } of await records()) {
|
|
98
|
+
if (!alive(record.pid)) continue
|
|
99
|
+
if (record.key === key || (await realpath(record.key).catch(() => null)) === key) {
|
|
100
|
+
if (await serves(record)) return record
|
|
101
|
+
await file(path).remove().catch(() => {})
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The port this server bound last time, or null on a first run. */
|
|
108
|
+
export async function rememberedPort(serverDir: string): Promise<number | null> {
|
|
109
|
+
let text = await file(join(serverDir, PORT_FILE))
|
|
110
|
+
.text()
|
|
111
|
+
.catch(() => "")
|
|
112
|
+
let port = Number(text.trim())
|
|
113
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Write the record and remember the port. */
|
|
117
|
+
export async function writeRecord(config: ServerConfig, port: number, address: string) {
|
|
118
|
+
await dir(config.serverDir).create()
|
|
119
|
+
let record: LiveRecord = {
|
|
120
|
+
pid,
|
|
121
|
+
port,
|
|
122
|
+
address,
|
|
123
|
+
key: config.key,
|
|
124
|
+
mode: config.mode,
|
|
125
|
+
entry: config.entry,
|
|
126
|
+
projectDir: config.projectDir,
|
|
127
|
+
started: new Date().toISOString(),
|
|
128
|
+
}
|
|
129
|
+
await file(join(config.serverDir, RECORD_FILE)).write(JSON.stringify(record))
|
|
130
|
+
await file(join(config.serverDir, PORT_FILE)).write(String(port))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Drop the record; the port memory and the tunnel key stay. */
|
|
134
|
+
export async function removeRecord(serverDir: string) {
|
|
135
|
+
await file(join(serverDir, RECORD_FILE))
|
|
136
|
+
.remove()
|
|
137
|
+
.catch(() => {})
|
|
138
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping"
|
|
2
|
+
|
|
3
|
+
// Stack-trace remapping for forwarded client logs. The runtime evaluates the
|
|
4
|
+
// app bundle as module "main" and each isolate bundle under its isolate id,
|
|
5
|
+
// so QuickJS frames cite bundle positions like "at boom (main:212:9)" or
|
|
6
|
+
// "at boom (worker:65:13)". With the current reload's sourcemaps latched on
|
|
7
|
+
// the server (state.currentMaps, keyed by module name), those positions are
|
|
8
|
+
// rewritten to the original .tsx sources before a log entry is buffered. A
|
|
9
|
+
// module without a map is left as it is: a position is never remapped against
|
|
10
|
+
// another module's map.
|
|
11
|
+
|
|
12
|
+
// Parsed maps are cached per module name and map text; a reload swaps the
|
|
13
|
+
// texts and the next lookup rebuilds the tracers. Entries for removed modules
|
|
14
|
+
// linger unused, which is harmless.
|
|
15
|
+
let cached = new Map<string, { text: string; tracer: TraceMap | null }>()
|
|
16
|
+
|
|
17
|
+
function tracerFor(name: string, text: string): TraceMap | null {
|
|
18
|
+
let entry = cached.get(name)
|
|
19
|
+
if (!entry || entry.text !== text) {
|
|
20
|
+
let tracer: TraceMap | null = null
|
|
21
|
+
try {
|
|
22
|
+
tracer = new TraceMap(JSON.parse(text))
|
|
23
|
+
} catch {
|
|
24
|
+
// A malformed map disables remapping for this module until the next reload.
|
|
25
|
+
}
|
|
26
|
+
entry = { text, tracer }
|
|
27
|
+
cached.set(name, entry)
|
|
28
|
+
}
|
|
29
|
+
return entry.tracer
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Rewrite every "NAME:LINE:COL" (or "NAME:LINE") position in `text`, for each
|
|
36
|
+
* module NAME in `maps`, to its original source position, e.g.
|
|
37
|
+
* "src/app.tsx:42:7". Positions a map has no entry for, positions of modules
|
|
38
|
+
* without a map, and all text when `maps` is null, pass through unchanged.
|
|
39
|
+
* QuickJS lines and columns are 1-based; sourcemap columns are 0-based.
|
|
40
|
+
*/
|
|
41
|
+
export function remapPositions(text: string, maps: Record<string, string> | null): string {
|
|
42
|
+
if (!maps) return text
|
|
43
|
+
for (let [name, map] of Object.entries(maps)) {
|
|
44
|
+
if (!text.includes(name + ":")) continue
|
|
45
|
+
let t = tracerFor(name, map)
|
|
46
|
+
if (!t) continue
|
|
47
|
+
// The leading capture keeps a name from matching inside a longer one
|
|
48
|
+
// ("audio" inside "workers/audio" or "app" inside "src/app.tsx").
|
|
49
|
+
let pattern = new RegExp(`(^|[^\\w/.$-])${name.replace(REGEX_SPECIALS, "\\$&")}:(\\d+)(?::(\\d+))?\\b`, "g")
|
|
50
|
+
text = text.replace(pattern, (frame, prefix, line, column) => {
|
|
51
|
+
let pos = originalPositionFor(t, {
|
|
52
|
+
line: parseInt(line, 10),
|
|
53
|
+
column: column ? Math.max(parseInt(column, 10) - 1, 0) : 0,
|
|
54
|
+
})
|
|
55
|
+
if (pos.source == null || pos.line == null) return frame
|
|
56
|
+
return `${prefix}${pos.source}:${pos.line}:${(pos.column ?? 0) + 1}`
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
return text
|
|
60
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { isTTY, on, write } from "flux:tty"
|
|
2
|
+
import { dir } from "flux:fs"
|
|
3
|
+
import type { ServerWebSocket } from "flux:http"
|
|
4
|
+
import { state } from "./state"
|
|
5
|
+
import { rebuildAndBroadcast, showBuildFailure } from "./rebuild"
|
|
6
|
+
import { clientList, loadEntry, setStats, setUserInputMuted, setWatchActive } from "./control"
|
|
7
|
+
import { ENTRY_EXTENSIONS, absolute } from "./mode"
|
|
8
|
+
import { startLineEditor } from "./line-editor"
|
|
9
|
+
import type { Completion } from "./line-editor"
|
|
10
|
+
|
|
11
|
+
// The repl on the server's own terminal: the same actions the control API
|
|
12
|
+
// offers a coding agent, typed by hand. A line editor over flux:tty raw mode
|
|
13
|
+
// (line-editor.ts: history, Tab completion of commands and `load` paths);
|
|
14
|
+
// where raw mode is refused, cooked lines from the terminal's own line
|
|
15
|
+
// discipline. Only with a terminal on stdin: a server started by a
|
|
16
|
+
// supervisor, the console or a background `&` has none, runs without a
|
|
17
|
+
// prompt, and stops on a signal.
|
|
18
|
+
|
|
19
|
+
const PROMPT = "srt> "
|
|
20
|
+
const HELP = "Commands: load <file>, reload [id...], stop [id...], list, stats [on|off], watch on|off, mute on|off, quit, help"
|
|
21
|
+
const COMMANDS = ["load ", "reload", "stop", "list", "stats", "watch ", "mute ", "quit", "exit", "help"]
|
|
22
|
+
|
|
23
|
+
// The clients named by a list of ids ("0 2", as `list` prints them), or
|
|
24
|
+
// every client for no ids. Unknown ids are reported and skipped.
|
|
25
|
+
function targets(args: string): ServerWebSocket[] {
|
|
26
|
+
let entries = [...state.clients.entries()]
|
|
27
|
+
if (!args) return entries.map(([ws]) => ws)
|
|
28
|
+
let found: ServerWebSocket[] = []
|
|
29
|
+
for (let token of args.split(/\s+/)) {
|
|
30
|
+
let id = parseInt(token, 10)
|
|
31
|
+
let entry = entries.find(([, info]) => info.id === id)
|
|
32
|
+
if (!entry) {
|
|
33
|
+
console.log(`No client with id ${token}`)
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
found.push(entry[0])
|
|
37
|
+
}
|
|
38
|
+
return found
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function describe(args: string, count: number): string {
|
|
42
|
+
return args ? `client(s) ${args.split(/\s+/).join(", ")}` : `all ${count} client(s)`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function cmdReload(args: string) {
|
|
46
|
+
let error = await rebuildAndBroadcast()
|
|
47
|
+
if (error) {
|
|
48
|
+
console.error(error)
|
|
49
|
+
showBuildFailure()
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
if (args) {
|
|
53
|
+
// rebuildAndBroadcast pushed to everyone; a selective reload re-sends
|
|
54
|
+
// the latched bundle to the named clients only, which is what it meant
|
|
55
|
+
// before the push existed. Kept for the one case it still serves: a
|
|
56
|
+
// client that missed the broadcast.
|
|
57
|
+
for (let ws of targets(args)) if (state.currentReload) ws.send(state.currentReload)
|
|
58
|
+
}
|
|
59
|
+
console.log(`[cli] Sent reload to ${describe(args, state.clients.size)}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cmdStop(args: string) {
|
|
63
|
+
let text = JSON.stringify({ type: "stop" })
|
|
64
|
+
let list = targets(args)
|
|
65
|
+
for (let ws of list) ws.send(text)
|
|
66
|
+
if (list.length) console.log(`[cli] Sent stop to ${describe(args, list.length)}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function cmdLoad(file: string) {
|
|
70
|
+
if (!file) {
|
|
71
|
+
console.log("Usage: load <file>")
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
let result = await loadEntry(file)
|
|
75
|
+
if ("error" in result) {
|
|
76
|
+
console.error(result.error)
|
|
77
|
+
if (result.status === 502) showBuildFailure()
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
console.log(`[cli] Loaded ${result.entry}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cmdList() {
|
|
84
|
+
let clients = clientList(true)
|
|
85
|
+
if (clients.length === 0) {
|
|
86
|
+
console.log("No connected clients")
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
console.log(`${clients.length} connected client(s):`)
|
|
90
|
+
for (let c of clients) {
|
|
91
|
+
console.log(` ${c.id}: ${c.address ?? "unknown"} [${c.platform}, ${c.version}]`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// "on" | "off" | "" (toggle) for the three switches; anything else is a
|
|
96
|
+
// usage error, reported with the command's usage line.
|
|
97
|
+
function toggle(args: string, current: boolean, usage: string): boolean | undefined {
|
|
98
|
+
if (args === "on") return true
|
|
99
|
+
if (args === "off") return false
|
|
100
|
+
if (!args) return !current
|
|
101
|
+
console.log(usage)
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function cmdStats(args: string) {
|
|
106
|
+
let on = toggle(args, state.stats, "Usage: stats [on|off]")
|
|
107
|
+
if (on === undefined) return
|
|
108
|
+
setStats(on)
|
|
109
|
+
console.log(`[cli] Stats overlay ${on ? "on" : "off"}`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function cmdWatch(args: string) {
|
|
113
|
+
let on = toggle(args, !state.watchPaused, "Usage: watch on|off")
|
|
114
|
+
if (on === undefined) return
|
|
115
|
+
setWatchActive(on)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cmdMute(args: string) {
|
|
119
|
+
let on = toggle(args, state.userInputMuted, "Usage: mute on|off")
|
|
120
|
+
if (on === undefined) return
|
|
121
|
+
setUserInputMuted(on)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The word and the rest of the line, trimmed.
|
|
125
|
+
function split(line: string): [string, string] {
|
|
126
|
+
let cmd = line.trim()
|
|
127
|
+
let i = cmd.indexOf(" ")
|
|
128
|
+
return i < 0 ? [cmd, ""] : [cmd.slice(0, i), cmd.slice(i + 1).trim()]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function dispatch(line: string, quit: () => void): Promise<void> {
|
|
132
|
+
let [cmd, args] = split(line)
|
|
133
|
+
switch (cmd) {
|
|
134
|
+
case "":
|
|
135
|
+
return
|
|
136
|
+
case "reload":
|
|
137
|
+
return cmdReload(args)
|
|
138
|
+
case "stop":
|
|
139
|
+
return cmdStop(args)
|
|
140
|
+
case "load":
|
|
141
|
+
return cmdLoad(args)
|
|
142
|
+
case "list":
|
|
143
|
+
return cmdList()
|
|
144
|
+
case "stats":
|
|
145
|
+
return cmdStats(args)
|
|
146
|
+
case "watch":
|
|
147
|
+
return cmdWatch(args)
|
|
148
|
+
case "mute":
|
|
149
|
+
return cmdMute(args)
|
|
150
|
+
case "quit":
|
|
151
|
+
case "exit":
|
|
152
|
+
return quit()
|
|
153
|
+
case "help":
|
|
154
|
+
console.log(HELP)
|
|
155
|
+
return
|
|
156
|
+
default:
|
|
157
|
+
console.log(`Unknown command: ${cmd}`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Tab completion: a command name, or for `load` a path relative to what
|
|
162
|
+
// loadEntry resolves against (the project dir, or the served file's dir):
|
|
163
|
+
// directories and app entries only.
|
|
164
|
+
async function complete(line: string): Promise<Completion> {
|
|
165
|
+
if (!line.startsWith("load ")) {
|
|
166
|
+
return { matches: COMMANDS.filter((c) => c.startsWith(line)), replace: line }
|
|
167
|
+
}
|
|
168
|
+
let partial = line.slice(5)
|
|
169
|
+
let slash = partial.lastIndexOf("/")
|
|
170
|
+
let dirPart = slash < 0 ? "" : partial.slice(0, slash + 1)
|
|
171
|
+
let prefix = partial.slice(dirPart.length)
|
|
172
|
+
let base = state.config.projectDir ?? state.config.sourceDir
|
|
173
|
+
let matches: string[] = []
|
|
174
|
+
try {
|
|
175
|
+
for (let entry of await dir(absolute(dirPart || ".", base)).entries()) {
|
|
176
|
+
if (!entry.name.startsWith(prefix)) continue
|
|
177
|
+
if (entry.type === "directory") matches.push(entry.name + "/")
|
|
178
|
+
else if (ENTRY_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) matches.push(entry.name)
|
|
179
|
+
}
|
|
180
|
+
} catch {}
|
|
181
|
+
return { matches, replace: prefix }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Start the repl when a terminal is attached; `quit` ends the server (the
|
|
185
|
+
// quit command, Ctrl-C, or stdin closing). Returns the function that
|
|
186
|
+
// detaches it, for the server's shutdown.
|
|
187
|
+
export function startRepl(quit: () => void): () => void {
|
|
188
|
+
if (!isTTY) {
|
|
189
|
+
console.log("[cli] No terminal on stdin, running without the repl")
|
|
190
|
+
return () => {}
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
return startLineEditor({ prompt: PROMPT, onLine: (line) => dispatch(line, quit), onQuit: quit, complete })
|
|
194
|
+
} catch (e) {
|
|
195
|
+
console.log(`[cli] ${String(e)}; using plain line input`)
|
|
196
|
+
return startLineInput(quit)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Cooked-mode fallback: the terminal edits the line, this side prompts.
|
|
201
|
+
// Commands run one at a time: a line typed while one runs waits for it, so
|
|
202
|
+
// its output and the next prompt keep their order. No prompt once the
|
|
203
|
+
// server is shutting down (quit, or a signal mid-command).
|
|
204
|
+
function startLineInput(quit: () => void): () => void {
|
|
205
|
+
let attached = true
|
|
206
|
+
let prompt = () => {
|
|
207
|
+
if (attached) write(PROMPT)
|
|
208
|
+
}
|
|
209
|
+
let chain = Promise.resolve()
|
|
210
|
+
let offLine = on("line", (line) => {
|
|
211
|
+
chain = chain.then(() => dispatch(line, quit)).then(prompt, (e) => {
|
|
212
|
+
console.error(`[cli] ${String(e)}`)
|
|
213
|
+
prompt()
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
let offClose = on("close", quit)
|
|
217
|
+
prompt()
|
|
218
|
+
return () => {
|
|
219
|
+
attached = false
|
|
220
|
+
offLine()
|
|
221
|
+
offClose()
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Server-side state shared by the route handlers: what the protocol needs,
|
|
2
|
+
// plus what main.ts resolved at startup (config.ts).
|
|
3
|
+
|
|
4
|
+
import type { ServerWebSocket } from "flux:http"
|
|
5
|
+
import type { ServerConfig } from "./config"
|
|
6
|
+
import type { ClientEntry } from "../types/control"
|
|
7
|
+
|
|
8
|
+
export let state = {
|
|
9
|
+
config: undefined as unknown as ServerConfig,
|
|
10
|
+
clients: new Map<ServerWebSocket, ClientEntry>(),
|
|
11
|
+
nextClientId: 0,
|
|
12
|
+
/**
|
|
13
|
+
* Identity of this server run, included in control responses that carry
|
|
14
|
+
* cross-call state (client ids, log seq cursors). Both reset on restart, so
|
|
15
|
+
* a consumer that sees the generation change knows its ids and cursors are
|
|
16
|
+
* from a dead server and must be re-fetched.
|
|
17
|
+
*/
|
|
18
|
+
generation: Date.now(),
|
|
19
|
+
/**
|
|
20
|
+
* The latched reload message (JSON text), replayed to late-joining clients.
|
|
21
|
+
* Set by a successful rebuild (and the build-failure trigger).
|
|
22
|
+
*/
|
|
23
|
+
currentReload: null as string | null,
|
|
24
|
+
/**
|
|
25
|
+
* The running bundle's sourcemaps (JSON text, bundle -> .tsx sources),
|
|
26
|
+
* keyed by the module name stack frames cite ("main" for the app, the
|
|
27
|
+
* isolate id for each isolate), used to remap stack traces in forwarded
|
|
28
|
+
* client logs (see control.ts). Replaced on every reload; a reload without
|
|
29
|
+
* maps clears them so frames are never remapped against a stale map.
|
|
30
|
+
*/
|
|
31
|
+
currentMaps: null as Record<string, string> | null,
|
|
32
|
+
/** The address clients reach this server on (host:port), set once bound. */
|
|
33
|
+
serverUrl: "",
|
|
34
|
+
stats: false,
|
|
35
|
+
/**
|
|
36
|
+
* Whether the user's own input is muted on every client, for an agent
|
|
37
|
+
* measuring or testing (the `mute` message; `welcome` carries it to a client
|
|
38
|
+
* joining while muted). Set by /mute.
|
|
39
|
+
*/
|
|
40
|
+
userInputMuted: false,
|
|
41
|
+
/**
|
|
42
|
+
* Whether reload-on-save is paused (the /watch endpoint, srt mcp's
|
|
43
|
+
* pause_watch), so an agent's half-finished saves are not pushed to the
|
|
44
|
+
* user's screens while it edits; its explicit reload still is.
|
|
45
|
+
*/
|
|
46
|
+
watchPaused: false,
|
|
47
|
+
// Capture events from all connected clients share one clock (captureStartMs,
|
|
48
|
+
// integer milliseconds) so they merge into one coherent timeline, tagged by
|
|
49
|
+
// `device`. Streamed to disk as JSON Lines - see main.ts's "capture" handling.
|
|
50
|
+
captureStartMs: 0,
|
|
51
|
+
captureLastAt: 0, // ms, same clock as captureStartMs
|
|
52
|
+
/** Serializes capture appends so events land on disk in arrival order. */
|
|
53
|
+
captureChain: Promise.resolve(),
|
|
54
|
+
}
|