@solidrt/cli 0.0.48 → 0.0.49
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 +19 -1
- package/package.json +7 -7
- package/scaffold/AGENTS.md +41 -3
- package/scaffold/package.json +4 -4
- package/server/main.ts +14 -8
- package/server/rebuild.ts +10 -2
- package/server/state.ts +1 -1
- package/server/tunnel.ts +5 -4
- package/src/args.ts +57 -22
- package/src/bundle-cli.ts +3 -2
- package/src/bundler.ts +127 -28
- package/src/commands/check.ts +6 -2
- package/src/commands/client.ts +16 -8
- package/src/commands/mcp.ts +105 -3
- package/src/commands/pack.ts +6 -2
- package/src/commands/server.ts +4 -1
- package/src/dev-dir.ts +25 -0
- package/src/dev-server.ts +46 -13
- package/src/pack-folder.ts +24 -3
- package/src/packer.ts +36 -3
- package/src/project.ts +9 -1
- package/src/repl.ts +14 -6
package/AGENTS.md
CHANGED
|
@@ -50,11 +50,29 @@ behavior in isolation.
|
|
|
50
50
|
(display scale is pinned to 1), so frames are identical on every machine.
|
|
51
51
|
- Run from the project directory. There is no `bunx --cwd` flag.
|
|
52
52
|
|
|
53
|
+
## Sessions (parallel dev servers on one machine)
|
|
54
|
+
|
|
55
|
+
- `-s <N>` / `--session <N>` (default 0) picks the dev server: port
|
|
56
|
+
`34884 + N`. Valid on `run`, `server`, `client`, `mcp`. `srt run -s1` is a
|
|
57
|
+
second, fully independent dev setup; `srt client -s1 -c2` attaches another
|
|
58
|
+
client to it.
|
|
59
|
+
- `-c <N>` / `--client <N>` picks the client data tree, defaulting to the
|
|
60
|
+
session number. (`--compile` gave its short to `--client`.)
|
|
61
|
+
- Dev state lives in `~/.solidrt/`: `servers/<port>/` holds each server's tunnel
|
|
62
|
+
key and `live.json` (the registry record MCP resolution reads, removed at
|
|
63
|
+
exit), `clients/client<M>/` the client trees (srt passes
|
|
64
|
+
`--data-root ~/.solidrt/clients` to every locally spawned client).
|
|
65
|
+
- A server run serves the project it was started in; `load` outside the
|
|
66
|
+
project root is refused. Restart the server in another project to switch.
|
|
67
|
+
- `srt mcp` needs no port: each tool call resolves the server serving the
|
|
68
|
+
project the bridge runs in (registry match + probe); `-s`/`--port` pin it.
|
|
69
|
+
|
|
53
70
|
## Dev server proxies (when clients on other devices need your machine's data)
|
|
54
71
|
|
|
55
72
|
- `--proxy-http` - route `fetch` through the dev server; responses cached in
|
|
56
|
-
`.srt-data/http-cache.db` (delete the file to clear).
|
|
73
|
+
`.srt-data/http-cache.db` in the project root (delete the file to clear).
|
|
57
74
|
|
|
58
75
|
## REPL (opened by `run`/`server`)
|
|
59
76
|
|
|
60
77
|
`load <file>`, `reload [n]`, `stop [n]`, `list`, `!<cmd>`, `quit`/`exit`.
|
|
78
|
+
`load` is bound to the project root the server was started in.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.49",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Antoine van Wel",
|
|
6
6
|
"type": "module",
|
|
@@ -22,22 +22,22 @@
|
|
|
22
22
|
"@jridgewell/remapping": "^2.3.0",
|
|
23
23
|
"@jridgewell/trace-mapping": "^0.3.25",
|
|
24
24
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
25
|
-
"babel-preset-solid": "2.0.0-
|
|
25
|
+
"babel-preset-solid": "2.0.0-rc.0",
|
|
26
26
|
"bonjour-service": "^1.4.0",
|
|
27
27
|
"qrcode-generator": "^2.0.4",
|
|
28
28
|
"zod": "^4.4.3"
|
|
29
29
|
},
|
|
30
30
|
"optionalDependencies": {
|
|
31
|
-
"@solidrt/darwin-arm64": "0.0.
|
|
32
|
-
"@solidrt/linux-arm64-gnu": "0.0.
|
|
33
|
-
"@solidrt/linux-x64-gnu": "0.0.
|
|
34
|
-
"@solidrt/win32-x64-msvc": "0.0.
|
|
31
|
+
"@solidrt/darwin-arm64": "0.0.49",
|
|
32
|
+
"@solidrt/linux-arm64-gnu": "0.0.49",
|
|
33
|
+
"@solidrt/linux-x64-gnu": "0.0.49",
|
|
34
|
+
"@solidrt/win32-x64-msvc": "0.0.49"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"typescript": "^7"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@solidrt/flux-types": "0.0.
|
|
40
|
+
"@solidrt/flux-types": "0.0.49",
|
|
41
41
|
"@types/babel__core": "^7.20.5",
|
|
42
42
|
"@types/bun": "latest"
|
|
43
43
|
}
|
package/scaffold/AGENTS.md
CHANGED
|
@@ -281,6 +281,40 @@ work stops being free" below is where it does not. Rules, in order of leverage:
|
|
|
281
281
|
to get_snapshot and every other MCP tool; `srt render` is the only way to
|
|
282
282
|
see it (Run / verify below).
|
|
283
283
|
|
|
284
|
+
### Isolates: heavy work off the JS thread
|
|
285
|
+
|
|
286
|
+
A long synchronous computation (a big parse, a simulation step, a blocking
|
|
287
|
+
`flux:ffi`/`flux:wasm` call) freezes rendering and input for its duration.
|
|
288
|
+
Move it into an isolate module: a file whose first statement is the
|
|
289
|
+
`"use isolate"` directive runs in a second runtime on its own thread, and
|
|
290
|
+
main calls its exports as async functions.
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
// src/worker.ts
|
|
294
|
+
"use isolate"
|
|
295
|
+
export function crunch(data: Uint8Array): number { /* ... */ }
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
// src/index.tsx
|
|
300
|
+
import { isolate } from "flux:isolate"
|
|
301
|
+
import type * as Worker from "./worker"
|
|
302
|
+
let worker = isolate<typeof Worker>("worker") // id = path from src/, no extension
|
|
303
|
+
let n = await worker.crunch(bytes) // main keeps rendering meanwhile
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The bundler builds each such module as its own bundle and ships it with the
|
|
307
|
+
app (dev pushes and `srt pack` alike). Rules: main may only `import type`
|
|
308
|
+
from an isolate module (a value import is a build error); arguments and
|
|
309
|
+
results are copies (numbers, strings, byte buffers, arrays, plain objects -
|
|
310
|
+
no functions, no class instances); the child has the non-gui `flux:*`
|
|
311
|
+
modules only, so it never touches the render tree; module state persists
|
|
312
|
+
between calls and each `isolate()` call is its own instance. An
|
|
313
|
+
`async function*` export is a stream: `for await (let p of worker.progress())`
|
|
314
|
+
pulls one item per step (progress, ticks, a subscription), `break` ends it in
|
|
315
|
+
the isolate, and streams never block plain calls. Full contract:
|
|
316
|
+
node_modules/@solidrt/flux-types/modules/isolate.d.ts.
|
|
317
|
+
|
|
284
318
|
### Where GPU work stops being free
|
|
285
319
|
|
|
286
320
|
"GPU work is nearly free" is a property of the hardware, not of the engine, and
|
|
@@ -346,7 +380,8 @@ up to the frame period, because work outside the frame call is not in them.
|
|
|
346
380
|
(future launcher/window naming) with no storage meaning.
|
|
347
381
|
- `bunx srt pack src/index.tsx` builds a single-file executable;
|
|
348
382
|
`bunx srt pack --folder src/index.tsx` writes the flat app folder
|
|
349
|
-
(runner + manifest.json + bundle + assets
|
|
383
|
+
(runner + manifest.json + bundle + assets/, plus the runner's GL
|
|
384
|
+
libraries on Windows and macOS) to `dist/`.
|
|
350
385
|
|
|
351
386
|
## Run / verify
|
|
352
387
|
|
|
@@ -374,8 +409,11 @@ its tools over guessing at runtime state:
|
|
|
374
409
|
|
|
375
410
|
- list_clients: connected app clients, their platform and runtime
|
|
376
411
|
capabilities, plus the server's `entry` (the app source it serves) and
|
|
377
|
-
`projectDir` - check entry matches the app you think you are driving
|
|
378
|
-
|
|
412
|
+
`projectDir` - check entry matches the app you think you are driving. Each
|
|
413
|
+
tool call finds the dev server currently serving this project (by its
|
|
414
|
+
project root), so a server restarted on another port/session is followed
|
|
415
|
+
automatically; no server serving this project is an error, not a wrong
|
|
416
|
+
server.
|
|
379
417
|
Each client also lists `queries`, the dev-tool query kinds its runtime
|
|
380
418
|
answers - check it before planning verification against a mixed-version
|
|
381
419
|
fleet (no "input" = the client predates send_input)
|
package/scaffold/package.json
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
"android": "srt client --android"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@solidrt/core": "0.0.
|
|
14
|
-
"@solidrt/components": "0.0.
|
|
13
|
+
"@solidrt/core": "0.0.49",
|
|
14
|
+
"@solidrt/components": "0.0.49"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
|
-
"@solidrt/cli": "0.0.
|
|
18
|
-
"@solidrt/flux-types": "0.0.
|
|
17
|
+
"@solidrt/cli": "0.0.49",
|
|
18
|
+
"@solidrt/flux-types": "0.0.49",
|
|
19
19
|
"typescript": "^7"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/server/main.ts
CHANGED
|
@@ -74,16 +74,16 @@ async function handleInternal(req: FluxRequest, server: Server, path: string): P
|
|
|
74
74
|
|
|
75
75
|
switch (path) {
|
|
76
76
|
case "/__internal__/reload": {
|
|
77
|
-
// { message, clients?, latch?, sourceDir?,
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// replaced on every reload
|
|
77
|
+
// { message, clients?, latch?, sourceDir?, map? }: send `message` (a
|
|
78
|
+
// full client-protocol message, built by srt) to the listed client ids,
|
|
79
|
+
// or to all when omitted. `latch` keeps it for late-joining clients
|
|
80
|
+
// (code reloads latch, one-shot bytecode loads do not); `sourceDir`
|
|
81
|
+
// moves the file-serving root (repl `load`; the project root - and with
|
|
82
|
+
// it the /assets/ root - is fixed for the life of the run); `map` is
|
|
83
|
+
// the bundle's sourcemap for log remapping, replaced on every reload
|
|
84
|
+
// (absent means none).
|
|
84
85
|
let body = await req.json()
|
|
85
86
|
if (typeof body.sourceDir === "string") state.sourceDir = body.sourceDir
|
|
86
|
-
if (typeof body.projectDir === "string") state.projectDir = body.projectDir
|
|
87
87
|
// Keep the rebuild entry in sync when `load` moves it, so a later MCP
|
|
88
88
|
// reload bundles the newly loaded file, not the launch-time one.
|
|
89
89
|
if (typeof body.entry === "string") state.config.entry = body.entry
|
|
@@ -211,6 +211,12 @@ serve({
|
|
|
211
211
|
if (path === "/assets" || path.startsWith("/assets/")) {
|
|
212
212
|
return handleFiles(req, path, state.projectDir)
|
|
213
213
|
}
|
|
214
|
+
// Isolate bundles are build outputs, not project files: srt (and the
|
|
215
|
+
// rebuild here) write them under .srt-data/isolates/, and the manifest
|
|
216
|
+
// lists them as isolates/<id>.js.
|
|
217
|
+
if (path.startsWith("/isolates/")) {
|
|
218
|
+
return handleFiles(req, path, config.cacheDir)
|
|
219
|
+
}
|
|
214
220
|
return handleFiles(req, path, state.sourceDir)
|
|
215
221
|
},
|
|
216
222
|
websocket: {
|
package/server/rebuild.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { command } from "flux:subprocess"
|
|
2
|
+
import { dir, file } from "flux:fs"
|
|
2
3
|
import { state } from "./state"
|
|
3
4
|
|
|
4
5
|
// Server-owned "rebuild and push": the single place the running app is rebuilt
|
|
@@ -45,13 +46,20 @@ export async function rebuildAndBroadcast(): Promise<string | null> {
|
|
|
45
46
|
return `Rebuild failed:\n${stderr.trim()}`
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
// bundle-cli writes one JSON object { code, map, manifest } to stdout.
|
|
49
|
-
let bundle: { code?: string; map?: string | null; manifest?: string }
|
|
49
|
+
// bundle-cli writes one JSON object { code, map, manifest, isolates } to stdout.
|
|
50
|
+
let bundle: { code?: string; map?: string | null; manifest?: string; isolates?: { id: string; code: string }[] }
|
|
50
51
|
try {
|
|
51
52
|
bundle = JSON.parse(typeof result.stdout === "string" ? result.stdout : "")
|
|
52
53
|
} catch {
|
|
53
54
|
return "Rebuild failed: unreadable bundler output"
|
|
54
55
|
}
|
|
56
|
+
// Isolate bundles are manifest assets clients fetch from our /isolates/
|
|
57
|
+
// route (served from cacheDir), so they must be on disk before the push.
|
|
58
|
+
for (let isolate of bundle.isolates ?? []) {
|
|
59
|
+
let path = `${config.cacheDir}/isolates/${isolate.id}.js`
|
|
60
|
+
await dir(path.slice(0, path.lastIndexOf("/"))).create()
|
|
61
|
+
await file(path).write(isolate.code)
|
|
62
|
+
}
|
|
55
63
|
state.currentMap = bundle.map ?? null
|
|
56
64
|
let text = JSON.stringify(buildReload(bundle.code ?? "", bundle.manifest))
|
|
57
65
|
state.currentReload = text
|
package/server/state.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type Config = {
|
|
|
29
29
|
cache: boolean
|
|
30
30
|
/** Directory holding the proxy cache db (the project-local .srt-data). */
|
|
31
31
|
cacheDir: string
|
|
32
|
-
/** Directory holding .
|
|
32
|
+
/** Directory holding tunnel.key (the server's ~/.solidrt/servers/<port>/ folder). */
|
|
33
33
|
keyDir: string
|
|
34
34
|
/** Destination for captured key events, or unset when off. */
|
|
35
35
|
capture?: string
|
package/server/tunnel.ts
CHANGED
|
@@ -15,15 +15,16 @@ import { printQr } from "./qr"
|
|
|
15
15
|
// the handshake instead of desyncing.
|
|
16
16
|
export const TUNNEL_PROTOCOL = "solidrt-dev/0"
|
|
17
17
|
|
|
18
|
-
// The persisted identity file,
|
|
19
|
-
// tunnel
|
|
20
|
-
|
|
18
|
+
// The persisted identity file, in the server's ~/.solidrt/servers/<port>/ folder
|
|
19
|
+
// (keyDir): the tunnel identity belongs to the server, not to any project it
|
|
20
|
+
// serves. Delete it to rotate the identity (which invalidates any old ticket).
|
|
21
|
+
const KEY_FILE = "tunnel.key"
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Bind the tunnel endpoint and print its ticket (text + QR). The endpoint is
|
|
24
25
|
* kept stable across restarts so a paired client can re-dial the old ticket
|
|
25
26
|
* without re-scanning: the UDP port is pinned to the dev server's port, and the
|
|
26
|
-
* secret key is persisted in <keyDir
|
|
27
|
+
* secret key is persisted in <keyDir>/tunnel.key (generated on first
|
|
27
28
|
* run). Both are needed - a moving port or a fresh key each start would change
|
|
28
29
|
* the ticket. Stable across restarts on the same network only; a new machine IP
|
|
29
30
|
* still stales the ticket's addresses (that is the discovery/off-LAN story).
|
package/src/args.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from "node:util"
|
|
2
2
|
import { resolve } from "node:path"
|
|
3
|
+
import { clientsRoot } from "./dev-dir"
|
|
3
4
|
|
|
4
5
|
// Everything after a bare "--" is the app's argument vector, kept out of
|
|
5
6
|
// parseArgs (which would fold it into positionals) and forwarded verbatim to
|
|
@@ -14,8 +15,9 @@ export let { values, positionals } = parseArgs({
|
|
|
14
15
|
options: {
|
|
15
16
|
dev: { type: "boolean", short: "d", default: false },
|
|
16
17
|
minify: { type: "boolean", short: "m", default: false },
|
|
17
|
-
compile: { type: "boolean",
|
|
18
|
+
compile: { type: "boolean", default: false },
|
|
18
19
|
flux: { type: "boolean", short: "f", default: false },
|
|
20
|
+
session: { type: "string", short: "s" },
|
|
19
21
|
folder: { type: "boolean", default: false },
|
|
20
22
|
stdout: { type: "boolean", default: false },
|
|
21
23
|
output: { type: "string", short: "o" },
|
|
@@ -28,7 +30,7 @@ export let { values, positionals } = parseArgs({
|
|
|
28
30
|
tunnel: { type: "boolean", default: false },
|
|
29
31
|
stats: { type: "boolean", default: false },
|
|
30
32
|
"data-root": { type: "string" },
|
|
31
|
-
client: { type: "string" },
|
|
33
|
+
client: { type: "string", short: "c" },
|
|
32
34
|
server: { type: "string" },
|
|
33
35
|
port: { type: "string" },
|
|
34
36
|
android: { type: "boolean", default: false },
|
|
@@ -38,23 +40,41 @@ export let { values, positionals } = parseArgs({
|
|
|
38
40
|
allowPositionals: true,
|
|
39
41
|
})
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
43
|
+
export const DEFAULT_DEV_PORT = 0x8844
|
|
44
|
+
|
|
45
|
+
// The session number: -s/--session <N> selects the dev server (port
|
|
46
|
+
// DEFAULT_DEV_PORT + N, see dev-server.ts resolveDevPort) and the default
|
|
47
|
+
// client slot. Resolved at load, like the port.
|
|
48
|
+
function resolveSession(): number {
|
|
49
|
+
let raw = values.session
|
|
50
|
+
if (raw === undefined) return 0
|
|
51
|
+
let n = Number(raw)
|
|
52
|
+
if (!/^\d+$/.test(raw) || DEFAULT_DEV_PORT + n > 65535) {
|
|
53
|
+
console.error(
|
|
54
|
+
`Invalid --session value "${raw}": expected a non-negative integer with ${DEFAULT_DEV_PORT} + N at most 65535`,
|
|
55
|
+
)
|
|
56
|
+
process.exit(1)
|
|
57
|
+
}
|
|
58
|
+
return n
|
|
59
|
+
}
|
|
60
|
+
export let session = resolveSession()
|
|
61
|
+
|
|
62
|
+
// Storage flags for a locally spawned client. Dev client trees live in
|
|
63
|
+
// ~/.solidrt/clients/client<M>/, reached through --data-root so the client
|
|
64
|
+
// runtime keeps its single pref-path default rule (see lattice/src/storage.rs
|
|
65
|
+
// and okf/backlog/parallel-dev-servers.md); an explicit --data-root wins. The
|
|
66
|
+
// client slot defaults to the session number, so each session gets its own
|
|
67
|
+
// tree with a single flag. Roots are passed absolute because the client
|
|
68
|
+
// chdirs into its app sandbox at startup.
|
|
46
69
|
export function clientStorageArgs(): string[] {
|
|
47
|
-
let args: string[] = []
|
|
48
70
|
let root = values["data-root"]
|
|
49
|
-
|
|
50
|
-
let client = values.client
|
|
51
|
-
if (client
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
process.exit(1)
|
|
55
|
-
}
|
|
56
|
-
args.push("--client", client)
|
|
71
|
+
let args = ["--data-root", root ? resolve(root) : clientsRoot()]
|
|
72
|
+
let client = values.client ?? String(session)
|
|
73
|
+
if (!/^\d+$/.test(client)) {
|
|
74
|
+
console.error(`Invalid --client value "${client}": expected a non-negative integer`)
|
|
75
|
+
process.exit(1)
|
|
57
76
|
}
|
|
77
|
+
args.push("--client", client)
|
|
58
78
|
return args
|
|
59
79
|
}
|
|
60
80
|
|
|
@@ -111,6 +131,17 @@ export function validateArgs() {
|
|
|
111
131
|
if (values.port !== undefined && command !== "run" && command !== "server" && command !== "mcp") {
|
|
112
132
|
usage("srt <run|server|mcp> --port <N> (--port is only valid with the run, server and mcp commands)")
|
|
113
133
|
}
|
|
134
|
+
// --session selects a dev server (and the default client slot), so it is
|
|
135
|
+
// valid wherever a dev server is started, attached to, or resolved.
|
|
136
|
+
if (
|
|
137
|
+
values.session !== undefined &&
|
|
138
|
+
command !== "run" &&
|
|
139
|
+
command !== "server" &&
|
|
140
|
+
command !== "client" &&
|
|
141
|
+
command !== "mcp"
|
|
142
|
+
) {
|
|
143
|
+
usage("srt <run|server|client|mcp> -s <N> (--session is only valid with the run, server, client and mcp commands)")
|
|
144
|
+
}
|
|
114
145
|
}
|
|
115
146
|
|
|
116
147
|
export function printUsage() {
|
|
@@ -131,7 +162,8 @@ init options:
|
|
|
131
162
|
-t, --template <name> Start from a named template (skips the interactive picker)
|
|
132
163
|
|
|
133
164
|
run/server options:
|
|
134
|
-
|
|
165
|
+
-s, --session <N> Session number: dev server on port 34884+N, client slot N (default: 0)
|
|
166
|
+
--port <N> Dev server port (default: 34884 + session)
|
|
135
167
|
--proxy-http Route fetch calls through the dev server (HTTP cache enabled)
|
|
136
168
|
--capture <file> Record connected clients' key events to a script file
|
|
137
169
|
--tunnel Accept ticket-paired clients through the p2p tunnel
|
|
@@ -140,22 +172,25 @@ run/server options:
|
|
|
140
172
|
run/client options:
|
|
141
173
|
--size <WxH> Window size (default: 1280x720)
|
|
142
174
|
--stats Show the debug stats overlay (FPS, memory, frame timings)
|
|
143
|
-
--data-root <dir> Client data root (default:
|
|
144
|
-
|
|
175
|
+
--data-root <dir> Client data root (default: ~/.solidrt/clients)
|
|
176
|
+
-c, --client <N> Client number: its own data tree under the data root (default: the session)
|
|
145
177
|
|
|
146
178
|
client options:
|
|
147
|
-
|
|
179
|
+
-s, --session <N> Connect to this session's dev server on this machine
|
|
180
|
+
(127.0.0.1:34884+N); without it, start on the connect screen
|
|
181
|
+
--server <host[:port]> Connect to a dev server at this address (default port: 34884 + session)
|
|
148
182
|
--android Install and launch the client on a connected Android device
|
|
149
183
|
--device <serial> Target a specific adb device by serial or unique prefix
|
|
150
184
|
|
|
151
185
|
mcp options:
|
|
152
|
-
|
|
186
|
+
-s, --session <N> Attach to the dev server of this session (default: resolve by project)
|
|
187
|
+
--port <N> Port of the dev server to attach to (default: resolve by project)
|
|
153
188
|
|
|
154
189
|
bundle options:
|
|
155
190
|
-f, --flux Bundle for the bare Flux runtime, without SolidJS (entry must be .ts|.js)
|
|
156
191
|
-d, --dev Use development build of SolidJS (default: production)
|
|
157
192
|
-m, --minify Minify the output
|
|
158
|
-
|
|
193
|
+
--compile Compile to bytecode
|
|
159
194
|
-o, --output <name> Output filename
|
|
160
195
|
--stdout Write bundle to stdout
|
|
161
196
|
|
package/src/bundle-cli.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Standalone bundler entry, spawned by the dev server (a flux process) as a
|
|
2
2
|
// Bun subprocess to rebuild the app on an MCP-triggered reload. flux cannot call
|
|
3
3
|
// Bun.build, so the server shells out to this. Params arrive as one JSON
|
|
4
|
-
// argument; one JSON object { code, map } goes to stdout
|
|
5
|
-
// stderr. On a build failure it exits non-zero with an
|
|
4
|
+
// argument; one JSON object { code, map, manifest, isolates } goes to stdout
|
|
5
|
+
// and diagnostics to stderr. On a build failure it exits non-zero with an
|
|
6
|
+
// empty stdout.
|
|
6
7
|
|
|
7
8
|
import { bundleWith, type BundleOptions } from "./bundler"
|
|
8
9
|
|
package/src/bundler.ts
CHANGED
|
@@ -4,11 +4,11 @@ import ts from "@babel/preset-typescript"
|
|
|
4
4
|
import remapping from "@jridgewell/remapping"
|
|
5
5
|
import solid from "babel-preset-solid"
|
|
6
6
|
import { type BunPlugin, type BuildArtifact } from "bun"
|
|
7
|
-
import { readFileSync } from "node:fs"
|
|
8
|
-
import { dirname, resolve as resolvePath } from "node:path"
|
|
7
|
+
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
|
|
8
|
+
import { dirname, join, relative, resolve as resolvePath, sep } from "node:path"
|
|
9
9
|
import { values, source } from "./args"
|
|
10
10
|
import { state, print, requireBinary } from "./util"
|
|
11
|
-
import { buildManifest } from "./project"
|
|
11
|
+
import { buildManifest, manifestAssetFor } from "./project"
|
|
12
12
|
|
|
13
13
|
// Babel plugin: rewrite `import data from "./x" with { type: "binary" }` into an
|
|
14
14
|
// inline Uint8Array of the file's bytes, and `with { type: "text" }` into an
|
|
@@ -82,7 +82,10 @@ async function codeFromOutputs(outputs: BuildArtifact[]): Promise<string> {
|
|
|
82
82
|
// (node_modules) skips the babel detour and keeps Bun's native loaders.
|
|
83
83
|
// With `babelMaps`, each file's transform map (original -> babel output) is
|
|
84
84
|
// collected there, keyed by absolute path, for sourcemap composition later.
|
|
85
|
-
|
|
85
|
+
// `isolateEntry` is the one "use isolate" module this build may load (its own
|
|
86
|
+
// entry); loading any other one means a by-value import of an isolate module,
|
|
87
|
+
// which is a build error (see isolate modules below).
|
|
88
|
+
function solidPlugin(babelMaps?: Map<string, object>, isolateEntry?: string): BunPlugin {
|
|
86
89
|
return {
|
|
87
90
|
name: "bun-plugin-solid",
|
|
88
91
|
setup: (build) => {
|
|
@@ -90,6 +93,11 @@ function solidPlugin(babelMaps?: Map<string, object>): BunPlugin {
|
|
|
90
93
|
if (!/\.(js|ts)x$/.test(args.path) && args.path.includes("node_modules")) return
|
|
91
94
|
let file = Bun.file(args.path)
|
|
92
95
|
let code = await file.text()
|
|
96
|
+
if (args.path !== isolateEntry && hasIsolateDirective(code)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`${args.path} is a "use isolate" module: import its types only (import type * as W from "./...") and call it through isolate() from flux:isolate`,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
93
101
|
let transforms = await transformAsync(code, {
|
|
94
102
|
filename: args.path,
|
|
95
103
|
sourceMaps: !!babelMaps,
|
|
@@ -103,6 +111,53 @@ function solidPlugin(babelMaps?: Map<string, object>): BunPlugin {
|
|
|
103
111
|
}
|
|
104
112
|
}
|
|
105
113
|
|
|
114
|
+
// Isolate modules (okf/done/isolates-and-ports.md): a source file whose first
|
|
115
|
+
// statement is the "use isolate" directive is the entry of its own bundle,
|
|
116
|
+
// run by flux:isolate in a second runtime. Its id is its path relative to
|
|
117
|
+
// the source root (the entry's directory) without extension; the bundle
|
|
118
|
+
// travels as the manifest asset isolates/<id>.js (dev) or .bin (pack). The
|
|
119
|
+
// main build never loads such a module (only `import type` reaches it), so
|
|
120
|
+
// the set is found by scanning the tree rather than by following imports.
|
|
121
|
+
|
|
122
|
+
// The directive is the first statement: leading whitespace, comments and a
|
|
123
|
+
// shebang may precede it, nothing else.
|
|
124
|
+
let ISOLATE_DIRECTIVE = /^(?:#![^\n]*\n)?(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*(?:"use isolate"|'use isolate')\s*(?:;|\n|$)/
|
|
125
|
+
|
|
126
|
+
export function hasIsolateDirective(code: string): boolean {
|
|
127
|
+
return ISOLATE_DIRECTIVE.test(code)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let SKIP_DIRS = new Set(["node_modules", "dist"])
|
|
131
|
+
|
|
132
|
+
export type IsolateModule = { id: string; path: string }
|
|
133
|
+
|
|
134
|
+
/** Every "use isolate" module under `root`, in id order. */
|
|
135
|
+
export function findIsolateModules(root: string): IsolateModule[] {
|
|
136
|
+
let out: IsolateModule[] = []
|
|
137
|
+
let walk = (dir: string) => {
|
|
138
|
+
for (let entry of readdirSync(dir, { withFileTypes: true })) {
|
|
139
|
+
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue
|
|
140
|
+
let abs = join(dir, entry.name)
|
|
141
|
+
if (entry.isDirectory()) {
|
|
142
|
+
walk(abs)
|
|
143
|
+
} else if (entry.isFile() && /\.(js|ts)x?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
|
|
144
|
+
if (hasIsolateDirective(readFileSync(abs, "utf8"))) {
|
|
145
|
+
let id = relative(root, abs).split(sep).join("/").replace(/\.(js|ts)x?$/, "")
|
|
146
|
+
out.push({ id, path: abs })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
walk(root)
|
|
152
|
+
out.sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
153
|
+
return out
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The manifest asset path of an isolate bundle. */
|
|
157
|
+
export function isolateAssetPath(id: string, ext: "js" | "bin"): string {
|
|
158
|
+
return `isolates/${id}.${ext}`
|
|
159
|
+
}
|
|
160
|
+
|
|
106
161
|
export type BundleOptions = { entry: string; devBase?: string; dev: boolean; minify: boolean }
|
|
107
162
|
|
|
108
163
|
export type BundleResult = {
|
|
@@ -111,6 +166,8 @@ export type BundleResult = {
|
|
|
111
166
|
map: string | null
|
|
112
167
|
/** Version manifest JSON for this bundle; clients install pushes under its hash. */
|
|
113
168
|
manifest: string
|
|
169
|
+
/** The app's isolate bundles, one per "use isolate" module, in id order. */
|
|
170
|
+
isolates: { id: string; code: string }[]
|
|
114
171
|
}
|
|
115
172
|
|
|
116
173
|
// The pure bundle: every input is explicit, so it runs identically in the srt
|
|
@@ -130,32 +187,66 @@ export async function bundleWith(opts: BundleOptions): Promise<BundleResult | nu
|
|
|
130
187
|
}
|
|
131
188
|
if (opts.devBase) define.__SRT_DEV_BASE__ = opts.devBase
|
|
132
189
|
|
|
190
|
+
// One Bun.build per entry: the app, then each isolate module as its own
|
|
191
|
+
// self-contained bundle (splitting is off, so a helper both import gets
|
|
192
|
+
// duplicated rather than shared). Only the app's build gets a composed
|
|
193
|
+
// sourcemap for now.
|
|
194
|
+
let build = async (entry: string, babelMaps?: Map<string, object>, isolateEntry?: string) => {
|
|
195
|
+
let result = null
|
|
196
|
+
try {
|
|
197
|
+
result = await Bun.build({
|
|
198
|
+
entrypoints: [entry],
|
|
199
|
+
target: "browser",
|
|
200
|
+
format: "esm",
|
|
201
|
+
minify: opts.minify,
|
|
202
|
+
external: ["flux:*", "srt:*"],
|
|
203
|
+
define,
|
|
204
|
+
loader: { ".svg": "text" },
|
|
205
|
+
sourcemap: babelMaps ? "external" : "none",
|
|
206
|
+
plugins: [solidPlugin(babelMaps, isolateEntry)],
|
|
207
|
+
})
|
|
208
|
+
} catch (e) {
|
|
209
|
+
console.error("[cli] compile error:\n", e)
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
if (!result.success) {
|
|
213
|
+
for (let msg of result.logs) console.error(msg)
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
return result
|
|
217
|
+
}
|
|
218
|
+
|
|
133
219
|
let babelMaps = opts.dev ? new Map<string, object>() : undefined
|
|
134
|
-
let
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
loader: { ".svg": "text" },
|
|
144
|
-
sourcemap: opts.dev ? "external" : "none",
|
|
145
|
-
plugins: [solidPlugin(babelMaps)],
|
|
146
|
-
})
|
|
147
|
-
} catch (e) {
|
|
148
|
-
console.error("[cli] compile error:\n", e)
|
|
149
|
-
return null
|
|
220
|
+
let main = await build(opts.entry, babelMaps)
|
|
221
|
+
if (!main) return null
|
|
222
|
+
let code = await codeFromOutputs(main.outputs)
|
|
223
|
+
|
|
224
|
+
let isolates: { id: string; code: string }[] = []
|
|
225
|
+
for (let module of findIsolateModules(dirname(resolvePath(opts.entry)))) {
|
|
226
|
+
let result = await build(module.path, undefined, module.path)
|
|
227
|
+
if (!result) return null
|
|
228
|
+
isolates.push({ id: module.id, code: await codeFromOutputs(result.outputs) })
|
|
150
229
|
}
|
|
151
230
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
231
|
+
let extra = isolates.map((i) => manifestAssetFor(isolateAssetPath(i.id, "js"), Buffer.from(i.code, "utf8")))
|
|
232
|
+
return {
|
|
233
|
+
code,
|
|
234
|
+
map: await composeMap(main.outputs, babelMaps),
|
|
235
|
+
manifest: buildManifest(code, opts.entry, extra),
|
|
236
|
+
isolates,
|
|
155
237
|
}
|
|
238
|
+
}
|
|
156
239
|
|
|
157
|
-
|
|
158
|
-
|
|
240
|
+
// Write dev isolate bundles where the dev server serves /isolates/ from
|
|
241
|
+
// (<project>/.srt-data/isolates/<id>.js), so clients can fetch the manifest
|
|
242
|
+
// assets the bundle lists. Stale files from removed modules stay behind
|
|
243
|
+
// unlisted, which is harmless.
|
|
244
|
+
export function writeIsolates(dir: string, isolates: { id: string; code: string }[]) {
|
|
245
|
+
for (let i of isolates) {
|
|
246
|
+
let file = join(dir, `${i.id}.js`)
|
|
247
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
248
|
+
writeFileSync(file, i.code)
|
|
249
|
+
}
|
|
159
250
|
}
|
|
160
251
|
|
|
161
252
|
// Compose Bun's bundle map (babel output -> bundle) with the per-file Babel
|
|
@@ -185,7 +276,15 @@ export async function bundle(entry = source) {
|
|
|
185
276
|
let dev = !!devBase || values.dev
|
|
186
277
|
// Keep stdout clean when the bundle itself is written to stdout.
|
|
187
278
|
if (!values.stdout) print(`[cli] Bundling (${dev ? "development" : "production"})`)
|
|
188
|
-
|
|
279
|
+
let result = await bundleWith({ entry: entry!, devBase, dev, minify: values.minify })
|
|
280
|
+
// With a server running, its /isolates/ route serves what we write here.
|
|
281
|
+
if (result && devBase) writeIsolates(devIsolatesDir(state.projectDir), result.isolates)
|
|
282
|
+
return result
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Where a project's dev isolate bundles are written and served from. */
|
|
286
|
+
export function devIsolatesDir(projectDir: string): string {
|
|
287
|
+
return join(projectDir, ".srt-data", "isolates")
|
|
189
288
|
}
|
|
190
289
|
|
|
191
290
|
export async function bundleTo(outfile: string) {
|
|
@@ -216,13 +315,13 @@ export async function bundleFlux(entry: string): Promise<string> {
|
|
|
216
315
|
}
|
|
217
316
|
|
|
218
317
|
// Bundle for the SolidRT runtime via the standard Solid-aware bundler.
|
|
219
|
-
export async function bundleSolid(): Promise<
|
|
318
|
+
export async function bundleSolid(): Promise<BundleResult> {
|
|
220
319
|
let result = await bundle()
|
|
221
320
|
if (!result) {
|
|
222
321
|
console.error("Build failed")
|
|
223
322
|
process.exit(1)
|
|
224
323
|
}
|
|
225
|
-
return result
|
|
324
|
+
return result
|
|
226
325
|
}
|
|
227
326
|
|
|
228
327
|
// Compile JS source to QuickJS bytecode via the fluxc binary.
|
package/src/commands/check.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, rmSync } from "node:fs"
|
|
2
2
|
import { dirname, join, resolve } from "node:path"
|
|
3
3
|
import { source } from "../args"
|
|
4
|
-
import { bundleWith } from "../bundler"
|
|
4
|
+
import { bundleWith, findIsolateModules } from "../bundler"
|
|
5
5
|
|
|
6
6
|
// srt check: verify the app without side effects. Bundles in memory (nothing
|
|
7
7
|
// written, so no dev-server reload fires and no build outputs land in the
|
|
@@ -86,7 +86,11 @@ export async function typecheck(root: string, entry: string): Promise<{ app: Dia
|
|
|
86
86
|
// applies precisely because nothing imports it, so entry-only rooting would
|
|
87
87
|
// silently drop it and every asset import would fail with TS2307. The
|
|
88
88
|
// pattern is relative to this config, which sits one level under the root.
|
|
89
|
-
|
|
89
|
+
// Isolate modules are program roots of their own: main reaches them by
|
|
90
|
+
// `import type` at most, and one nothing imports would otherwise go
|
|
91
|
+
// unchecked.
|
|
92
|
+
let files = [resolve(entry), ...findIsolateModules(dirname(resolve(entry))).map((m) => m.path)]
|
|
93
|
+
await Bun.write(config, JSON.stringify({ extends: tsconfig, include: ["../**/*.d.ts"], files }))
|
|
90
94
|
try {
|
|
91
95
|
let proc = Bun.spawn([tsc, "-p", config, "--noEmit", "--pretty", "false"], {
|
|
92
96
|
cwd: root,
|
package/src/commands/client.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { values, clientStorageArgs } from "../args"
|
|
2
2
|
import { requireBinary, run } from "../util"
|
|
3
3
|
import { spawnAndroidClient } from "../dev-android"
|
|
4
|
-
import { DEV_PORT } from "../dev-server"
|
|
4
|
+
import { DEV_HOST, DEV_PORT } from "../dev-server"
|
|
5
5
|
|
|
6
6
|
// Standalone solidrt-go client (no dev server). The `run` command instead uses
|
|
7
7
|
// spawnClient() to launch a client tied to the dev-server lifecycle. --server
|
|
8
|
-
// auto-connects to a dev server at the given address
|
|
9
|
-
//
|
|
10
|
-
// on
|
|
8
|
+
// auto-connects to a dev server at the given address, and -s <N> is its
|
|
9
|
+
// shorthand for the session's server on this machine; without either, the
|
|
10
|
+
// client starts on the connect screen. With --android it is installed and
|
|
11
|
+
// launched on a connected Android device instead of run locally.
|
|
11
12
|
export async function runClientCommand() {
|
|
12
13
|
if (values.android) {
|
|
13
14
|
await spawnAndroidClient()
|
|
@@ -17,10 +18,17 @@ export async function runClientCommand() {
|
|
|
17
18
|
let runner = requireBinary("solidrt-go")
|
|
18
19
|
let args: string[] = [...clientStorageArgs()]
|
|
19
20
|
if (values.size) args.push("--size", values.size)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
// Both flags resolve to the one address the client understands. --server
|
|
22
|
+
// wins: an explicit host is never overridden by a session number, which
|
|
23
|
+
// only ever names a loopback port.
|
|
24
|
+
let address = values.server
|
|
25
|
+
? values.server.includes(":")
|
|
26
|
+
? values.server
|
|
27
|
+
: `${values.server}:${DEV_PORT}`
|
|
28
|
+
: values.session !== undefined
|
|
29
|
+
? `${DEV_HOST}:${DEV_PORT}`
|
|
30
|
+
: null
|
|
31
|
+
if (address) args.push("--dev-server", address)
|
|
24
32
|
let exit = await run(runner, args)
|
|
25
33
|
process.exit(exit)
|
|
26
34
|
}
|
package/src/commands/mcp.ts
CHANGED
|
@@ -9,14 +9,116 @@ import { z } from "zod"
|
|
|
9
9
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|
10
10
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
11
11
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
|
12
|
-
import { resolve } from "node:path"
|
|
12
|
+
import { dirname, join, resolve } from "node:path"
|
|
13
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
14
|
+
import { values } from "../args"
|
|
13
15
|
import { DEV_PORT } from "../dev-server"
|
|
16
|
+
import { devDir } from "../dev-dir"
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
// An explicit -s/--port pins the port for the bridge's lifetime. Otherwise
|
|
19
|
+
// the port is resolved per tool call from the server registry, so one bridge
|
|
20
|
+
// (started when the workspace opens, kept alive across server restarts)
|
|
21
|
+
// follows whichever server is currently serving this project - and the
|
|
22
|
+
// scaffold's mcp.json never carries a port.
|
|
23
|
+
const FIXED_PORT = values.port !== undefined || values.session !== undefined ? DEV_PORT : null
|
|
24
|
+
|
|
25
|
+
// The projectDir the bridge is working in: the nearest package.json above its
|
|
26
|
+
// own cwd, the same rule srt applies to an entry (project.ts projectDirFor),
|
|
27
|
+
// so both sides derive the same string.
|
|
28
|
+
function findProjectDir(): string | null {
|
|
29
|
+
let dir = process.cwd()
|
|
30
|
+
while (true) {
|
|
31
|
+
if (existsSync(join(dir, "package.json"))) return dir
|
|
32
|
+
let parent = dirname(dir)
|
|
33
|
+
if (parent === dir) return null
|
|
34
|
+
dir = parent
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pidAlive(pid: number): boolean {
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, 0)
|
|
41
|
+
return true
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type LiveRecord = { pid: number; port: number; projectDir: string }
|
|
48
|
+
|
|
49
|
+
// The global server registry: every running dev server keeps a live.json in
|
|
50
|
+
// ~/.solidrt/servers/<port>/ (see dev-server.ts writeLiveRecord). Unreadable or
|
|
51
|
+
// malformed records are skipped, not fatal - the registry is a hint.
|
|
52
|
+
function liveRecords(): LiveRecord[] {
|
|
53
|
+
let root = devDir("servers")
|
|
54
|
+
let names: string[]
|
|
55
|
+
try {
|
|
56
|
+
names = readdirSync(root)
|
|
57
|
+
} catch {
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
let records: LiveRecord[] = []
|
|
61
|
+
for (let name of names) {
|
|
62
|
+
try {
|
|
63
|
+
let record = JSON.parse(readFileSync(join(root, name, "live.json"), "utf8"))
|
|
64
|
+
if (typeof record?.pid === "number" && typeof record?.port === "number" && typeof record?.projectDir === "string") {
|
|
65
|
+
records.push(record)
|
|
66
|
+
}
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
return records
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type PortResult = { ok: true; port: number } | { ok: false; message: string }
|
|
73
|
+
|
|
74
|
+
async function resolvePort(): Promise<PortResult> {
|
|
75
|
+
if (FIXED_PORT !== null) return { ok: true, port: FIXED_PORT }
|
|
76
|
+
let project = findProjectDir()
|
|
77
|
+
if (!project) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
message: `No package.json found above ${process.cwd()}, so no dev server can be resolved by project. Pass -s <N> or --port <N> to srt mcp.`,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
let matches = liveRecords().filter((r) => r.projectDir === project && pidAlive(r.pid))
|
|
84
|
+
if (matches.length > 1) {
|
|
85
|
+
let ports = matches
|
|
86
|
+
.map((r) => r.port)
|
|
87
|
+
.sort((a, b) => a - b)
|
|
88
|
+
.join(", ")
|
|
89
|
+
return { ok: false, message: `${matches.length} dev servers are serving this project (ports ${ports}); pass -s <N> to srt mcp` }
|
|
90
|
+
}
|
|
91
|
+
if (matches.length === 0) {
|
|
92
|
+
return { ok: false, message: `No dev server for ${project}. Start one with srt run, or pass -s <N> to srt mcp.` }
|
|
93
|
+
}
|
|
94
|
+
let port = matches[0]!.port
|
|
95
|
+
// The record is a hint; the server is authoritative. The probe catches a
|
|
96
|
+
// stale record whose pid was reused by an unrelated process.
|
|
97
|
+
try {
|
|
98
|
+
let probe = await fetch(`http://127.0.0.1:${port}/__control__/clients`)
|
|
99
|
+
let body: any = await probe.json().catch(() => null)
|
|
100
|
+
if (!probe.ok || body?.projectDir !== project) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
message: `The server on port ${port} is not serving ${project}${
|
|
104
|
+
typeof body?.projectDir === "string" ? ` (it serves ${body.projectDir})` : ""
|
|
105
|
+
}. Start one with srt run, or pass -s <N> to srt mcp.`,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
message: `No dev server for ${project}: the registry lists port ${port} but nothing answers there. Start one with srt run.`,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, port }
|
|
115
|
+
}
|
|
16
116
|
|
|
17
117
|
type ControlResult = { ok: true; body: any } | { ok: false; message: string }
|
|
18
118
|
|
|
19
119
|
async function control(path: string, method: "GET" | "POST" = "GET", payload?: unknown): Promise<ControlResult> {
|
|
120
|
+
let resolved = await resolvePort()
|
|
121
|
+
if (!resolved.ok) return resolved
|
|
20
122
|
let resp
|
|
21
123
|
try {
|
|
22
124
|
let init: RequestInit = { method }
|
|
@@ -24,7 +126,7 @@ async function control(path: string, method: "GET" | "POST" = "GET", payload?: u
|
|
|
24
126
|
init.headers = { "content-type": "application/json" }
|
|
25
127
|
init.body = JSON.stringify(payload)
|
|
26
128
|
}
|
|
27
|
-
resp = await fetch(
|
|
129
|
+
resp = await fetch(`http://127.0.0.1:${resolved.port}/__control__${path}`, init)
|
|
28
130
|
} catch {
|
|
29
131
|
return {
|
|
30
132
|
ok: false,
|
package/src/commands/pack.ts
CHANGED
|
@@ -41,8 +41,12 @@ export async function runPackCommand() {
|
|
|
41
41
|
let fonts = resolvePackFonts(source!)
|
|
42
42
|
console.log(`>> fonts: ${fonts.length ? fonts.map((f) => f.alias).join(", ") : "none"}`)
|
|
43
43
|
|
|
44
|
-
let
|
|
45
|
-
let
|
|
44
|
+
let bundled = await bundleSolid()
|
|
45
|
+
let bytecode = await compileToBytecode(bundled.code)
|
|
46
|
+
let isolates = []
|
|
47
|
+
for (let i of bundled.isolates) isolates.push({ id: i.id, bytecode: await compileToBytecode(i.code) })
|
|
48
|
+
if (isolates.length) console.log(`>> isolates: ${isolates.map((i) => i.id).join(", ")}`)
|
|
49
|
+
let folder = buildPackFolder(source!, bytecode, isolates)
|
|
46
50
|
|
|
47
51
|
if (values.folder) {
|
|
48
52
|
let outDir = values.output ?? "dist"
|
package/src/commands/server.ts
CHANGED
|
@@ -16,7 +16,10 @@ export async function runServerCommand() {
|
|
|
16
16
|
// Initialize state from args
|
|
17
17
|
state.source = source
|
|
18
18
|
state.sourceDir = source ? dirname(resolve(source)) : process.cwd()
|
|
19
|
-
|
|
19
|
+
// With no entry the project is wherever srt was started: walk up to the
|
|
20
|
+
// nearest package.json exactly like an entry would, so the projectDir the
|
|
21
|
+
// MCP bridge derives for its registry match agrees with ours.
|
|
22
|
+
state.projectDir = projectDirFor(source ? resolve(source) : resolve("package.json"))
|
|
20
23
|
state.stats = values.stats
|
|
21
24
|
state.capture = values.capture ? resolve(values.capture) : undefined
|
|
22
25
|
|
package/src/dev-dir.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
// The folder name is deliberately isolated here: one switch point if it ever
|
|
5
|
+
// changes or becomes configurable.
|
|
6
|
+
const DEV_DIR_NAME = ".solidrt"
|
|
7
|
+
|
|
8
|
+
// All dev-tooling state lives in one home dotdir, one rule on every platform
|
|
9
|
+
// (okf/backlog/parallel-dev-servers.md): servers/<port>/ holds a dev server's
|
|
10
|
+
// identity (tunnel.key) and its registry record (live.json), clients/ is the
|
|
11
|
+
// data root srt passes for every locally spawned client, so dev client trees
|
|
12
|
+
// land in clients/client<M>/. Deleting the dir resets every bit of dev state.
|
|
13
|
+
export function devDir(...parts: string[]): string {
|
|
14
|
+
return join(homedir(), DEV_DIR_NAME, ...parts)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `servers/<port>/` under the dev dir - a dev server's identity and registry record, keyed by port. */
|
|
18
|
+
export function serverDir(port: number): string {
|
|
19
|
+
return devDir("servers", String(port))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `clients/` under the dev dir - the --data-root for locally spawned dev clients. */
|
|
23
|
+
export function clientsRoot(): string {
|
|
24
|
+
return devDir("clients")
|
|
25
|
+
}
|
package/src/dev-server.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
import { resolve } from "path"
|
|
1
|
+
import { resolve, join } from "path"
|
|
2
2
|
import { tmpdir, networkInterfaces } from "node:os"
|
|
3
3
|
import { fileURLToPath } from "node:url"
|
|
4
|
+
import { mkdirSync, writeFileSync, unlinkSync } from "node:fs"
|
|
4
5
|
import { state, print, printErr, requireBinary, pipeAbovePrompt, shutdown } from "./util"
|
|
5
|
-
import { appArgs, values } from "./args"
|
|
6
|
+
import { appArgs, values, session, DEFAULT_DEV_PORT } from "./args"
|
|
7
|
+
import { serverDir } from "./dev-dir"
|
|
6
8
|
|
|
7
9
|
export const DEV_HOST = "127.0.0.1"
|
|
8
|
-
export const DEFAULT_DEV_PORT = 0x8844
|
|
9
10
|
|
|
10
11
|
// The port every dev-server consumer dials: the spawned server, the local and
|
|
11
12
|
// Android clients' --dev-server address, and the MCP bridge's control base.
|
|
12
|
-
// Resolved once here, so --port
|
|
13
|
+
// Resolved once here, so --port/--session need no threading through those
|
|
14
|
+
// call sites. An explicit --port wins over the session; the server folder is
|
|
15
|
+
// keyed by the port actually bound either way.
|
|
13
16
|
function resolveDevPort(): number {
|
|
14
17
|
let raw = values.port
|
|
15
|
-
if (raw === undefined) return DEFAULT_DEV_PORT
|
|
18
|
+
if (raw === undefined) return DEFAULT_DEV_PORT + session
|
|
16
19
|
let port = Number(raw)
|
|
17
20
|
if (!/^\d+$/.test(raw) || port < 1 || port > 65535) {
|
|
18
21
|
console.error(`Invalid --port value "${raw}": expected a port number between 1 and 65535`)
|
|
@@ -58,10 +61,10 @@ async function post(path: string, body: object) {
|
|
|
58
61
|
* Send a client-protocol message through the server: to the given client ids,
|
|
59
62
|
* or to every client when omitted. `latch` keeps the message for late-joining
|
|
60
63
|
* clients (code reloads latch, one-shot bytecode loads do not); `sourceDir`
|
|
61
|
-
* moves the server's file-serving root
|
|
62
|
-
*
|
|
63
|
-
* stack-trace remapping (omitting it clears the server's map,
|
|
64
|
-
* reload never remaps against a stale one).
|
|
64
|
+
* moves the server's file-serving root (repl `load`; the project root is
|
|
65
|
+
* fixed for the life of the run); `map` is the bundle's sourcemap, kept
|
|
66
|
+
* server-side for stack-trace remapping (omitting it clears the server's map,
|
|
67
|
+
* so a mapless reload never remaps against a stale one).
|
|
65
68
|
*/
|
|
66
69
|
export async function sendReload(
|
|
67
70
|
message: object,
|
|
@@ -69,7 +72,6 @@ export async function sendReload(
|
|
|
69
72
|
clients?: number[]
|
|
70
73
|
latch?: boolean
|
|
71
74
|
sourceDir?: string
|
|
72
|
-
projectDir?: string
|
|
73
75
|
entry?: string
|
|
74
76
|
map?: string | null
|
|
75
77
|
} = {},
|
|
@@ -185,15 +187,41 @@ function requireFreePort(port: number) {
|
|
|
185
187
|
let probe = Bun.serve({ port, fetch: () => new Response() })
|
|
186
188
|
probe.stop(true)
|
|
187
189
|
} catch {
|
|
188
|
-
printErr(`[cli] Port ${port} is already in use; start on another
|
|
190
|
+
printErr(`[cli] Port ${port} is already in use; start on another session with -s <N> (or --port <P>)`)
|
|
189
191
|
process.exit(1)
|
|
190
192
|
}
|
|
191
193
|
}
|
|
192
194
|
|
|
195
|
+
// The server's registry record: written once the server answers, removed at
|
|
196
|
+
// exit, so MCP bridges can resolve a project to a port without any per-project
|
|
197
|
+
// config (okf/backlog/parallel-dev-servers.md). The pid is the flux server's
|
|
198
|
+
// (the process owning the port), so a record left behind by a crash fails the
|
|
199
|
+
// bridge's liveness check; the record is a hint either way - the bridge's
|
|
200
|
+
// /__control__/clients probe is authoritative.
|
|
201
|
+
function writeLiveRecord() {
|
|
202
|
+
let record = {
|
|
203
|
+
pid: state.serverProc?.pid,
|
|
204
|
+
port: DEV_PORT,
|
|
205
|
+
projectDir: state.projectDir,
|
|
206
|
+
entry: state.source ?? null,
|
|
207
|
+
started: new Date().toISOString(),
|
|
208
|
+
}
|
|
209
|
+
writeFileSync(join(serverDir(DEV_PORT), "live.json"), JSON.stringify(record))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function removeLiveRecord() {
|
|
213
|
+
try {
|
|
214
|
+
unlinkSync(join(serverDir(DEV_PORT), "live.json"))
|
|
215
|
+
} catch {}
|
|
216
|
+
}
|
|
217
|
+
|
|
193
218
|
export async function startServer() {
|
|
194
219
|
let flux = requireBinary("flux")
|
|
195
220
|
requireFreePort(DEV_PORT)
|
|
196
221
|
let script = await bundleServer()
|
|
222
|
+
// The server's own folder (tunnel.key lands there, written by the flux
|
|
223
|
+
// process, which does not create directories).
|
|
224
|
+
mkdirSync(serverDir(DEV_PORT), { recursive: true })
|
|
197
225
|
|
|
198
226
|
let lanAddress = Object.values(networkInterfaces())
|
|
199
227
|
.flat()
|
|
@@ -220,8 +248,8 @@ export async function startServer() {
|
|
|
220
248
|
minify: values.minify,
|
|
221
249
|
bundlerCmd: [process.execPath, bundleCli],
|
|
222
250
|
cache: values["proxy-http"],
|
|
223
|
-
cacheDir: resolve(".srt-data"),
|
|
224
|
-
keyDir:
|
|
251
|
+
cacheDir: resolve(state.projectDir, ".srt-data"),
|
|
252
|
+
keyDir: serverDir(DEV_PORT),
|
|
225
253
|
capture: state.capture,
|
|
226
254
|
stats: state.stats,
|
|
227
255
|
tunnel: values.tunnel,
|
|
@@ -257,6 +285,11 @@ export async function startServer() {
|
|
|
257
285
|
}
|
|
258
286
|
}
|
|
259
287
|
|
|
288
|
+
writeLiveRecord()
|
|
289
|
+
// shutdown() exits via process.exit, so the exit hook covers every orderly
|
|
290
|
+
// path; only a kill -9 leaves the record behind, for the pid check to catch.
|
|
291
|
+
process.on("exit", removeLiveRecord)
|
|
292
|
+
|
|
260
293
|
// mDNS advertise (dropped, code kept for future use - see
|
|
261
294
|
// docs/flux-dev-server-plan.md): the p2p ticket is the cross-device connect
|
|
262
295
|
// story now. If advertise returns, it belongs next to the server (a flux
|
package/src/pack-folder.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
type ManifestFont,
|
|
11
11
|
} from "./project"
|
|
12
12
|
import { resolvePackFonts } from "./fonts"
|
|
13
|
+
import { runnerGlLibs } from "./packer"
|
|
14
|
+
import { isolateAssetPath } from "./bundler"
|
|
13
15
|
|
|
14
16
|
// The canonical flat pack folder (okf/plans/client-storage-updates.md, Pack
|
|
15
17
|
// output): runner + manifest.json + bundle.bin + assets/. The manifest
|
|
@@ -32,13 +34,20 @@ export type PackFolder = {
|
|
|
32
34
|
manifest: string
|
|
33
35
|
/** Files to place in the folder: absolute source -> folder-relative path. */
|
|
34
36
|
copies: Array<{ from: string; to: string }>
|
|
37
|
+
/** Build outputs to place in the folder (isolate bytecode): folder-relative path + bytes. */
|
|
38
|
+
files: Array<{ to: string; bytes: Buffer }>
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
|
|
41
|
+
// `isolates` are the app's isolate bundles compiled to bytecode; they ship as
|
|
42
|
+
// the manifest assets isolates/<id>.bin (the production runtime has no
|
|
43
|
+
// compiler, so pack never ships isolate source).
|
|
44
|
+
export function buildPackFolder(entry: string, bytecode: Buffer, isolates: { id: string; bytecode: Buffer }[]): PackFolder {
|
|
38
45
|
let identity = loadAppIdentity(entry)
|
|
39
46
|
let projectDir = projectDirFor(resolve(entry))
|
|
40
47
|
let { assets, icon } = collectAssets(entry)
|
|
41
48
|
let copies = assets.map((a) => ({ from: join(projectDir, a.path), to: a.path }))
|
|
49
|
+
let files = isolates.map((i) => ({ to: isolateAssetPath(i.id, "bin"), bytes: i.bytecode }))
|
|
50
|
+
for (let f of files) assets.push({ path: f.to, sha256: hashHex(f.bytes), size: f.bytes.length })
|
|
42
51
|
|
|
43
52
|
// The full resolved font set: custom fonts are already collected assets;
|
|
44
53
|
// defaults materialize under assets/fonts/ (a user file already at that
|
|
@@ -77,7 +86,7 @@ export function buildPackFolder(entry: string, bytecode: Buffer): PackFolder {
|
|
|
77
86
|
...(assets.length ? { assets } : {}),
|
|
78
87
|
...(fonts.length ? { fonts } : {}),
|
|
79
88
|
})
|
|
80
|
-
return { manifest, copies }
|
|
89
|
+
return { manifest, copies, files }
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
/**
|
|
@@ -93,9 +102,11 @@ export function writePackFolder(outDir: string, runnerPath: string, bytecode: Bu
|
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
let runnerName = "solidrt" + (process.platform === "win32" ? ".exe" : "")
|
|
105
|
+
let glLibs = runnerGlLibs(runnerPath)
|
|
96
106
|
mkdirSync(outDir, { recursive: true })
|
|
97
107
|
rmSync(join(outDir, "assets"), { recursive: true, force: true })
|
|
98
|
-
|
|
108
|
+
rmSync(join(outDir, "isolates"), { recursive: true, force: true })
|
|
109
|
+
for (let name of ["manifest.json", "bundle.bin", runnerName, ...glLibs.map((lib) => lib.name)]) {
|
|
99
110
|
rmSync(join(outDir, name), { force: true })
|
|
100
111
|
}
|
|
101
112
|
|
|
@@ -105,6 +116,11 @@ export function writePackFolder(outDir: string, runnerPath: string, bytecode: Bu
|
|
|
105
116
|
if (process.platform !== "win32") {
|
|
106
117
|
Bun.spawnSync(["chmod", "+x", join(outDir, runnerName)])
|
|
107
118
|
}
|
|
119
|
+
// The runner loads its GL libraries from next to itself; a folder pack must
|
|
120
|
+
// carry them like the platform package does.
|
|
121
|
+
for (let lib of glLibs) {
|
|
122
|
+
cpSync(lib.path, join(outDir, lib.name), { dereference: true })
|
|
123
|
+
}
|
|
108
124
|
writeFileSync(join(outDir, "bundle.bin"), bytecode)
|
|
109
125
|
writeFileSync(join(outDir, "manifest.json"), folder.manifest)
|
|
110
126
|
for (let { from, to } of folder.copies) {
|
|
@@ -112,4 +128,9 @@ export function writePackFolder(outDir: string, runnerPath: string, bytecode: Bu
|
|
|
112
128
|
mkdirSync(dirname(dest), { recursive: true })
|
|
113
129
|
cpSync(from, dest)
|
|
114
130
|
}
|
|
131
|
+
for (let { to, bytes } of folder.files) {
|
|
132
|
+
let dest = join(outDir, to)
|
|
133
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
134
|
+
writeFileSync(dest, bytes)
|
|
135
|
+
}
|
|
115
136
|
}
|
package/src/packer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs"
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
2
3
|
import { requireBinary } from "./util"
|
|
3
4
|
import { compileToBytecode } from "./bundler"
|
|
4
5
|
import type { PackFolder } from "./pack-folder"
|
|
@@ -14,6 +15,33 @@ const MAGIC = {
|
|
|
14
15
|
// Section kinds in the solidrt trailer. Must match lattice/src/main.rs.
|
|
15
16
|
const SECTION_MANIFEST = 1
|
|
16
17
|
const SECTION_FILE = 2
|
|
18
|
+
const SECTION_GL_LIB = 3
|
|
19
|
+
|
|
20
|
+
// The GL libraries the runner needs next to it (or, single-file, embedded as
|
|
21
|
+
// kind-3 sections it extracts at boot): ANGLE's libraries on Windows and
|
|
22
|
+
// macOS, nothing on platforms with a system GL. Order matters and the runner
|
|
23
|
+
// preloads in section order: libGLESv2 must load before libEGL so libEGL's
|
|
24
|
+
// import of it resolves against the already-loaded module instead of a
|
|
25
|
+
// directory search.
|
|
26
|
+
const GL_LIB_NAMES: Partial<Record<NodeJS.Platform, string[]>> = {
|
|
27
|
+
win32: ["libGLESv2.dll", "libEGL.dll"],
|
|
28
|
+
darwin: ["libGLESv2.dylib", "libEGL.dylib"],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The GL libraries shipped next to the runner binary, resolved to their paths.
|
|
32
|
+
// Missing files are fatal: a pack without them cannot create a window.
|
|
33
|
+
export function runnerGlLibs(runnerPath: string): Array<{ name: string; path: string }> {
|
|
34
|
+
let names = GL_LIB_NAMES[process.platform] ?? []
|
|
35
|
+
let dir = dirname(runnerPath)
|
|
36
|
+
return names.map((name) => {
|
|
37
|
+
let path = join(dir, name)
|
|
38
|
+
if (!existsSync(path)) {
|
|
39
|
+
console.error(`Could not find ${name} next to the runner (${dir}); the packed app needs it to create a GL context.`)
|
|
40
|
+
process.exit(1)
|
|
41
|
+
}
|
|
42
|
+
return { name, path }
|
|
43
|
+
})
|
|
44
|
+
}
|
|
17
45
|
|
|
18
46
|
type Section = { kind: number; bytes: Buffer; name?: string }
|
|
19
47
|
|
|
@@ -48,13 +76,18 @@ function packSections(runnerBytes: Buffer, sections: Section[], magic: Buffer):
|
|
|
48
76
|
// section form - the canonical manifest verbatim, then every manifest-listed
|
|
49
77
|
// file named by its manifest path. Bundle, fonts, and identity all come from
|
|
50
78
|
// the manifest; assets are read in place via ranged reads at their section
|
|
51
|
-
// offsets, so nothing is unpacked at runtime.
|
|
79
|
+
// offsets, so nothing is unpacked at runtime. GL libraries ride along as
|
|
80
|
+
// kind-3 sections (runtime freight, deliberately outside the manifest); the
|
|
81
|
+
// runner extracts those to its cache and preloads them before window setup.
|
|
52
82
|
export function packSolid(folder: PackFolder, bytecode: Buffer): Buffer {
|
|
53
|
-
let
|
|
83
|
+
let runnerPath = requireBinary("solidrt")
|
|
84
|
+
let runnerBytes = readFileSync(runnerPath)
|
|
54
85
|
let sections: Section[] = [
|
|
55
86
|
{ kind: SECTION_MANIFEST, bytes: Buffer.from(folder.manifest, "utf8") },
|
|
56
87
|
{ kind: SECTION_FILE, bytes: bytecode, name: "bundle.bin" },
|
|
57
88
|
...folder.copies.map((c) => ({ kind: SECTION_FILE, bytes: readFileSync(c.from), name: c.to })),
|
|
89
|
+
...folder.files.map((f) => ({ kind: SECTION_FILE, bytes: f.bytes, name: f.to })),
|
|
90
|
+
...runnerGlLibs(runnerPath).map((lib) => ({ kind: SECTION_GL_LIB, bytes: readFileSync(lib.path), name: lib.name })),
|
|
58
91
|
]
|
|
59
92
|
return packSections(runnerBytes, sections, MAGIC.solidrt)
|
|
60
93
|
}
|
package/src/project.ts
CHANGED
|
@@ -71,10 +71,13 @@ export const RUNTIME_VERSION = 1
|
|
|
71
71
|
let pkgVersion = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).version
|
|
72
72
|
export const SOLIDRT_VERSION: string = pkgVersion === "0.0.0" ? "unknown" : pkgVersion
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
// `extra` are build outputs that ship as assets too (isolate bundles); they
|
|
75
|
+
// follow the assets/ tree in the list, in the order given.
|
|
76
|
+
export function buildManifest(code: string, entry: string, extra: ManifestAsset[] = []): string {
|
|
75
77
|
let identity = loadAppIdentity(entry)
|
|
76
78
|
let sha256 = new Bun.CryptoHasher("sha256").update(code).digest("hex")
|
|
77
79
|
let { assets, fonts, icon } = collectAssets(entry)
|
|
80
|
+
assets.push(...extra)
|
|
78
81
|
return JSON.stringify({
|
|
79
82
|
appId: identity.appId,
|
|
80
83
|
displayName: identity.displayName,
|
|
@@ -90,6 +93,11 @@ export function buildManifest(code: string, entry: string): string {
|
|
|
90
93
|
export type ManifestAsset = { path: string; sha256: string; size: number }
|
|
91
94
|
export type ManifestFont = { path: string; alias: string }
|
|
92
95
|
|
|
96
|
+
/** The manifest entry for in-memory asset bytes at `path`. */
|
|
97
|
+
export function manifestAssetFor(path: string, bytes: Uint8Array): ManifestAsset {
|
|
98
|
+
return { path, sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex"), size: bytes.length }
|
|
99
|
+
}
|
|
100
|
+
|
|
93
101
|
// The project root the assets/ convention hangs off: the nearest package.json
|
|
94
102
|
// dir, or the entry's own dir when there is none.
|
|
95
103
|
export function projectDirFor(sourcePath: string): string {
|
package/src/repl.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { readdirSync } from "node:fs"
|
|
|
4
4
|
import { state, print, printErr, shutdown } from "./util"
|
|
5
5
|
import { buildReload, getClients, sendReload, sendStop, sendStats, sendWatch, showBuildFailure } from "./dev-server"
|
|
6
6
|
import { bundle } from "./bundler"
|
|
7
|
-
import { buildManifest
|
|
7
|
+
import { buildManifest } from "./project"
|
|
8
8
|
import { startWatcher, stopWatcher } from "./watcher"
|
|
9
9
|
|
|
10
10
|
// Resolve repl client indexes ("0 2") against the server's client list,
|
|
@@ -101,6 +101,15 @@ async function cmdLoad(file: string) {
|
|
|
101
101
|
return
|
|
102
102
|
}
|
|
103
103
|
let path = resolve(file)
|
|
104
|
+
// Same rule as /__control__/load (control.ts): a server run serves the
|
|
105
|
+
// project it started in, and an entry outside the project root cannot
|
|
106
|
+
// resolve the project's dependencies anyway.
|
|
107
|
+
let norm = (p: string) => p.replace(/\\/g, "/")
|
|
108
|
+
let root = norm(state.projectDir).replace(/\/+$/, "") + "/"
|
|
109
|
+
if (!norm(path).startsWith(root)) {
|
|
110
|
+
printErr(`[cli] Entry is outside the project root: ${path} is not under ${state.projectDir}. Restart srt in that project to work on it.`)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
104
113
|
if (file.endsWith(".tsx")) {
|
|
105
114
|
let result = await bundle(path)
|
|
106
115
|
if (!result) {
|
|
@@ -126,15 +135,14 @@ async function cmdLoad(file: string) {
|
|
|
126
135
|
}
|
|
127
136
|
state.source = path
|
|
128
137
|
state.sourceDir = dirname(path)
|
|
129
|
-
state.projectDir = projectDirFor(path)
|
|
130
138
|
startWatcher()
|
|
131
|
-
// The load also moves the server's file-serving root to the new source dir
|
|
132
|
-
// its
|
|
133
|
-
//
|
|
139
|
+
// The load also moves the server's file-serving root to the new source dir
|
|
140
|
+
// and its rebuild entry to the new file (for a later MCP reload). The
|
|
141
|
+
// project root - and with it the /assets/ root - is fixed for the life of
|
|
142
|
+
// the run.
|
|
134
143
|
await sendReload(buildReload({ code: state.currentCode, manifest: state.currentManifest }), {
|
|
135
144
|
latch: true,
|
|
136
145
|
sourceDir: state.sourceDir,
|
|
137
|
-
projectDir: state.projectDir,
|
|
138
146
|
entry: file.endsWith(".tsx") ? path : undefined,
|
|
139
147
|
map: state.currentMap,
|
|
140
148
|
})
|