@openthink/ui-leaf 0.3.2 → 0.4.0
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/README.md +16 -2
- package/dist/cli.js +58 -13
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +36 -2
- package/dist/index.js +58 -13
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,6 +82,8 @@ Three reasons:
|
|
|
82
82
|
|
|
83
83
|
The transport is HTTP + JSON over loopback. The token is in `window.__UI_LEAF__.token`, and it's served inline in the HTML at `/index.html` — so the token only protects against drive-by cross-origin requests in the user's browser, not against other processes on the same machine. Any local process that can reach `127.0.0.1:<port>` can fetch the page, grep the token out, and call `/mutate` with it; treat any local process you don't trust as having the same access as the view. View bundling resolves React from `ui-leaf`'s installed location, so your project doesn't need to install React.
|
|
84
84
|
|
|
85
|
+
The same trust boundary applies to the `data` you pass to `mount()`. The payload is JSON-inlined into `window.__UI_LEAF__.data` in the same `/index.html`, and is also written to `<tmpdir()>/ui-leaf-XXXXXX/index.html` on disk for the mount lifetime — readable by the same set of same-UID local processes that can read the token. For PHI, PCI, financial records, or anything else where a same-UID local reader is in your threat model, don't pass the sensitive payload through `data`; keep it in your CLI's memory and inject it into the view via an authenticated `connect-src 'self'` fetch on boot. See "Data-at-rest in the temp directory" below for the disk-residency details.
|
|
86
|
+
|
|
85
87
|
## API surface
|
|
86
88
|
|
|
87
89
|
```ts
|
|
@@ -90,7 +92,8 @@ import type { ViewProps, MutationHandler } from "@openthink/ui-leaf/view";
|
|
|
90
92
|
|
|
91
93
|
await mount({
|
|
92
94
|
view, // resolves <viewsRoot>/<view>.tsx
|
|
93
|
-
data, // JSON-serializable, becomes data prop
|
|
95
|
+
data, // JSON-serializable, becomes data prop (convenience default)
|
|
96
|
+
dataLoader, // optional async fn; serves data via authenticated /api/data (no disk write)
|
|
94
97
|
mutations, // Record<string, MutationHandler> (optional)
|
|
95
98
|
viewsRoot, // optional, default: <cwd>/views
|
|
96
99
|
title, // optional, default: "ui-leaf"
|
|
@@ -157,7 +160,18 @@ Be deliberate — every name you add becomes a viable rebinding target. Don't ad
|
|
|
157
160
|
|
|
158
161
|
ui-leaf serialises the `data` you pass to `mount()` into `<tmpdir()>/ui-leaf-XXXXXX/index.html` so the dev server can serve it. The directory is created with `mode 0700` (readable only by the same UID), and ui-leaf removes it on `close()`, on `SIGINT`/`SIGTERM`, on uncaught throws via a `process.on('exit')` fallback, and opportunistically sweeps `ui-leaf-*` siblings older than 24h on every startup to catch anything that still slipped through.
|
|
159
162
|
|
|
160
|
-
What still leaks: `SIGKILL`, OOM-kill, and abrupt power loss skip every Node hook, so the directory stays on disk until the next `mount()` runs (the startup sweep) or the OS rotates `tmpdir()` (on macOS, only across reboots; on many Linux systems, only via `tmpfiles.d` age policies). If the data is sensitive enough that even that bounded window is too long,
|
|
163
|
+
What still leaks: `SIGKILL`, OOM-kill, and abrupt power loss skip every Node hook, so the directory stays on disk until the next `mount()` runs (the startup sweep) or the OS rotates `tmpdir()` (on macOS, only across reboots; on many Linux systems, only via `tmpfiles.d` age policies). If the data is sensitive enough that even that bounded window is too long, use `dataLoader` instead of `data`:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
mount({
|
|
167
|
+
view: "report",
|
|
168
|
+
dataLoader: async () => {
|
|
169
|
+
return await db.fetchSensitiveRecords(); // stays in memory; never touches disk
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`dataLoader` invokes the function once at mount time, captures the result in-process, and serves it at a token-gated `GET /api/data` endpoint using the same per-launch token as `/mutate`. The view fetches `/api/data` on first render. Nothing is written to `index.html` or any tempdir file — the payload stays in memory for the entire mount lifetime. `data` remains the ergonomic default for routine payloads where disk residency is not a concern.
|
|
161
175
|
|
|
162
176
|
## Sharing views across users
|
|
163
177
|
|
package/dist/cli.js
CHANGED
|
@@ -152,6 +152,7 @@ async function startDevServer(opts) {
|
|
|
152
152
|
const {
|
|
153
153
|
view,
|
|
154
154
|
data,
|
|
155
|
+
dataLoader,
|
|
155
156
|
viewsRoot,
|
|
156
157
|
mutations = {},
|
|
157
158
|
title = "ui-leaf",
|
|
@@ -202,6 +203,9 @@ async function startDevServer(opts) {
|
|
|
202
203
|
`ui-leaf: view '${view}' not found at ${viewAbs} (looked for .tsx; viewsRoot=${viewsRoot})`
|
|
203
204
|
);
|
|
204
205
|
}
|
|
206
|
+
if (data !== void 0 && dataLoader) {
|
|
207
|
+
throw new Error("ui-leaf: pass data or dataLoader, not both");
|
|
208
|
+
}
|
|
205
209
|
const token = randomBytes(32).toString("hex");
|
|
206
210
|
tempDir = await mkdtemp(join(tmpdir(), "ui-leaf-"));
|
|
207
211
|
const dirToSweep = tempDir;
|
|
@@ -213,17 +217,12 @@ async function startDevServer(opts) {
|
|
|
213
217
|
};
|
|
214
218
|
process.on("exit", cleanupOnExit);
|
|
215
219
|
void sweepStaleTempDirs();
|
|
220
|
+
let loadedData;
|
|
221
|
+
if (dataLoader) {
|
|
222
|
+
loadedData = await dataLoader();
|
|
223
|
+
}
|
|
216
224
|
const entryPath = join(dirToSweep, "entry.tsx");
|
|
217
|
-
|
|
218
|
-
entryPath,
|
|
219
|
-
`import { createRoot } from "react-dom/client";
|
|
220
|
-
import View from ${JSON.stringify(viewAbs)};
|
|
221
|
-
|
|
222
|
-
const ctx = (globalThis).__UI_LEAF__ || {};
|
|
223
|
-
const data = ctx.data;
|
|
224
|
-
const token = ctx.token;
|
|
225
|
-
|
|
226
|
-
async function mutate(name, args) {
|
|
225
|
+
const sharedEntryFns = `async function mutate(name, args) {
|
|
227
226
|
const res = await fetch("/mutate", {
|
|
228
227
|
method: "POST",
|
|
229
228
|
headers: {
|
|
@@ -257,17 +256,50 @@ async function heartbeat() {
|
|
|
257
256
|
}
|
|
258
257
|
}
|
|
259
258
|
setInterval(heartbeat, 5000);
|
|
260
|
-
heartbeat()
|
|
259
|
+
heartbeat();`;
|
|
260
|
+
await writeFile(
|
|
261
|
+
entryPath,
|
|
262
|
+
dataLoader ? `import { createRoot } from "react-dom/client";
|
|
263
|
+
import View from ${JSON.stringify(viewAbs)};
|
|
264
|
+
|
|
265
|
+
const ctx = (globalThis).__UI_LEAF__ || {};
|
|
266
|
+
const token = ctx.token;
|
|
267
|
+
|
|
268
|
+
${sharedEntryFns}
|
|
269
|
+
|
|
270
|
+
// Heartbeat runs outside bootstrap so a slow loader cannot trip the watcher.
|
|
271
|
+
async function bootstrap() {
|
|
272
|
+
const res = await fetch("/api/data", {
|
|
273
|
+
headers: token ? { Authorization: "Bearer " + token } : {},
|
|
274
|
+
});
|
|
275
|
+
if (!res.ok) {
|
|
276
|
+
const text = await res.text().catch(function () { return ""; });
|
|
277
|
+
throw new Error("ui-leaf: /api/data fetch failed (" + res.status + "): " + text);
|
|
278
|
+
}
|
|
279
|
+
const data = await res.json();
|
|
280
|
+
const el = document.getElementById("root");
|
|
281
|
+
if (!el) throw new Error("ui-leaf: #root element missing");
|
|
282
|
+
createRoot(el).render(<View data={data} mutate={mutate} />);
|
|
283
|
+
}
|
|
284
|
+
bootstrap();
|
|
285
|
+
` : `import { createRoot } from "react-dom/client";
|
|
286
|
+
import View from ${JSON.stringify(viewAbs)};
|
|
287
|
+
|
|
288
|
+
const ctx = (globalThis).__UI_LEAF__ || {};
|
|
289
|
+
const data = ctx.data;
|
|
290
|
+
const token = ctx.token;
|
|
291
|
+
|
|
292
|
+
${sharedEntryFns}
|
|
261
293
|
|
|
262
294
|
const el = document.getElementById("root");
|
|
263
295
|
if (!el) throw new Error("ui-leaf: #root element missing");
|
|
264
296
|
createRoot(el).render(<View data={data} mutate={mutate} />);
|
|
265
297
|
`
|
|
266
298
|
);
|
|
267
|
-
const dataInline = escapeForScriptTag(JSON.stringify(JSON.stringify(data)));
|
|
268
299
|
const tokenInline = JSON.stringify(token);
|
|
269
300
|
const titleEscaped = title.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
270
301
|
const htmlPath = join(dirToSweep, "index.html");
|
|
302
|
+
const scriptContent = dataLoader ? `{ token: ${tokenInline} }` : `{ data: JSON.parse(${escapeForScriptTag(JSON.stringify(JSON.stringify(data ?? null)))}), token: ${tokenInline} }`;
|
|
271
303
|
await writeFile(
|
|
272
304
|
htmlPath,
|
|
273
305
|
`<!DOCTYPE html>
|
|
@@ -275,7 +307,7 @@ createRoot(el).render(<View data={data} mutate={mutate} />);
|
|
|
275
307
|
<head>
|
|
276
308
|
<meta charset="utf-8" />
|
|
277
309
|
<title>${titleEscaped}</title>
|
|
278
|
-
<script>window.__UI_LEAF__ =
|
|
310
|
+
<script>window.__UI_LEAF__ = ${scriptContent};</script>
|
|
279
311
|
</head>
|
|
280
312
|
<body>
|
|
281
313
|
<div id="root"></div>
|
|
@@ -366,6 +398,18 @@ createRoot(el).render(<View data={data} mutate={mutate} />);
|
|
|
366
398
|
}
|
|
367
399
|
return;
|
|
368
400
|
}
|
|
401
|
+
if (req.method === "GET" && url2 === "/api/data") {
|
|
402
|
+
if (!dataLoader) {
|
|
403
|
+
next();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (!checkAuth2(req)) {
|
|
407
|
+
sendJson2(res, 401, { error: "unauthorized" });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
sendJson2(res, 200, loadedData !== void 0 ? loadedData : null);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
369
413
|
next();
|
|
370
414
|
});
|
|
371
415
|
if (cspHeader) {
|
|
@@ -459,6 +503,7 @@ async function mount(opts) {
|
|
|
459
503
|
const server = await startDevServer({
|
|
460
504
|
view: opts.view,
|
|
461
505
|
data: opts.data,
|
|
506
|
+
dataLoader: opts.dataLoader,
|
|
462
507
|
viewsRoot,
|
|
463
508
|
mutations: opts.mutations,
|
|
464
509
|
title: opts.title,
|
package/dist/cli.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/cli.ts","../src/index.ts","../src/dev-server.ts"],"sourcesContent":["#!/usr/bin/env node\n// ui-leaf CLI — language-neutral entry point for non-Node consumers.\n//\n// Protocol (stdio, line-delimited JSON):\n//\n// STDIN\n// Line 1: config object (view, viewsRoot, data, mutations: [names], …)\n// Line 2+: mutation responses, one per line:\n// {\"type\":\"result\",\"id\":<n>,\"value\":<any>}\n// {\"type\":\"error\",\"id\":<n>,\"message\":\"<text>\"}\n//\n// STDOUT\n// {\"type\":\"ready\",\"url\":\"<url>\",\"port\":<n>} emitted once when the dev server is up\n// {\"type\":\"mutate\",\"id\":<n>,\"name\":\"<s>\",\"args\":<any>} emitted when a view triggers a mutation\n// {\"type\":\"closed\"} emitted on natural close\n// {\"type\":\"error\",\"message\":\"<text>\"} emitted on internal error\n//\n// Lifecycle\n// Exits 0 on natural close (view closed).\n// Exits 1 on internal error.\n// Closing stdin from the parent triggers shutdown (any pending\n// mutations are rejected).\n\nimport { createInterface } from \"node:readline\";\nimport { mount, type MountOptions } from \"./index.js\";\n\n// Capture the real stdout write BEFORE anything (especially mount() with\n// silent: true) gets a chance to redirect process.stdout. The binary's\n// protocol output uses this directly; rsbuild's noise (which goes\n// through process.stdout.write) gets redirected to stderr by silent mode\n// without affecting our protocol channel.\nconst realStdoutWrite = process.stdout.write.bind(process.stdout);\n\nconst args = process.argv.slice(2);\n\nif (args.length === 0 || args[0] === \"--help\" || args[0] === \"-h\") {\n process.stdout.write(\n [\n \"ui-leaf — Customizable browser views, on demand, for any CLI.\",\n \"\",\n \"Usage:\",\n \" ui-leaf mount Read a JSON config from stdin and mount a view.\",\n \" See https://github.com/OpenThinkAi/ui-leaf for\",\n \" the full stdio protocol spec.\",\n \"\",\n \" ui-leaf --version Print version.\",\n \" ui-leaf --help Print this message.\",\n \"\",\n ].join(\"\\n\"),\n );\n process.exit(0);\n}\n\nif (args[0] === \"--version\" || args[0] === \"-v\") {\n // Read version from package.json shipped alongside this binary.\n const { createRequire } = await import(\"node:module\");\n const require = createRequire(import.meta.url);\n const pkg = require(\"../package.json\") as { version: string };\n process.stdout.write(`${pkg.version}\\n`);\n process.exit(0);\n}\n\nif (args[0] !== \"mount\") {\n process.stderr.write(`ui-leaf: unknown command \"${args[0]}\"\\n`);\n process.exit(1);\n}\n\n// `ui-leaf mount --help` (or being run on a TTY without piped input)\n// would otherwise sit silently on stdin. Print the protocol pointer and\n// exit 0 so users discovering the binary land on docs, not a \"broken\"\n// exit code.\nif (args[1] === \"--help\" || args[1] === \"-h\" || process.stdin.isTTY) {\n process.stdout.write(\n [\n \"ui-leaf mount — read a JSON config from stdin and mount a view.\",\n \"\",\n \"Protocol: line-delimited JSON over stdio.\",\n \" stdin line 1 = config object\",\n \" lines 2+ = mutation responses {type:result|error,id,...}\",\n \" stdout {type:ready,url,port}, {type:mutate,id,name,args},\",\n \" {type:closed}, {type:error,message}\",\n \"\",\n \"Full spec: https://github.com/OpenThinkAi/ui-leaf#driving-ui-leaf-from-a-non-node-cli\",\n \"\",\n \"Example:\",\n ` echo '{\"view\":\"spec\",\"viewsRoot\":\"/abs/path\",\"data\":{}}' | ui-leaf mount`,\n \"\",\n ].join(\"\\n\"),\n );\n process.exit(0);\n}\n\ntry {\n await runMount();\n} catch (err) {\n emit({\n type: \"error\",\n message: err instanceof Error ? err.message : String(err),\n });\n process.exit(1);\n}\n\ninterface ConfigRequest {\n view: string;\n viewsRoot: string;\n data?: unknown;\n mutations?: string[];\n title?: string;\n port?: number;\n openBrowser?: boolean;\n shell?: \"tab\" | \"app\";\n csp?: string;\n heartbeatTimeoutMs?: number;\n startupGraceMs?: number;\n}\n\ninterface MutateResult {\n type: \"result\";\n id: number;\n value?: unknown;\n}\n\ninterface MutateError {\n type: \"error\";\n id: number;\n message: string;\n}\n\ntype MutateResponse = MutateResult | MutateError;\n\nfunction emit(event: unknown): void {\n realStdoutWrite(`${JSON.stringify(event)}\\n`);\n}\n\nasync function runMount(): Promise<void> {\n const rl = createInterface({ input: process.stdin });\n let nextId = 0;\n const pending = new Map<\n number,\n { resolve: (value: unknown) => void; reject: (err: Error) => void }\n >();\n\n let configReceived = false;\n let configResolve!: (cfg: ConfigRequest) => void;\n let configReject!: (err: Error) => void;\n const configPromise = new Promise<ConfigRequest>((res, rej) => {\n configResolve = res;\n configReject = rej;\n });\n\n // Track the mounted view so the stdin-close handler can shut it down.\n // Set after `mount()` resolves; null until then.\n // biome-ignore lint/suspicious/noExplicitAny: MountedView shape inferred\n let mountedView: any = null;\n let stdinClosed = false;\n\n rl.on(\"line\", (line) => {\n const trimmed = line.trim();\n if (!trimmed) return;\n\n if (!configReceived) {\n configReceived = true;\n try {\n const config = JSON.parse(trimmed) as ConfigRequest;\n configResolve(config);\n } catch (err) {\n emit({\n type: \"error\",\n message: `failed to parse config JSON: ${err instanceof Error ? err.message : String(err)}`,\n });\n process.exit(1);\n }\n return;\n }\n\n // Mutation response.\n let msg: MutateResponse;\n try {\n msg = JSON.parse(trimmed) as MutateResponse;\n } catch {\n // Malformed line; ignore (consumer should be using the protocol).\n return;\n }\n const p = pending.get(msg.id);\n if (!p) return;\n pending.delete(msg.id);\n if (msg.type === \"result\") p.resolve(msg.value);\n else if (msg.type === \"error\") p.reject(new Error(msg.message));\n });\n\n rl.on(\"close\", () => {\n stdinClosed = true;\n // Reject any pending mutations — the parent isn't going to respond.\n for (const { reject } of pending.values()) {\n reject(new Error(\"ui-leaf: stdin closed by parent before mutation responded\"));\n }\n pending.clear();\n // If we never received config, the parent dropped before doing\n // anything useful. Bail out with a non-zero exit so that's visible.\n if (!configReceived) {\n configReject(new Error(\"ui-leaf: stdin closed before config received\"));\n return;\n }\n // Otherwise, tear down the mounted view (if it exists yet) so the\n // process exits without waiting on the heartbeat timeout. The\n // view.closed promise resolves and runMount's normal exit path runs.\n if (mountedView) {\n void mountedView.close();\n }\n });\n\n const config = await configPromise;\n\n // Build mutations map: each declared name becomes a handler that emits\n // a mutate event on stdout and awaits a paired response on stdin.\n // biome-ignore lint/suspicious/noExplicitAny: handler signatures vary\n const mutations: Record<string, (args: any) => Promise<unknown>> = {};\n for (const name of config.mutations ?? []) {\n mutations[name] = (mutationArgs: unknown) => {\n const id = ++nextId;\n return new Promise<unknown>((resolve, reject) => {\n pending.set(id, { resolve, reject });\n emit({ type: \"mutate\", id, name, args: mutationArgs });\n });\n };\n }\n\n const mountOpts: MountOptions = {\n view: config.view,\n viewsRoot: config.viewsRoot,\n data: config.data,\n mutations,\n title: config.title,\n port: config.port,\n openBrowser: config.openBrowser,\n shell: config.shell,\n csp: config.csp,\n heartbeatTimeoutMs: config.heartbeatTimeoutMs,\n startupGraceMs: config.startupGraceMs,\n silent: true, // bridge owns stdout; rsbuild output must stay silent\n };\n\n try {\n const view = await mount(mountOpts);\n mountedView = view;\n // If stdin closed while we were waiting on mount(), tear down right\n // away rather than hold the dev server open.\n if (stdinClosed) {\n void view.close();\n }\n emit({ type: \"ready\", url: view.url, port: view.port });\n await view.closed;\n emit({ type: \"closed\" });\n process.exit(0);\n } catch (err) {\n emit({\n type: \"error\",\n message: err instanceof Error ? err.message : String(err),\n });\n process.exit(1);\n }\n}\n","// ui-leaf — Customizable browser views, on demand, for any CLI.\n// https://github.com/OpenThinkAi/ui-leaf\n\nimport { resolve } from \"node:path\";\nimport {\n startDevServer,\n type CspOption,\n type MutationHandler,\n type Shell,\n} from \"./dev-server.js\";\n\nexport type { CspOption, MutationHandler, Shell };\n\nexport interface MountOptions {\n /** View name. Resolves to <viewsRoot>/<view>.tsx. */\n view: string;\n /** JSON-serializable data passed to the view as a prop. */\n data: unknown;\n /**\n * Mutation handlers the view can call via mutate(name, args).\n * Each handler can self-type its args and return:\n *\n * mutations: {\n * recategorize: async (args: { id: string; category: string }) => {\n * await db.recategorize(args.id, args.category);\n * return { ok: true };\n * },\n * }\n *\n * Each request body is capped at 1 MiB; oversized POSTs are rejected\n * with a 400 and the view's mutate() promise rejects with a clear error.\n */\n // biome-ignore lint/suspicious/noExplicitAny: each handler has its own\n // arg/return types; the map can't share one shape.\n mutations?: Record<string, MutationHandler<any, any>>;\n /** Root directory holding view .tsx files. Defaults to <cwd>/views. */\n viewsRoot?: string;\n /** Browser tab title. Defaults to \"ui-leaf\". */\n title?: string;\n /**\n * Port to bind. Defaults to 5810 — unused by the major Node dev tools.\n * If the port is unavailable, ui-leaf bumps to the next free port and\n * the actual bound port is reflected on the returned `url` and `port`.\n * Override only if you need a stable URL (e.g. an external bookmark).\n */\n port?: number;\n /**\n * Open the browser when ready. Defaults to true. When false, mount()\n * returns the URL on its resolved value so the caller can drive a\n * headless browser, log the address, etc.\n */\n openBrowser?: boolean;\n /**\n * Browser shell. Defaults to \"tab\".\n *\n * - `\"tab\"` — open in the user's default browser as a regular tab.\n * Works everywhere; URL bar is visible.\n *\n * - `\"app\"` — try Chromium's `--app` mode for a chromeless window\n * (no URL bar, no tabs, looks like a desktop app). Available on\n * Chrome, Edge, and Brave. If no Chromium browser is installed,\n * ui-leaf falls back to \"tab\" with a stderr note. Safari and\n * Firefox always fall back.\n *\n * Pair with the share-link pattern (see \"Sharing views across users\"\n * in the README) when you want users to never see a localhost URL.\n */\n shell?: Shell;\n /**\n * Abort to close the dev server early. The returned `closed` promise\n * resolves either way; if you need to distinguish a signal-driven close\n * from a natural tab-close, check `signal.aborted` after the await.\n */\n signal?: AbortSignal;\n /**\n * Browser silence (ms) that triggers shutdown after the startup grace\n * window. Defaults to 75000 — chosen to survive a single browser\n * background-tab throttle (browsers clamp setInterval in hidden tabs to\n * roughly once per minute). Lower it if you want faster shutdown on tab\n * close; raise it if your debugger pauses the page or your machine\n * sleeps mid-session.\n */\n heartbeatTimeoutMs?: number;\n /**\n * Content-Security-Policy enforcement. Defaults to \"off\".\n *\n * - `\"off\"` — no CSP header sent. Views can fetch arbitrary URLs and\n * embed external resources freely. The data/mutations convention is\n * honor-system.\n *\n * - `\"strict\"` — ui-leaf sends a balanced preset: locks `connect-src`\n * to same-origin (the architectural lock — views cannot fetch\n * external APIs, so all data flows through `data` and `mutations`),\n * while permitting common needs (HTTPS images / fonts, inline\n * styles for React, eval for rsbuild HMR). View files can only\n * *add* further restrictions via meta tag, never remove them.\n *\n * - `string` — raw CSP header value for full control. Use when the\n * \"strict\" preset doesn't fit (e.g. you need `connect-src` to\n * include a Sentry endpoint).\n *\n * Trade-off: when set to \"strict\" or a custom string, a view file\n * cannot relax the policy at runtime. Switching back requires changing\n * the mount() call. That rigidity is a feature.\n */\n csp?: CspOption;\n /**\n * Extra hostnames accepted in the request `Host` and `Origin` headers\n * on top of the built-in loopback set (`localhost`, `127.0.0.1`, `[::1]`).\n *\n * The dev server gates every request on this set to defend against\n * DNS-rebinding attacks; non-matching requests get HTTP 403. Use this\n * escape hatch when you need to reach the dev server through a custom\n * `/etc/hosts` alias (e.g. `[\"my-app.local\"]`) or any other loopback\n * name. Hostnames are matched case-insensitively, port-agnostic.\n *\n * Be deliberate: any hostname you add becomes a viable DNS-rebinding\n * target. Don't add wildcards, public DNS names, or LAN hostnames you\n * don't fully control.\n */\n allowedHosts?: string[];\n /**\n * Suppress ui-leaf / rsbuild output to stdout. Default: false.\n *\n * When you drive `mount()` programmatically — e.g. as part of a Node\n * bridge for a non-Node CLI that's spawned ui-leaf as a subprocess —\n * stdout is usually reserved for a structured protocol (line-delimited\n * JSON, etc.). Without this option, rsbuild's banner and build messages\n * collide with that protocol and silently corrupt it on the consumer\n * side.\n *\n * Setting `silent: true`:\n * - Sets rsbuild's logLevel to 'silent' (no banner, build, or\n * deprecation messages emitted by rsbuild)\n * - Redirects `process.stdout.write` to `process.stderr` for the\n * lifetime of the dev server, restored on close\n *\n * Tradeoff: any other code in the same process that writes to stdout\n * during the dev server's lifetime is also redirected. Hold the\n * captured `process.stdout.write` reference yourself if you need to\n * write to the *real* stdout from the same process.\n */\n silent?: boolean;\n /**\n * Grace period (ms) after server start before the heartbeat watcher arms.\n * Cold-loading clients sometimes take a few seconds to send their first\n * heartbeat. Defaults to 30000.\n *\n * If no client connects within (startupGraceMs + heartbeatTimeoutMs),\n * the server shuts down on its own.\n */\n startupGraceMs?: number;\n}\n\nexport interface MountedView {\n /** URL the view is reachable at (http://127.0.0.1:<port>). */\n url: string;\n /** Bound port. Useful when port: 0 was requested. */\n port: number;\n /** Resolves when the view closes (heartbeat timeout) or close() is called. */\n closed: Promise<void>;\n /** Force-close the dev server early. */\n close: () => Promise<void>;\n}\n\n/**\n * Mount a customizable browser view from a CLI. Spins up a local dev server\n * and renders the chosen view with the given data. Returns once the server\n * is ready; await `result.closed` to block until the user closes the\n * browser tab.\n *\n * Mutations triggered in the view are dispatched to the registered handlers\n * here; the view never reaches the CLI's backing API directly.\n *\n * Multi-tab note: if the user opens the served URL in additional tabs (or\n * duplicates the tab), each tab heartbeats independently and the server\n * stays alive while *any* tab is open. Closing the original tab does not\n * shut down the CLI if a duplicate is still loaded.\n *\n * Ctrl+C: this function installs SIGINT and SIGTERM handlers that close\n * the dev server (and clean up its temp directory) before exiting.\n */\nexport async function mount(opts: MountOptions): Promise<MountedView> {\n const viewsRoot = opts.viewsRoot ?? resolve(process.cwd(), \"views\");\n\n const server = await startDevServer({\n view: opts.view,\n data: opts.data,\n viewsRoot,\n mutations: opts.mutations,\n title: opts.title,\n port: opts.port,\n openBrowser: opts.openBrowser,\n shell: opts.shell,\n heartbeatTimeoutMs: opts.heartbeatTimeoutMs,\n startupGraceMs: opts.startupGraceMs,\n csp: opts.csp,\n allowedHosts: opts.allowedHosts,\n silent: opts.silent,\n });\n\n const onSignal = (signal: NodeJS.Signals): void => {\n void (async () => {\n await server.close();\n // Re-raise so default exit codes still apply.\n process.kill(process.pid, signal);\n })();\n };\n const sigint = (): void => onSignal(\"SIGINT\");\n const sigterm = (): void => onSignal(\"SIGTERM\");\n process.once(\"SIGINT\", sigint);\n process.once(\"SIGTERM\", sigterm);\n\n if (opts.signal) {\n if (opts.signal.aborted) {\n process.off(\"SIGINT\", sigint);\n process.off(\"SIGTERM\", sigterm);\n await server.close();\n return {\n url: server.url,\n port: server.port,\n closed: Promise.resolve(),\n close: server.close,\n };\n }\n opts.signal.addEventListener(\n \"abort\",\n () => void server.close(),\n { once: true },\n );\n }\n\n const closed = server.closed.finally(() => {\n process.off(\"SIGINT\", sigint);\n process.off(\"SIGTERM\", sigterm);\n });\n\n return {\n url: server.url,\n port: server.port,\n closed,\n close: server.close,\n };\n}\n","// Spike: dev server with mutation bridge + heartbeat-based shutdown.\n// Builds on Spike #1; adds the bridge that makes ui-leaf actually useful.\n\nimport { randomBytes, timingSafeEqual as nodeTimingSafeEqual } from \"node:crypto\";\nimport { rmSync } from \"node:fs\";\nimport { mkdtemp, readdir, rm, stat, writeFile } from \"node:fs/promises\";\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { createRequire } from \"node:module\";\nimport { createServer as createTcpServer, type Socket } from \"node:net\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join, resolve, sep } from \"node:path\";\nimport { createRsbuild } from \"@rsbuild/core\";\nimport { pluginReact } from \"@rsbuild/plugin-react\";\nimport open, { apps } from \"open\";\n\n// Resolve react / react-dom from ui-leaf's installed location using\n// Node's actual resolver. With hoisting (npm/pnpm/bun), these end up in\n// the consumer's top-level node_modules, NOT under ui-leaf/node_modules.\n// Aliasing the resolved directory paths in rspack lets the bundled view\n// always find react no matter where the package manager put it.\nconst uiLeafRequire = createRequire(import.meta.url);\nconst reactPath = dirname(uiLeafRequire.resolve(\"react/package.json\"));\nconst reactDomPath = dirname(uiLeafRequire.resolve(\"react-dom/package.json\"));\n\n// Module-level stdout redirect state. Captured ONCE at module load so\n// concurrent silent: true mounts share the same \"original\" reference and\n// restore-order doesn't matter. Refcounted so the last close restores.\nconst ORIGINAL_STDOUT_WRITE = process.stdout.write.bind(process.stdout);\nlet stdoutRedirectCount = 0;\n\n/**\n * Ask the OS for a free port and return it. Used when the consumer\n * requests `port: 0`. There's a small race window between close() and\n * rsbuild's bind, but in practice it's never been an issue for dev\n * tooling.\n */\nasync function findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const server = createTcpServer();\n server.unref();\n server.on(\"error\", reject);\n server.listen(0, \"127.0.0.1\", () => {\n const addr = server.address();\n if (!addr || typeof addr !== \"object\") {\n server.close();\n reject(new Error(\"ui-leaf: failed to obtain a free port from the OS\"));\n return;\n }\n const port = addr.port;\n server.close(() => resolve(port));\n });\n });\n}\n\n// Stale-tempdir sweep threshold. Each mount writes index.html with the\n// consumer's data inlined; a crashed run leaves the dir behind until OS\n// rotation. 24h is well past any realistic single-session lifetime and\n// well short of the macOS reboot-only rotation cadence.\nconst STALE_TEMPDIR_AGE_MS = 24 * 60 * 60 * 1000;\n\n/**\n * Best-effort removal of `ui-leaf-*` siblings in `tmpdir()` whose mtime\n * is older than the stale threshold. Fire-and-forget; never throws.\n * Filters by mtime so concurrent ui-leaf processes' active dirs are left\n * alone (they're created fresh per mount).\n */\nasync function sweepStaleTempDirs(): Promise<void> {\n try {\n const root = tmpdir();\n const entries = await readdir(root, { withFileTypes: true });\n const cutoff = Date.now() - STALE_TEMPDIR_AGE_MS;\n await Promise.all(\n entries\n .filter((e) => e.isDirectory() && e.name.startsWith(\"ui-leaf-\"))\n .map(async (e) => {\n const path = join(root, e.name);\n try {\n const info = await stat(path);\n if (info.mtimeMs < cutoff) {\n await rm(path, { recursive: true, force: true });\n }\n } catch {\n // Another process may be mid-cleanup, or the dir may belong\n // to a different UID; either way, skip silently.\n }\n }),\n );\n } catch {\n // tmpdir() unreadable is rare and not actionable here.\n }\n}\n\n/**\n * Redirect process.stdout.write to process.stderr until the returned\n * function is called. Safe under concurrent silent mounts.\n */\nfunction redirectStdoutToStderr(): () => void {\n stdoutRedirectCount++;\n if (stdoutRedirectCount === 1) {\n // biome-ignore lint/suspicious/noExplicitAny: stdout.write has overloaded\n // signatures; forward exactly what comes in.\n process.stdout.write = ((chunk: any, enc?: any, cb?: any) =>\n process.stderr.write(chunk, enc, cb)) as typeof process.stdout.write;\n }\n let released = false;\n return () => {\n if (released) return;\n released = true;\n stdoutRedirectCount--;\n if (stdoutRedirectCount === 0) {\n process.stdout.write = ORIGINAL_STDOUT_WRITE;\n }\n };\n}\n\nexport type MutationHandler<TArgs = unknown, TResult = unknown> = (\n args: TArgs,\n) => TResult | Promise<TResult>;\n\n// `(string & {})` preserves the \"off\" / \"strict\" autocomplete suggestions\n// while still allowing arbitrary CSP strings. Plain string would collapse\n// the union and lose IntelliSense for the literals.\nexport type CspOption = \"off\" | \"strict\" | (string & {});\n\nexport type Shell = \"tab\" | \"app\";\n\n/**\n * Try to open `url` in a Chromium browser's --app mode (chromeless window:\n * no URL bar, no tabs). Returns true if a Chromium browser was found and\n * launched, false if no Chromium variant is installed (caller should fall\n * back to the default-browser tab).\n */\nasync function openInAppMode(url: string): Promise<boolean> {\n // Order: most-common Chromium variants first.\n const candidates = [apps.chrome, apps.edge, apps.brave];\n for (const app of candidates) {\n try {\n await open(url, { app: { name: app, arguments: [`--app=${url}`] } });\n return true;\n } catch {\n // Try next candidate; `open` throws if the binary isn't installed.\n }\n }\n return false;\n}\n\n/**\n * Strict preset: locks `connect-src` to same-origin (the architectural\n * lock that forces views to route mutations through the CLI), while\n * permitting common needs (HTTPS images/fonts, inline styles for React,\n * eval/inline-scripts for rsbuild's HMR client). A future \"production\"\n * mode could ship a tighter preset once HMR isn't in the picture.\n */\nconst STRICT_CSP = [\n \"default-src 'self'\",\n \"connect-src 'self'\",\n \"img-src 'self' data: https:\",\n \"font-src 'self' https: data:\",\n \"style-src 'self' 'unsafe-inline'\",\n \"script-src 'self' 'unsafe-eval' 'unsafe-inline'\",\n].join(\"; \");\n\nfunction resolveCsp(opt: CspOption | undefined): string | null {\n if (!opt || opt === \"off\") return null;\n if (opt === \"strict\") return STRICT_CSP;\n return opt;\n}\n\nexport interface DevServerOptions {\n view: string;\n data: unknown;\n viewsRoot: string;\n // biome-ignore lint/suspicious/noExplicitAny: each handler has its own\n // arg/return types; the map can't share one shape.\n mutations?: Record<string, MutationHandler<any, any>>;\n /** Browser tab title. Defaults to \"ui-leaf\". */\n title?: string;\n port?: number;\n openBrowser?: boolean;\n /**\n * Browser shell. Defaults to \"tab\".\n *\n * - \"tab\" — open in user's default browser as a regular tab.\n * - \"app\" — try Chromium's --app mode (chromeless window). Falls back\n * to \"tab\" if no Chromium browser is installed (Chrome/Edge/Brave),\n * with a stderr note. Safari and Firefox always fall back.\n */\n shell?: Shell;\n /** Heartbeat-stop window in ms. Browser silence longer than this triggers shutdown. */\n heartbeatTimeoutMs?: number;\n /** Grace period after server start before the heartbeat watcher is armed. */\n startupGraceMs?: number;\n /** Content-Security-Policy enforcement. See MountOptions.csp. */\n csp?: CspOption;\n /**\n * Extra hostnames (beyond `localhost`, `127.0.0.1`, `[::1]`) accepted in\n * the request `Host` and `Origin` headers. Use to allow a custom\n * `/etc/hosts` alias or another loopback name; values are matched by\n * hostname only (port-agnostic). Anything outside this set + the\n * loopback defaults is rejected with HTTP 403 to defend against\n * DNS-rebinding attacks. Default: empty.\n */\n allowedHosts?: string[];\n /**\n * Suppress ui-leaf / rsbuild output to stdout. When true:\n * - rsbuildConfig.logLevel is set to 'silent' (no banner, build, or\n * deprecation messages)\n * - process.stdout.write is redirected to process.stderr for the\n * lifetime of the dev server, restored on close()\n *\n * Use when driving mount() programmatically and stdout is reserved for\n * a structured protocol (e.g. line-delimited JSON to a parent process).\n * Default: false.\n */\n silent?: boolean;\n}\n\nexport interface DevServer {\n url: string;\n port: number;\n /** Resolves when the view is closed (heartbeat timeout) or close() is called. */\n closed: Promise<void>;\n close: () => Promise<void>;\n}\n\nfunction escapeForScriptTag(json: string): string {\n // Defend against </script> break-out and U+2028 / U+2029 line terminators\n // that JSON.stringify emits raw but JS string literals don't accept.\n return json\n .replace(/</g, \"\\\\u003c\")\n .replace(/\\u2028/g, \"\\\\u2028\")\n .replace(/\\u2029/g, \"\\\\u2029\");\n}\n\nasync function readJsonBody(req: IncomingMessage): Promise<unknown> {\n const chunks: Buffer[] = [];\n let total = 0;\n for await (const chunk of req) {\n const buf = chunk as Buffer;\n total += buf.length;\n // 1 MiB cap per request — protects against accidental huge payloads.\n if (total > 1024 * 1024) {\n throw new Error(\"request body exceeds 1 MiB limit\");\n }\n chunks.push(buf);\n }\n const text = Buffer.concat(chunks).toString(\"utf8\");\n return text ? JSON.parse(text) : undefined;\n}\n\nfunction timingSafeEqual(a: string, b: string): boolean {\n // Length check is not timing-safe but is fine — the token length is fixed\n // and known to attackers regardless. The byte compare must be timing-safe.\n if (a.length !== b.length) return false;\n return nodeTimingSafeEqual(Buffer.from(a, \"utf8\"), Buffer.from(b, \"utf8\"));\n}\n\nconst DEFAULT_LOOPBACK_HOSTNAMES = [\"127.0.0.1\", \"localhost\", \"::1\"] as const;\n\n// Extract the hostname portion of a Host header value, stripping the port.\n// IPv6 hosts arrive bracketed (`[::1]:5810`); plain hosts as `host:port`\n// or bare `host`. Returns lowercased hostname or null on shapes we don't\n// recognise (caller treats null as \"reject\").\nfunction parseHostHeader(value: string): string | null {\n const trimmed = value.trim();\n if (trimmed === \"\") return null;\n if (trimmed.startsWith(\"[\")) {\n const close = trimmed.indexOf(\"]\");\n if (close === -1) return null;\n return trimmed.slice(1, close).toLowerCase();\n }\n const colon = trimmed.indexOf(\":\");\n return (colon === -1 ? trimmed : trimmed.slice(0, colon)).toLowerCase();\n}\n\n// DNS-rebinding defence: every request must arrive with a Host header\n// pointing at one of the allowed names. Same gate applies to Origin when\n// the browser sends one. Absent Origin is fine — many legitimate\n// same-origin requests omit it. `Origin: null` is allowed because\n// sandboxed iframes and `file://` pages send it; the Host check still\n// constrains the network path so the Origin allowance isn't load-bearing.\nfunction isAllowedHost(value: string | undefined, allowed: Set<string>): boolean {\n const host = value === undefined ? null : parseHostHeader(value);\n return host !== null && allowed.has(host);\n}\n\nfunction isAllowedOrigin(value: string | undefined, allowed: Set<string>): boolean {\n if (value === undefined || value === \"\" || value === \"null\") return true;\n try {\n // WHATWG URL keeps the brackets on IPv6 hostnames (`[::1]`), but the\n // allow-list stores them stripped (matching parseHostHeader's output)\n // so origins and hosts compare consistently.\n let hostname = new URL(value).hostname.toLowerCase();\n if (hostname.startsWith(\"[\") && hostname.endsWith(\"]\")) {\n hostname = hostname.slice(1, -1);\n }\n return allowed.has(hostname);\n } catch {\n return false;\n }\n}\n\nexport async function startDevServer(opts: DevServerOptions): Promise<DevServer> {\n const {\n view,\n data,\n viewsRoot,\n mutations = {},\n title = \"ui-leaf\",\n port,\n openBrowser = true,\n shell = \"tab\",\n heartbeatTimeoutMs = 75_000,\n startupGraceMs = 30_000,\n csp,\n allowedHosts,\n silent = false,\n } = opts;\n const cspHeader = resolveCsp(csp);\n const allowedHostSet = new Set<string>(DEFAULT_LOOPBACK_HOSTNAMES);\n for (const h of allowedHosts ?? []) allowedHostSet.add(h.toLowerCase());\n const allowedHostList = [...allowedHostSet].join(\", \");\n\n // Resolve port: 0 means \"let the OS pick\" — but rsbuild doesn't honor\n // port: 0 (it literally binds to 0 and reports back 0). Pre-allocate a\n // free port via Node's net layer and pass that explicit port to rsbuild\n // instead. Default 5810 if no port specified.\n const resolvedPort = port === 0 ? await findFreePort() : (port ?? 5810);\n\n // Programmatic consumers (esp. non-Node CLIs spawning ui-leaf as a\n // subprocess) often reserve stdout for a structured protocol. Belt-and-\n // -suspenders: rsbuild's logLevel:'silent' catches its own logger output,\n // and process.stdout.write is redirected to stderr to catch anything\n // that bypasses rsbuild's logger (banner before logger init, third-party\n // module writes, etc.).\n const restoreStdout: (() => void) | null = silent ? redirectStdoutToStderr() : null;\n\n // Hoisted so the outer catch can sweep them on a setup-time throw.\n let tempDir: string | null = null;\n let cleanupOnExit: (() => void) | null = null;\n\n try {\n if (view.includes(\"/\") || view.includes(\"\\\\\")) {\n throw new Error(\n `ui-leaf: view '${view}' must be a bare identifier with no path separators`,\n );\n }\n const viewsRootAbs = resolve(viewsRoot);\n const viewAbs = resolve(viewsRootAbs, `${view}.tsx`);\n if (!viewAbs.startsWith(viewsRootAbs + sep)) {\n throw new Error(\n `ui-leaf: view '${view}' resolves outside viewsRoot`,\n );\n }\n try {\n await stat(viewAbs);\n } catch {\n throw new Error(\n `ui-leaf: view '${view}' not found at ${viewAbs} (looked for .tsx; viewsRoot=${viewsRoot})`,\n );\n }\n\n const token = randomBytes(32).toString(\"hex\");\n tempDir = await mkdtemp(join(tmpdir(), \"ui-leaf-\"));\n\n // Synchronous fallback for any exit path that bypasses cleanup() —\n // uncaught throws and programmatic process.exit. SIGKILL, OOM-kill,\n // and power loss skip every Node hook, so the next startup's sweep is\n // the backstop for those. rmSync with force is idempotent when the\n // dir is already gone, so this no-ops safely if cleanup() ran first.\n const dirToSweep = tempDir;\n cleanupOnExit = (): void => {\n try {\n rmSync(dirToSweep, { recursive: true, force: true });\n } catch {\n // Exit handlers must not throw.\n }\n };\n process.on(\"exit\", cleanupOnExit);\n\n // Mop up any abandoned ui-leaf-* dirs from prior crashed runs that\n // SIGKILL'd or otherwise skipped both cleanup() and the exit handler.\n // Fire-and-forget so the sweep never blocks startup.\n void sweepStaleTempDirs();\n\n const entryPath = join(dirToSweep, \"entry.tsx\");\n await writeFile(\n entryPath,\n `import { createRoot } from \"react-dom/client\";\nimport View from ${JSON.stringify(viewAbs)};\n\nconst ctx = (globalThis).__UI_LEAF__ || {};\nconst data = ctx.data;\nconst token = ctx.token;\n\nasync function mutate(name, args) {\n const res = await fetch(\"/mutate\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n ...(token ? { Authorization: \"Bearer \" + token } : {}),\n },\n body: JSON.stringify({ name, args }),\n });\n const text = await res.text().catch(function () { return \"\"; });\n if (!res.ok) {\n var detail = text;\n try {\n var parsed = text ? JSON.parse(text) : null;\n if (parsed && typeof parsed === \"object\" && typeof parsed.error === \"string\") {\n detail = parsed.error;\n }\n } catch (_) { /* keep raw text */ }\n throw new Error(\"ui-leaf: mutation '\" + name + \"' failed (\" + res.status + \"): \" + detail);\n }\n return text ? JSON.parse(text) : undefined;\n}\n\nasync function heartbeat() {\n try {\n await fetch(\"/heartbeat\", {\n method: \"POST\",\n headers: token ? { Authorization: \"Bearer \" + token } : {},\n });\n } catch {\n /* server may have shut down; ignore */\n }\n}\nsetInterval(heartbeat, 5000);\nheartbeat();\n\nconst el = document.getElementById(\"root\");\nif (!el) throw new Error(\"ui-leaf: #root element missing\");\ncreateRoot(el).render(<View data={data} mutate={mutate} />);\n`,\n );\n\n // Double-stringify encodes data as a JS string literal so the inline\n // assignment uses JSON.parse(…) at load time rather than an object literal.\n // This sidesteps ECMAScript Annex B.3.1, which lets a `__proto__` key in\n // an object literal mutate the prototype — JSON.parse has no such carve-out.\n const dataInline = escapeForScriptTag(JSON.stringify(JSON.stringify(data)));\n const tokenInline = JSON.stringify(token);\n const titleEscaped = title\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\");\n const htmlPath = join(dirToSweep, \"index.html\");\n await writeFile(\n htmlPath,\n `<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <title>${titleEscaped}</title>\n <script>window.__UI_LEAF__ = { data: JSON.parse(${dataInline}), token: ${tokenInline} };</script>\n </head>\n <body>\n <div id=\"root\"></div>\n </body>\n</html>\n`,\n );\n\n let lastHeartbeatAt = Date.now();\n let closeRequested = false;\n let resolveClosed: () => void = () => {};\n const closed = new Promise<void>((r) => {\n resolveClosed = r;\n });\n\n function checkAuth(req: IncomingMessage): boolean {\n const header = req.headers.authorization ?? \"\";\n const match = /^Bearer (.+)$/.exec(header);\n if (!match) return false;\n return timingSafeEqual(match[1]!, token);\n }\n\n function sendJson(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, { \"Content-Type\": \"application/json\" });\n res.end(body === undefined ? \"\" : JSON.stringify(body));\n }\n\n const rsbuild = await createRsbuild({\n cwd: dirToSweep,\n rsbuildConfig: {\n plugins: [pluginReact()],\n ...(silent ? { logLevel: \"silent\" as const } : {}),\n source: { entry: { index: entryPath } },\n // 5810 is unused by the major Node dev tools (vite=5173, parcel=1234,\n // webpack=8080, next/CRA=3000). rsbuild auto-bumps to the next free\n // port if 5810 is busy, so collisions are graceful.\n server: { port: resolvedPort, host: \"127.0.0.1\" },\n // Note: `dev.setupMiddlewares` is deprecated as of rsbuild 2.x in\n // favor of `server.setup`, but the new API has a different signature\n // and bypasses the rsbuild CSRF middleware in ways that break our\n // POST endpoints. Sticking with the deprecated path for v1.\n dev: {\n setupMiddlewares: [\n (middlewares) => {\n middlewares.unshift(async (req, res, next) => {\n // DNS-rebinding gate: reject anything not arriving with an\n // allowed Host (and Origin, when present) before any auth\n // or routing runs. Done in ui-leaf's own middleware rather\n // than relying on rsbuild so the property is explicit and\n // survives upstream API churn.\n const hostOk = isAllowedHost(req.headers.host, allowedHostSet);\n const originOk = isAllowedOrigin(req.headers.origin, allowedHostSet);\n if (!hostOk || !originOk) {\n const offender = !hostOk\n ? `Host \"${req.headers.host ?? \"(absent)\"}\"`\n : `Origin \"${req.headers.origin}\"`;\n res.statusCode = 403;\n res.setHeader(\"Content-Type\", \"text/plain; charset=utf-8\");\n res.end(\n `ui-leaf: refusing request with ${offender} — only the following hostnames are accepted to prevent DNS rebinding: ${allowedHostList}. Open the dev server at http://localhost:${resolvedPort}/ or http://127.0.0.1:${resolvedPort}/, or pass { allowedHosts: [\"my-alias\"] } to mount() to permit a custom alias.\\n`,\n );\n return;\n }\n const url = req.url ?? \"\";\n if (req.method === \"POST\" && url === \"/heartbeat\") {\n if (!checkAuth(req)) {\n sendJson(res, 401, { error: \"unauthorized\" });\n return;\n }\n lastHeartbeatAt = Date.now();\n sendJson(res, 204, undefined);\n return;\n }\n if (req.method === \"POST\" && url === \"/mutate\") {\n if (!checkAuth(req)) {\n sendJson(res, 401, { error: \"unauthorized\" });\n return;\n }\n let body: { name?: string; args?: unknown };\n try {\n body = (await readJsonBody(req)) as typeof body;\n } catch (err) {\n sendJson(res, 400, {\n error: err instanceof Error ? err.message : \"bad request\",\n });\n return;\n }\n const name = body?.name;\n if (typeof name !== \"string\" || name.length === 0) {\n sendJson(res, 400, { error: \"missing mutation name\" });\n return;\n }\n if (!Object.hasOwn(mutations, name)) {\n sendJson(res, 404, {\n error: `ui-leaf: no mutation handler registered for '${name}'. Add it to the mutations: { } map passed to mount().`,\n });\n return;\n }\n const handler = mutations[name]!;\n try {\n const result = await handler(body.args);\n sendJson(res, 200, result ?? null);\n } catch (err) {\n sendJson(res, 500, {\n error: err instanceof Error ? err.message : String(err),\n });\n }\n return;\n }\n next();\n });\n // CSP middleware unshifts AFTER the route handler so it ends up\n // at index 0 — runs first on every request, sets the header,\n // then yields to the route or rsbuild static handlers. No-op\n // when csp resolves to null (the \"off\" default).\n if (cspHeader) {\n middlewares.unshift((_req, res, next) => {\n res.setHeader(\"Content-Security-Policy\", cspHeader);\n next();\n });\n }\n },\n ],\n },\n html: { template: htmlPath },\n tools: {\n rspack: {\n resolve: {\n alias: {\n react: reactPath,\n \"react-dom\": reactDomPath,\n },\n },\n },\n },\n },\n });\n\n const devServer = await rsbuild.startDevServer();\n const actualPort = devServer.port;\n const url = `http://127.0.0.1:${actualPort}`;\n const startedAt = Date.now();\n\n // DNS-rebinding gate for WebSocket upgrades. The setupMiddlewares stack\n // above only fires for `request` events — `upgrade` events bypass it\n // entirely and reach rsbuild's HMR socket directly. Prepend a listener\n // on the underlying http server so our Host check runs before rsbuild's\n // HMR upgrade handler; on reject we destroy the socket (pure TCP close,\n // no HTTP response) so no HMR frames can leak to a rebound origin.\n const httpServer = devServer.server.httpServer;\n const upgradeGate = (req: IncomingMessage, socket: Socket): void => {\n if (!isAllowedHost(req.headers.host, allowedHostSet)) {\n socket.destroy();\n }\n };\n httpServer?.prependListener(\"upgrade\", upgradeGate);\n\n let heartbeatWatcher: NodeJS.Timeout | undefined;\n\n const cleanup = async (): Promise<void> => {\n if (closeRequested) return;\n closeRequested = true;\n if (heartbeatWatcher) clearInterval(heartbeatWatcher);\n httpServer?.removeListener(\"upgrade\", upgradeGate);\n await devServer.server.close();\n await rm(dirToSweep, { recursive: true, force: true });\n // Listener is per-mount — unhook so long-lived hosts that mount many\n // views in sequence don't pile up exit handlers.\n if (cleanupOnExit) process.off(\"exit\", cleanupOnExit);\n if (restoreStdout) restoreStdout();\n resolveClosed();\n };\n\n heartbeatWatcher = setInterval(() => {\n const now = Date.now();\n if (now - startedAt < startupGraceMs) return;\n if (now - lastHeartbeatAt > heartbeatTimeoutMs) {\n void cleanup();\n }\n }, 1000);\n\n if (openBrowser) {\n if (shell === \"app\") {\n const launched = await openInAppMode(url);\n if (!launched) {\n process.stderr.write(\n `ui-leaf: shell:\"app\" requested but no Chromium browser found; falling back to default browser tab.\\n`,\n );\n await open(url);\n }\n } else {\n await open(url);\n }\n }\n\n return {\n url,\n port: actualPort,\n closed,\n close: cleanup,\n };\n } catch (err) {\n // Setup failed before close() got wired up, so the caller has no\n // close to call. The user's catch may keep the process alive doing\n // other work, so sweep the (possibly populated) tempDir now and\n // unhook the exit fallback rather than waiting on process exit.\n if (tempDir) {\n try {\n rmSync(tempDir, { recursive: true, force: true });\n } catch {\n // Best-effort; the next startup's sweep will catch it.\n }\n }\n if (cleanupOnExit) process.off(\"exit\", cleanupOnExit);\n restoreStdout?.();\n throw err;\n }\n}\n"],"mappings":"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","names":["resolve","resolve","checkAuth","sendJson","url","resolve","createRequire","require","config","resolve"]}
|
|
1
|
+
{"version":3,"sources":["../src/cli.ts","../src/index.ts","../src/dev-server.ts"],"sourcesContent":["#!/usr/bin/env node\n// ui-leaf CLI — language-neutral entry point for non-Node consumers.\n//\n// Protocol (stdio, line-delimited JSON):\n//\n// STDIN\n// Line 1: config object (view, viewsRoot, data, mutations: [names], …)\n// Line 2+: mutation responses, one per line:\n// {\"type\":\"result\",\"id\":<n>,\"value\":<any>}\n// {\"type\":\"error\",\"id\":<n>,\"message\":\"<text>\"}\n//\n// STDOUT\n// {\"type\":\"ready\",\"url\":\"<url>\",\"port\":<n>} emitted once when the dev server is up\n// {\"type\":\"mutate\",\"id\":<n>,\"name\":\"<s>\",\"args\":<any>} emitted when a view triggers a mutation\n// {\"type\":\"closed\"} emitted on natural close\n// {\"type\":\"error\",\"message\":\"<text>\"} emitted on internal error\n//\n// Lifecycle\n// Exits 0 on natural close (view closed).\n// Exits 1 on internal error.\n// Closing stdin from the parent triggers shutdown (any pending\n// mutations are rejected).\n\nimport { createInterface } from \"node:readline\";\nimport { mount, type MountOptions } from \"./index.js\";\n\n// Capture the real stdout write BEFORE anything (especially mount() with\n// silent: true) gets a chance to redirect process.stdout. The binary's\n// protocol output uses this directly; rsbuild's noise (which goes\n// through process.stdout.write) gets redirected to stderr by silent mode\n// without affecting our protocol channel.\nconst realStdoutWrite = process.stdout.write.bind(process.stdout);\n\nconst args = process.argv.slice(2);\n\nif (args.length === 0 || args[0] === \"--help\" || args[0] === \"-h\") {\n process.stdout.write(\n [\n \"ui-leaf — Customizable browser views, on demand, for any CLI.\",\n \"\",\n \"Usage:\",\n \" ui-leaf mount Read a JSON config from stdin and mount a view.\",\n \" See https://github.com/OpenThinkAi/ui-leaf for\",\n \" the full stdio protocol spec.\",\n \"\",\n \" ui-leaf --version Print version.\",\n \" ui-leaf --help Print this message.\",\n \"\",\n ].join(\"\\n\"),\n );\n process.exit(0);\n}\n\nif (args[0] === \"--version\" || args[0] === \"-v\") {\n // Read version from package.json shipped alongside this binary.\n const { createRequire } = await import(\"node:module\");\n const require = createRequire(import.meta.url);\n const pkg = require(\"../package.json\") as { version: string };\n process.stdout.write(`${pkg.version}\\n`);\n process.exit(0);\n}\n\nif (args[0] !== \"mount\") {\n process.stderr.write(`ui-leaf: unknown command \"${args[0]}\"\\n`);\n process.exit(1);\n}\n\n// `ui-leaf mount --help` (or being run on a TTY without piped input)\n// would otherwise sit silently on stdin. Print the protocol pointer and\n// exit 0 so users discovering the binary land on docs, not a \"broken\"\n// exit code.\nif (args[1] === \"--help\" || args[1] === \"-h\" || process.stdin.isTTY) {\n process.stdout.write(\n [\n \"ui-leaf mount — read a JSON config from stdin and mount a view.\",\n \"\",\n \"Protocol: line-delimited JSON over stdio.\",\n \" stdin line 1 = config object\",\n \" lines 2+ = mutation responses {type:result|error,id,...}\",\n \" stdout {type:ready,url,port}, {type:mutate,id,name,args},\",\n \" {type:closed}, {type:error,message}\",\n \"\",\n \"Full spec: https://github.com/OpenThinkAi/ui-leaf#driving-ui-leaf-from-a-non-node-cli\",\n \"\",\n \"Example:\",\n ` echo '{\"view\":\"spec\",\"viewsRoot\":\"/abs/path\",\"data\":{}}' | ui-leaf mount`,\n \"\",\n ].join(\"\\n\"),\n );\n process.exit(0);\n}\n\ntry {\n await runMount();\n} catch (err) {\n emit({\n type: \"error\",\n message: err instanceof Error ? err.message : String(err),\n });\n process.exit(1);\n}\n\ninterface ConfigRequest {\n view: string;\n viewsRoot: string;\n data?: unknown;\n mutations?: string[];\n title?: string;\n port?: number;\n openBrowser?: boolean;\n shell?: \"tab\" | \"app\";\n csp?: string;\n heartbeatTimeoutMs?: number;\n startupGraceMs?: number;\n}\n\ninterface MutateResult {\n type: \"result\";\n id: number;\n value?: unknown;\n}\n\ninterface MutateError {\n type: \"error\";\n id: number;\n message: string;\n}\n\ntype MutateResponse = MutateResult | MutateError;\n\nfunction emit(event: unknown): void {\n realStdoutWrite(`${JSON.stringify(event)}\\n`);\n}\n\nasync function runMount(): Promise<void> {\n const rl = createInterface({ input: process.stdin });\n let nextId = 0;\n const pending = new Map<\n number,\n { resolve: (value: unknown) => void; reject: (err: Error) => void }\n >();\n\n let configReceived = false;\n let configResolve!: (cfg: ConfigRequest) => void;\n let configReject!: (err: Error) => void;\n const configPromise = new Promise<ConfigRequest>((res, rej) => {\n configResolve = res;\n configReject = rej;\n });\n\n // Track the mounted view so the stdin-close handler can shut it down.\n // Set after `mount()` resolves; null until then.\n // biome-ignore lint/suspicious/noExplicitAny: MountedView shape inferred\n let mountedView: any = null;\n let stdinClosed = false;\n\n rl.on(\"line\", (line) => {\n const trimmed = line.trim();\n if (!trimmed) return;\n\n if (!configReceived) {\n configReceived = true;\n try {\n const config = JSON.parse(trimmed) as ConfigRequest;\n configResolve(config);\n } catch (err) {\n emit({\n type: \"error\",\n message: `failed to parse config JSON: ${err instanceof Error ? err.message : String(err)}`,\n });\n process.exit(1);\n }\n return;\n }\n\n // Mutation response.\n let msg: MutateResponse;\n try {\n msg = JSON.parse(trimmed) as MutateResponse;\n } catch {\n // Malformed line; ignore (consumer should be using the protocol).\n return;\n }\n const p = pending.get(msg.id);\n if (!p) return;\n pending.delete(msg.id);\n if (msg.type === \"result\") p.resolve(msg.value);\n else if (msg.type === \"error\") p.reject(new Error(msg.message));\n });\n\n rl.on(\"close\", () => {\n stdinClosed = true;\n // Reject any pending mutations — the parent isn't going to respond.\n for (const { reject } of pending.values()) {\n reject(new Error(\"ui-leaf: stdin closed by parent before mutation responded\"));\n }\n pending.clear();\n // If we never received config, the parent dropped before doing\n // anything useful. Bail out with a non-zero exit so that's visible.\n if (!configReceived) {\n configReject(new Error(\"ui-leaf: stdin closed before config received\"));\n return;\n }\n // Otherwise, tear down the mounted view (if it exists yet) so the\n // process exits without waiting on the heartbeat timeout. The\n // view.closed promise resolves and runMount's normal exit path runs.\n if (mountedView) {\n void mountedView.close();\n }\n });\n\n const config = await configPromise;\n\n // Build mutations map: each declared name becomes a handler that emits\n // a mutate event on stdout and awaits a paired response on stdin.\n // biome-ignore lint/suspicious/noExplicitAny: handler signatures vary\n const mutations: Record<string, (args: any) => Promise<unknown>> = {};\n for (const name of config.mutations ?? []) {\n mutations[name] = (mutationArgs: unknown) => {\n const id = ++nextId;\n return new Promise<unknown>((resolve, reject) => {\n pending.set(id, { resolve, reject });\n emit({ type: \"mutate\", id, name, args: mutationArgs });\n });\n };\n }\n\n const mountOpts: MountOptions = {\n view: config.view,\n viewsRoot: config.viewsRoot,\n data: config.data,\n mutations,\n title: config.title,\n port: config.port,\n openBrowser: config.openBrowser,\n shell: config.shell,\n csp: config.csp,\n heartbeatTimeoutMs: config.heartbeatTimeoutMs,\n startupGraceMs: config.startupGraceMs,\n silent: true, // bridge owns stdout; rsbuild output must stay silent\n };\n\n try {\n const view = await mount(mountOpts);\n mountedView = view;\n // If stdin closed while we were waiting on mount(), tear down right\n // away rather than hold the dev server open.\n if (stdinClosed) {\n void view.close();\n }\n emit({ type: \"ready\", url: view.url, port: view.port });\n await view.closed;\n emit({ type: \"closed\" });\n process.exit(0);\n } catch (err) {\n emit({\n type: \"error\",\n message: err instanceof Error ? err.message : String(err),\n });\n process.exit(1);\n }\n}\n","// ui-leaf — Customizable browser views, on demand, for any CLI.\n// https://github.com/OpenThinkAi/ui-leaf\n\nimport { resolve } from \"node:path\";\nimport {\n startDevServer,\n type CspOption,\n type MutationHandler,\n type Shell,\n} from \"./dev-server.js\";\n\nexport type { CspOption, MutationHandler, Shell };\n\nexport interface MountOptions {\n /** View name. Resolves to <viewsRoot>/<view>.tsx. */\n view: string;\n /**\n * JSON-serializable data passed to the view as a prop.\n *\n * Privacy caveat: ui-leaf serialises this payload into\n * `<tmpdir()>/ui-leaf-XXXXXX/index.html` for the mount lifetime, and\n * any same-UID local process that can reach `127.0.0.1:<port>` can\n * fetch `/index.html` and read it — the per-launch token guards\n * `/mutate` against drive-by cross-origin requests in the browser, not\n * against other processes on the machine. For PHI, PCI, financial\n * records, or anything else where a same-UID local reader is in your\n * threat model, use `dataLoader` instead — the loader's return value\n * is served at an authenticated `/api/data` endpoint and never written\n * to disk. See the README section \"Data-at-rest in the temp directory\"\n * and the `dataLoader` field below for details.\n */\n data?: unknown;\n /**\n * Async function that supplies sensitive data to the view without\n * writing it to disk. When provided, the loader is called once during\n * mount setup; its resolved value is served at a token-gated\n * `GET /api/data` endpoint (same per-launch token as `/mutate`) and\n * the view fetches it on first render before calling `createRoot().render()`.\n *\n * Use this instead of `data` for PHI, PCI, financial records, or\n * anything else where disk residency is in your threat model. `data`\n * inlines the payload into `<tmpdir>/ui-leaf-XXXXXX/index.html` for\n * the mount lifetime; `dataLoader` keeps it in memory only.\n *\n * Error semantics: if the loader rejects, the rejection propagates to\n * the `mount()` caller (no automatic retry). Errors surface at mount\n * time, matching the synchronous `data` path's behavior.\n *\n * Mutual exclusion: passing both `data` and `dataLoader` throws at\n * mount time.\n */\n dataLoader?: () => Promise<unknown>;\n /**\n * Mutation handlers the view can call via mutate(name, args).\n * Each handler can self-type its args and return:\n *\n * mutations: {\n * recategorize: async (args: { id: string; category: string }) => {\n * await db.recategorize(args.id, args.category);\n * return { ok: true };\n * },\n * }\n *\n * Each request body is capped at 1 MiB; oversized POSTs are rejected\n * with a 400 and the view's mutate() promise rejects with a clear error.\n */\n // biome-ignore lint/suspicious/noExplicitAny: each handler has its own\n // arg/return types; the map can't share one shape.\n mutations?: Record<string, MutationHandler<any, any>>;\n /** Root directory holding view .tsx files. Defaults to <cwd>/views. */\n viewsRoot?: string;\n /** Browser tab title. Defaults to \"ui-leaf\". */\n title?: string;\n /**\n * Port to bind. Defaults to 5810 — unused by the major Node dev tools.\n * If the port is unavailable, ui-leaf bumps to the next free port and\n * the actual bound port is reflected on the returned `url` and `port`.\n * Override only if you need a stable URL (e.g. an external bookmark).\n */\n port?: number;\n /**\n * Open the browser when ready. Defaults to true. When false, mount()\n * returns the URL on its resolved value so the caller can drive a\n * headless browser, log the address, etc.\n */\n openBrowser?: boolean;\n /**\n * Browser shell. Defaults to \"tab\".\n *\n * - `\"tab\"` — open in the user's default browser as a regular tab.\n * Works everywhere; URL bar is visible.\n *\n * - `\"app\"` — try Chromium's `--app` mode for a chromeless window\n * (no URL bar, no tabs, looks like a desktop app). Available on\n * Chrome, Edge, and Brave. If no Chromium browser is installed,\n * ui-leaf falls back to \"tab\" with a stderr note. Safari and\n * Firefox always fall back.\n *\n * Pair with the share-link pattern (see \"Sharing views across users\"\n * in the README) when you want users to never see a localhost URL.\n */\n shell?: Shell;\n /**\n * Abort to close the dev server early. The returned `closed` promise\n * resolves either way; if you need to distinguish a signal-driven close\n * from a natural tab-close, check `signal.aborted` after the await.\n */\n signal?: AbortSignal;\n /**\n * Browser silence (ms) that triggers shutdown after the startup grace\n * window. Defaults to 75000 — chosen to survive a single browser\n * background-tab throttle (browsers clamp setInterval in hidden tabs to\n * roughly once per minute). Lower it if you want faster shutdown on tab\n * close; raise it if your debugger pauses the page or your machine\n * sleeps mid-session.\n */\n heartbeatTimeoutMs?: number;\n /**\n * Content-Security-Policy enforcement. Defaults to \"off\".\n *\n * - `\"off\"` — no CSP header sent. Views can fetch arbitrary URLs and\n * embed external resources freely. The data/mutations convention is\n * honor-system.\n *\n * - `\"strict\"` — ui-leaf sends a balanced preset: locks `connect-src`\n * to same-origin (the architectural lock — views cannot fetch\n * external APIs, so all data flows through `data` and `mutations`),\n * while permitting common needs (HTTPS images / fonts, inline\n * styles for React, eval for rsbuild HMR). View files can only\n * *add* further restrictions via meta tag, never remove them.\n *\n * - `string` — raw CSP header value for full control. Use when the\n * \"strict\" preset doesn't fit (e.g. you need `connect-src` to\n * include a Sentry endpoint).\n *\n * Trade-off: when set to \"strict\" or a custom string, a view file\n * cannot relax the policy at runtime. Switching back requires changing\n * the mount() call. That rigidity is a feature.\n */\n csp?: CspOption;\n /**\n * Extra hostnames accepted in the request `Host` and `Origin` headers\n * on top of the built-in loopback set (`localhost`, `127.0.0.1`, `[::1]`).\n *\n * The dev server gates every request on this set to defend against\n * DNS-rebinding attacks; non-matching requests get HTTP 403. Use this\n * escape hatch when you need to reach the dev server through a custom\n * `/etc/hosts` alias (e.g. `[\"my-app.local\"]`) or any other loopback\n * name. Hostnames are matched case-insensitively, port-agnostic.\n *\n * Be deliberate: any hostname you add becomes a viable DNS-rebinding\n * target. Don't add wildcards, public DNS names, or LAN hostnames you\n * don't fully control.\n */\n allowedHosts?: string[];\n /**\n * Suppress ui-leaf / rsbuild output to stdout. Default: false.\n *\n * When you drive `mount()` programmatically — e.g. as part of a Node\n * bridge for a non-Node CLI that's spawned ui-leaf as a subprocess —\n * stdout is usually reserved for a structured protocol (line-delimited\n * JSON, etc.). Without this option, rsbuild's banner and build messages\n * collide with that protocol and silently corrupt it on the consumer\n * side.\n *\n * Setting `silent: true`:\n * - Sets rsbuild's logLevel to 'silent' (no banner, build, or\n * deprecation messages emitted by rsbuild)\n * - Redirects `process.stdout.write` to `process.stderr` for the\n * lifetime of the dev server, restored on close\n *\n * Tradeoff: any other code in the same process that writes to stdout\n * during the dev server's lifetime is also redirected. Hold the\n * captured `process.stdout.write` reference yourself if you need to\n * write to the *real* stdout from the same process.\n */\n silent?: boolean;\n /**\n * Grace period (ms) after server start before the heartbeat watcher arms.\n * Cold-loading clients sometimes take a few seconds to send their first\n * heartbeat. Defaults to 30000.\n *\n * If no client connects within (startupGraceMs + heartbeatTimeoutMs),\n * the server shuts down on its own.\n */\n startupGraceMs?: number;\n}\n\nexport interface MountedView {\n /** URL the view is reachable at (http://127.0.0.1:<port>). */\n url: string;\n /** Bound port. Useful when port: 0 was requested. */\n port: number;\n /** Resolves when the view closes (heartbeat timeout) or close() is called. */\n closed: Promise<void>;\n /** Force-close the dev server early. */\n close: () => Promise<void>;\n}\n\n/**\n * Mount a customizable browser view from a CLI. Spins up a local dev server\n * and renders the chosen view with the given data. Returns once the server\n * is ready; await `result.closed` to block until the user closes the\n * browser tab.\n *\n * Mutations triggered in the view are dispatched to the registered handlers\n * here; the view never reaches the CLI's backing API directly.\n *\n * Multi-tab note: if the user opens the served URL in additional tabs (or\n * duplicates the tab), each tab heartbeats independently and the server\n * stays alive while *any* tab is open. Closing the original tab does not\n * shut down the CLI if a duplicate is still loaded.\n *\n * Ctrl+C: this function installs SIGINT and SIGTERM handlers that close\n * the dev server (and clean up its temp directory) before exiting.\n */\nexport async function mount(opts: MountOptions): Promise<MountedView> {\n const viewsRoot = opts.viewsRoot ?? resolve(process.cwd(), \"views\");\n\n const server = await startDevServer({\n view: opts.view,\n data: opts.data,\n dataLoader: opts.dataLoader,\n viewsRoot,\n mutations: opts.mutations,\n title: opts.title,\n port: opts.port,\n openBrowser: opts.openBrowser,\n shell: opts.shell,\n heartbeatTimeoutMs: opts.heartbeatTimeoutMs,\n startupGraceMs: opts.startupGraceMs,\n csp: opts.csp,\n allowedHosts: opts.allowedHosts,\n silent: opts.silent,\n });\n\n const onSignal = (signal: NodeJS.Signals): void => {\n void (async () => {\n await server.close();\n // Re-raise so default exit codes still apply.\n process.kill(process.pid, signal);\n })();\n };\n const sigint = (): void => onSignal(\"SIGINT\");\n const sigterm = (): void => onSignal(\"SIGTERM\");\n process.once(\"SIGINT\", sigint);\n process.once(\"SIGTERM\", sigterm);\n\n if (opts.signal) {\n if (opts.signal.aborted) {\n process.off(\"SIGINT\", sigint);\n process.off(\"SIGTERM\", sigterm);\n await server.close();\n return {\n url: server.url,\n port: server.port,\n closed: Promise.resolve(),\n close: server.close,\n };\n }\n opts.signal.addEventListener(\n \"abort\",\n () => void server.close(),\n { once: true },\n );\n }\n\n const closed = server.closed.finally(() => {\n process.off(\"SIGINT\", sigint);\n process.off(\"SIGTERM\", sigterm);\n });\n\n return {\n url: server.url,\n port: server.port,\n closed,\n close: server.close,\n };\n}\n","// Spike: dev server with mutation bridge + heartbeat-based shutdown.\n// Builds on Spike #1; adds the bridge that makes ui-leaf actually useful.\n\nimport { randomBytes, timingSafeEqual as nodeTimingSafeEqual } from \"node:crypto\";\nimport { rmSync } from \"node:fs\";\nimport { mkdtemp, readdir, rm, stat, writeFile } from \"node:fs/promises\";\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { createRequire } from \"node:module\";\nimport { createServer as createTcpServer, type Socket } from \"node:net\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join, resolve, sep } from \"node:path\";\nimport { createRsbuild } from \"@rsbuild/core\";\nimport { pluginReact } from \"@rsbuild/plugin-react\";\nimport open, { apps } from \"open\";\n\n// Resolve react / react-dom from ui-leaf's installed location using\n// Node's actual resolver. With hoisting (npm/pnpm/bun), these end up in\n// the consumer's top-level node_modules, NOT under ui-leaf/node_modules.\n// Aliasing the resolved directory paths in rspack lets the bundled view\n// always find react no matter where the package manager put it.\nconst uiLeafRequire = createRequire(import.meta.url);\nconst reactPath = dirname(uiLeafRequire.resolve(\"react/package.json\"));\nconst reactDomPath = dirname(uiLeafRequire.resolve(\"react-dom/package.json\"));\n\n// Module-level stdout redirect state. Captured ONCE at module load so\n// concurrent silent: true mounts share the same \"original\" reference and\n// restore-order doesn't matter. Refcounted so the last close restores.\nconst ORIGINAL_STDOUT_WRITE = process.stdout.write.bind(process.stdout);\nlet stdoutRedirectCount = 0;\n\n/**\n * Ask the OS for a free port and return it. Used when the consumer\n * requests `port: 0`. There's a small race window between close() and\n * rsbuild's bind, but in practice it's never been an issue for dev\n * tooling.\n */\nasync function findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const server = createTcpServer();\n server.unref();\n server.on(\"error\", reject);\n server.listen(0, \"127.0.0.1\", () => {\n const addr = server.address();\n if (!addr || typeof addr !== \"object\") {\n server.close();\n reject(new Error(\"ui-leaf: failed to obtain a free port from the OS\"));\n return;\n }\n const port = addr.port;\n server.close(() => resolve(port));\n });\n });\n}\n\n// Stale-tempdir sweep threshold. Each mount writes index.html with the\n// consumer's data inlined; a crashed run leaves the dir behind until OS\n// rotation. 24h is well past any realistic single-session lifetime and\n// well short of the macOS reboot-only rotation cadence.\nconst STALE_TEMPDIR_AGE_MS = 24 * 60 * 60 * 1000;\n\n/**\n * Best-effort removal of `ui-leaf-*` siblings in `tmpdir()` whose mtime\n * is older than the stale threshold. Fire-and-forget; never throws.\n * Filters by mtime so concurrent ui-leaf processes' active dirs are left\n * alone (they're created fresh per mount).\n */\nasync function sweepStaleTempDirs(): Promise<void> {\n try {\n const root = tmpdir();\n const entries = await readdir(root, { withFileTypes: true });\n const cutoff = Date.now() - STALE_TEMPDIR_AGE_MS;\n await Promise.all(\n entries\n .filter((e) => e.isDirectory() && e.name.startsWith(\"ui-leaf-\"))\n .map(async (e) => {\n const path = join(root, e.name);\n try {\n const info = await stat(path);\n if (info.mtimeMs < cutoff) {\n await rm(path, { recursive: true, force: true });\n }\n } catch {\n // Another process may be mid-cleanup, or the dir may belong\n // to a different UID; either way, skip silently.\n }\n }),\n );\n } catch {\n // tmpdir() unreadable is rare and not actionable here.\n }\n}\n\n/**\n * Redirect process.stdout.write to process.stderr until the returned\n * function is called. Safe under concurrent silent mounts.\n */\nfunction redirectStdoutToStderr(): () => void {\n stdoutRedirectCount++;\n if (stdoutRedirectCount === 1) {\n // biome-ignore lint/suspicious/noExplicitAny: stdout.write has overloaded\n // signatures; forward exactly what comes in.\n process.stdout.write = ((chunk: any, enc?: any, cb?: any) =>\n process.stderr.write(chunk, enc, cb)) as typeof process.stdout.write;\n }\n let released = false;\n return () => {\n if (released) return;\n released = true;\n stdoutRedirectCount--;\n if (stdoutRedirectCount === 0) {\n process.stdout.write = ORIGINAL_STDOUT_WRITE;\n }\n };\n}\n\nexport type MutationHandler<TArgs = unknown, TResult = unknown> = (\n args: TArgs,\n) => TResult | Promise<TResult>;\n\n// `(string & {})` preserves the \"off\" / \"strict\" autocomplete suggestions\n// while still allowing arbitrary CSP strings. Plain string would collapse\n// the union and lose IntelliSense for the literals.\nexport type CspOption = \"off\" | \"strict\" | (string & {});\n\nexport type Shell = \"tab\" | \"app\";\n\n/**\n * Try to open `url` in a Chromium browser's --app mode (chromeless window:\n * no URL bar, no tabs). Returns true if a Chromium browser was found and\n * launched, false if no Chromium variant is installed (caller should fall\n * back to the default-browser tab).\n */\nasync function openInAppMode(url: string): Promise<boolean> {\n // Order: most-common Chromium variants first.\n const candidates = [apps.chrome, apps.edge, apps.brave];\n for (const app of candidates) {\n try {\n await open(url, { app: { name: app, arguments: [`--app=${url}`] } });\n return true;\n } catch {\n // Try next candidate; `open` throws if the binary isn't installed.\n }\n }\n return false;\n}\n\n/**\n * Strict preset: locks `connect-src` to same-origin (the architectural\n * lock that forces views to route mutations through the CLI), while\n * permitting common needs (HTTPS images/fonts, inline styles for React,\n * eval/inline-scripts for rsbuild's HMR client). A future \"production\"\n * mode could ship a tighter preset once HMR isn't in the picture.\n */\nconst STRICT_CSP = [\n \"default-src 'self'\",\n \"connect-src 'self'\",\n \"img-src 'self' data: https:\",\n \"font-src 'self' https: data:\",\n \"style-src 'self' 'unsafe-inline'\",\n \"script-src 'self' 'unsafe-eval' 'unsafe-inline'\",\n].join(\"; \");\n\nfunction resolveCsp(opt: CspOption | undefined): string | null {\n if (!opt || opt === \"off\") return null;\n if (opt === \"strict\") return STRICT_CSP;\n return opt;\n}\n\nexport interface DevServerOptions {\n view: string;\n data?: unknown;\n dataLoader?: () => Promise<unknown>;\n viewsRoot: string;\n // biome-ignore lint/suspicious/noExplicitAny: each handler has its own\n // arg/return types; the map can't share one shape.\n mutations?: Record<string, MutationHandler<any, any>>;\n /** Browser tab title. Defaults to \"ui-leaf\". */\n title?: string;\n port?: number;\n openBrowser?: boolean;\n /**\n * Browser shell. Defaults to \"tab\".\n *\n * - \"tab\" — open in user's default browser as a regular tab.\n * - \"app\" — try Chromium's --app mode (chromeless window). Falls back\n * to \"tab\" if no Chromium browser is installed (Chrome/Edge/Brave),\n * with a stderr note. Safari and Firefox always fall back.\n */\n shell?: Shell;\n /** Heartbeat-stop window in ms. Browser silence longer than this triggers shutdown. */\n heartbeatTimeoutMs?: number;\n /** Grace period after server start before the heartbeat watcher is armed. */\n startupGraceMs?: number;\n /** Content-Security-Policy enforcement. See MountOptions.csp. */\n csp?: CspOption;\n /**\n * Extra hostnames (beyond `localhost`, `127.0.0.1`, `[::1]`) accepted in\n * the request `Host` and `Origin` headers. Use to allow a custom\n * `/etc/hosts` alias or another loopback name; values are matched by\n * hostname only (port-agnostic). Anything outside this set + the\n * loopback defaults is rejected with HTTP 403 to defend against\n * DNS-rebinding attacks. Default: empty.\n */\n allowedHosts?: string[];\n /**\n * Suppress ui-leaf / rsbuild output to stdout. When true:\n * - rsbuildConfig.logLevel is set to 'silent' (no banner, build, or\n * deprecation messages)\n * - process.stdout.write is redirected to process.stderr for the\n * lifetime of the dev server, restored on close()\n *\n * Use when driving mount() programmatically and stdout is reserved for\n * a structured protocol (e.g. line-delimited JSON to a parent process).\n * Default: false.\n */\n silent?: boolean;\n}\n\nexport interface DevServer {\n url: string;\n port: number;\n /** Resolves when the view is closed (heartbeat timeout) or close() is called. */\n closed: Promise<void>;\n close: () => Promise<void>;\n}\n\nfunction escapeForScriptTag(json: string): string {\n // Defend against </script> break-out and U+2028 / U+2029 line terminators\n // that JSON.stringify emits raw but JS string literals don't accept.\n return json\n .replace(/</g, \"\\\\u003c\")\n .replace(/\\u2028/g, \"\\\\u2028\")\n .replace(/\\u2029/g, \"\\\\u2029\");\n}\n\nasync function readJsonBody(req: IncomingMessage): Promise<unknown> {\n const chunks: Buffer[] = [];\n let total = 0;\n for await (const chunk of req) {\n const buf = chunk as Buffer;\n total += buf.length;\n // 1 MiB cap per request — protects against accidental huge payloads.\n if (total > 1024 * 1024) {\n throw new Error(\"request body exceeds 1 MiB limit\");\n }\n chunks.push(buf);\n }\n const text = Buffer.concat(chunks).toString(\"utf8\");\n return text ? JSON.parse(text) : undefined;\n}\n\nfunction timingSafeEqual(a: string, b: string): boolean {\n // Length check is not timing-safe but is fine — the token length is fixed\n // and known to attackers regardless. The byte compare must be timing-safe.\n if (a.length !== b.length) return false;\n return nodeTimingSafeEqual(Buffer.from(a, \"utf8\"), Buffer.from(b, \"utf8\"));\n}\n\nconst DEFAULT_LOOPBACK_HOSTNAMES = [\"127.0.0.1\", \"localhost\", \"::1\"] as const;\n\n// Extract the hostname portion of a Host header value, stripping the port.\n// IPv6 hosts arrive bracketed (`[::1]:5810`); plain hosts as `host:port`\n// or bare `host`. Returns lowercased hostname or null on shapes we don't\n// recognise (caller treats null as \"reject\").\nfunction parseHostHeader(value: string): string | null {\n const trimmed = value.trim();\n if (trimmed === \"\") return null;\n if (trimmed.startsWith(\"[\")) {\n const close = trimmed.indexOf(\"]\");\n if (close === -1) return null;\n return trimmed.slice(1, close).toLowerCase();\n }\n const colon = trimmed.indexOf(\":\");\n return (colon === -1 ? trimmed : trimmed.slice(0, colon)).toLowerCase();\n}\n\n// DNS-rebinding defence: every request must arrive with a Host header\n// pointing at one of the allowed names. Same gate applies to Origin when\n// the browser sends one. Absent Origin is fine — many legitimate\n// same-origin requests omit it. `Origin: null` is allowed because\n// sandboxed iframes and `file://` pages send it; the Host check still\n// constrains the network path so the Origin allowance isn't load-bearing.\nfunction isAllowedHost(value: string | undefined, allowed: Set<string>): boolean {\n const host = value === undefined ? null : parseHostHeader(value);\n return host !== null && allowed.has(host);\n}\n\nfunction isAllowedOrigin(value: string | undefined, allowed: Set<string>): boolean {\n if (value === undefined || value === \"\" || value === \"null\") return true;\n try {\n // WHATWG URL keeps the brackets on IPv6 hostnames (`[::1]`), but the\n // allow-list stores them stripped (matching parseHostHeader's output)\n // so origins and hosts compare consistently.\n let hostname = new URL(value).hostname.toLowerCase();\n if (hostname.startsWith(\"[\") && hostname.endsWith(\"]\")) {\n hostname = hostname.slice(1, -1);\n }\n return allowed.has(hostname);\n } catch {\n return false;\n }\n}\n\nexport async function startDevServer(opts: DevServerOptions): Promise<DevServer> {\n const {\n view,\n data,\n dataLoader,\n viewsRoot,\n mutations = {},\n title = \"ui-leaf\",\n port,\n openBrowser = true,\n shell = \"tab\",\n heartbeatTimeoutMs = 75_000,\n startupGraceMs = 30_000,\n csp,\n allowedHosts,\n silent = false,\n } = opts;\n const cspHeader = resolveCsp(csp);\n const allowedHostSet = new Set<string>(DEFAULT_LOOPBACK_HOSTNAMES);\n for (const h of allowedHosts ?? []) allowedHostSet.add(h.toLowerCase());\n const allowedHostList = [...allowedHostSet].join(\", \");\n\n // Resolve port: 0 means \"let the OS pick\" — but rsbuild doesn't honor\n // port: 0 (it literally binds to 0 and reports back 0). Pre-allocate a\n // free port via Node's net layer and pass that explicit port to rsbuild\n // instead. Default 5810 if no port specified.\n const resolvedPort = port === 0 ? await findFreePort() : (port ?? 5810);\n\n // Programmatic consumers (esp. non-Node CLIs spawning ui-leaf as a\n // subprocess) often reserve stdout for a structured protocol. Belt-and-\n // -suspenders: rsbuild's logLevel:'silent' catches its own logger output,\n // and process.stdout.write is redirected to stderr to catch anything\n // that bypasses rsbuild's logger (banner before logger init, third-party\n // module writes, etc.).\n const restoreStdout: (() => void) | null = silent ? redirectStdoutToStderr() : null;\n\n // Hoisted so the outer catch can sweep them on a setup-time throw.\n let tempDir: string | null = null;\n let cleanupOnExit: (() => void) | null = null;\n\n try {\n if (view.includes(\"/\") || view.includes(\"\\\\\")) {\n throw new Error(\n `ui-leaf: view '${view}' must be a bare identifier with no path separators`,\n );\n }\n const viewsRootAbs = resolve(viewsRoot);\n const viewAbs = resolve(viewsRootAbs, `${view}.tsx`);\n if (!viewAbs.startsWith(viewsRootAbs + sep)) {\n throw new Error(\n `ui-leaf: view '${view}' resolves outside viewsRoot`,\n );\n }\n try {\n await stat(viewAbs);\n } catch {\n throw new Error(\n `ui-leaf: view '${view}' not found at ${viewAbs} (looked for .tsx; viewsRoot=${viewsRoot})`,\n );\n }\n\n if (data !== undefined && dataLoader) {\n throw new Error(\"ui-leaf: pass data or dataLoader, not both\");\n }\n\n const token = randomBytes(32).toString(\"hex\");\n tempDir = await mkdtemp(join(tmpdir(), \"ui-leaf-\"));\n\n // Synchronous fallback for any exit path that bypasses cleanup() —\n // uncaught throws and programmatic process.exit. SIGKILL, OOM-kill,\n // and power loss skip every Node hook, so the next startup's sweep is\n // the backstop for those. rmSync with force is idempotent when the\n // dir is already gone, so this no-ops safely if cleanup() ran first.\n const dirToSweep = tempDir;\n cleanupOnExit = (): void => {\n try {\n rmSync(dirToSweep, { recursive: true, force: true });\n } catch {\n // Exit handlers must not throw.\n }\n };\n process.on(\"exit\", cleanupOnExit);\n\n // Mop up any abandoned ui-leaf-* dirs from prior crashed runs that\n // SIGKILL'd or otherwise skipped both cleanup() and the exit handler.\n // Fire-and-forget so the sweep never blocks startup.\n void sweepStaleTempDirs();\n\n // Eagerly invoke the loader before writing any files. The resolved value\n // lives only in this closure — it is never written to disk. If the loader\n // rejects, the setup-failure catch below sweeps tempDir and removes the\n // exit fallback before re-throwing, so nothing lingers on disk.\n let loadedData: unknown;\n if (dataLoader) {\n loadedData = await dataLoader();\n }\n\n const entryPath = join(dirToSweep, \"entry.tsx\");\n\n // Shared browser-side functions: same across both entry variants.\n const sharedEntryFns = `async function mutate(name, args) {\n const res = await fetch(\"/mutate\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n ...(token ? { Authorization: \"Bearer \" + token } : {}),\n },\n body: JSON.stringify({ name, args }),\n });\n const text = await res.text().catch(function () { return \"\"; });\n if (!res.ok) {\n var detail = text;\n try {\n var parsed = text ? JSON.parse(text) : null;\n if (parsed && typeof parsed === \"object\" && typeof parsed.error === \"string\") {\n detail = parsed.error;\n }\n } catch (_) { /* keep raw text */ }\n throw new Error(\"ui-leaf: mutation '\" + name + \"' failed (\" + res.status + \"): \" + detail);\n }\n return text ? JSON.parse(text) : undefined;\n}\n\nasync function heartbeat() {\n try {\n await fetch(\"/heartbeat\", {\n method: \"POST\",\n headers: token ? { Authorization: \"Bearer \" + token } : {},\n });\n } catch {\n /* server may have shut down; ignore */\n }\n}\nsetInterval(heartbeat, 5000);\nheartbeat();`;\n\n await writeFile(\n entryPath,\n dataLoader\n ? `import { createRoot } from \"react-dom/client\";\nimport View from ${JSON.stringify(viewAbs)};\n\nconst ctx = (globalThis).__UI_LEAF__ || {};\nconst token = ctx.token;\n\n${sharedEntryFns}\n\n// Heartbeat runs outside bootstrap so a slow loader cannot trip the watcher.\nasync function bootstrap() {\n const res = await fetch(\"/api/data\", {\n headers: token ? { Authorization: \"Bearer \" + token } : {},\n });\n if (!res.ok) {\n const text = await res.text().catch(function () { return \"\"; });\n throw new Error(\"ui-leaf: /api/data fetch failed (\" + res.status + \"): \" + text);\n }\n const data = await res.json();\n const el = document.getElementById(\"root\");\n if (!el) throw new Error(\"ui-leaf: #root element missing\");\n createRoot(el).render(<View data={data} mutate={mutate} />);\n}\nbootstrap();\n`\n : `import { createRoot } from \"react-dom/client\";\nimport View from ${JSON.stringify(viewAbs)};\n\nconst ctx = (globalThis).__UI_LEAF__ || {};\nconst data = ctx.data;\nconst token = ctx.token;\n\n${sharedEntryFns}\n\nconst el = document.getElementById(\"root\");\nif (!el) throw new Error(\"ui-leaf: #root element missing\");\ncreateRoot(el).render(<View data={data} mutate={mutate} />);\n`,\n );\n\n // Double-stringify encodes data as a JS string literal so the inline\n // assignment uses JSON.parse(…) at load time rather than an object literal.\n // This sidesteps ECMAScript Annex B.3.1, which lets a `__proto__` key in\n // an object literal mutate the prototype — JSON.parse has no such carve-out.\n const tokenInline = JSON.stringify(token);\n const titleEscaped = title\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\");\n const htmlPath = join(dirToSweep, \"index.html\");\n // When dataLoader is set, omit data from the inline script — the loader\n // value is served at /api/data and never written to disk.\n const scriptContent = dataLoader\n ? `{ token: ${tokenInline} }`\n : `{ data: JSON.parse(${escapeForScriptTag(JSON.stringify(JSON.stringify(data ?? null)))}), token: ${tokenInline} }`;\n await writeFile(\n htmlPath,\n `<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <title>${titleEscaped}</title>\n <script>window.__UI_LEAF__ = ${scriptContent};</script>\n </head>\n <body>\n <div id=\"root\"></div>\n </body>\n</html>\n`,\n );\n\n let lastHeartbeatAt = Date.now();\n let closeRequested = false;\n let resolveClosed: () => void = () => {};\n const closed = new Promise<void>((r) => {\n resolveClosed = r;\n });\n\n function checkAuth(req: IncomingMessage): boolean {\n const header = req.headers.authorization ?? \"\";\n const match = /^Bearer (.+)$/.exec(header);\n if (!match) return false;\n return timingSafeEqual(match[1]!, token);\n }\n\n function sendJson(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, { \"Content-Type\": \"application/json\" });\n res.end(body === undefined ? \"\" : JSON.stringify(body));\n }\n\n const rsbuild = await createRsbuild({\n cwd: dirToSweep,\n rsbuildConfig: {\n plugins: [pluginReact()],\n ...(silent ? { logLevel: \"silent\" as const } : {}),\n source: { entry: { index: entryPath } },\n // 5810 is unused by the major Node dev tools (vite=5173, parcel=1234,\n // webpack=8080, next/CRA=3000). rsbuild auto-bumps to the next free\n // port if 5810 is busy, so collisions are graceful.\n server: { port: resolvedPort, host: \"127.0.0.1\" },\n // Note: `dev.setupMiddlewares` is deprecated as of rsbuild 2.x in\n // favor of `server.setup`, but the new API has a different signature\n // and bypasses the rsbuild CSRF middleware in ways that break our\n // POST endpoints. Sticking with the deprecated path for v1.\n dev: {\n setupMiddlewares: [\n (middlewares) => {\n middlewares.unshift(async (req, res, next) => {\n // DNS-rebinding gate: reject anything not arriving with an\n // allowed Host (and Origin, when present) before any auth\n // or routing runs. Done in ui-leaf's own middleware rather\n // than relying on rsbuild so the property is explicit and\n // survives upstream API churn.\n const hostOk = isAllowedHost(req.headers.host, allowedHostSet);\n const originOk = isAllowedOrigin(req.headers.origin, allowedHostSet);\n if (!hostOk || !originOk) {\n const offender = !hostOk\n ? `Host \"${req.headers.host ?? \"(absent)\"}\"`\n : `Origin \"${req.headers.origin}\"`;\n res.statusCode = 403;\n res.setHeader(\"Content-Type\", \"text/plain; charset=utf-8\");\n res.end(\n `ui-leaf: refusing request with ${offender} — only the following hostnames are accepted to prevent DNS rebinding: ${allowedHostList}. Open the dev server at http://localhost:${resolvedPort}/ or http://127.0.0.1:${resolvedPort}/, or pass { allowedHosts: [\"my-alias\"] } to mount() to permit a custom alias.\\n`,\n );\n return;\n }\n const url = req.url ?? \"\";\n if (req.method === \"POST\" && url === \"/heartbeat\") {\n if (!checkAuth(req)) {\n sendJson(res, 401, { error: \"unauthorized\" });\n return;\n }\n lastHeartbeatAt = Date.now();\n sendJson(res, 204, undefined);\n return;\n }\n if (req.method === \"POST\" && url === \"/mutate\") {\n if (!checkAuth(req)) {\n sendJson(res, 401, { error: \"unauthorized\" });\n return;\n }\n let body: { name?: string; args?: unknown };\n try {\n body = (await readJsonBody(req)) as typeof body;\n } catch (err) {\n sendJson(res, 400, {\n error: err instanceof Error ? err.message : \"bad request\",\n });\n return;\n }\n const name = body?.name;\n if (typeof name !== \"string\" || name.length === 0) {\n sendJson(res, 400, { error: \"missing mutation name\" });\n return;\n }\n if (!Object.hasOwn(mutations, name)) {\n sendJson(res, 404, {\n error: `ui-leaf: no mutation handler registered for '${name}'. Add it to the mutations: { } map passed to mount().`,\n });\n return;\n }\n const handler = mutations[name]!;\n try {\n const result = await handler(body.args);\n sendJson(res, 200, result ?? null);\n } catch (err) {\n sendJson(res, 500, {\n error: err instanceof Error ? err.message : String(err),\n });\n }\n return;\n }\n if (req.method === \"GET\" && url === \"/api/data\") {\n if (!dataLoader) {\n next();\n return;\n }\n if (!checkAuth(req)) {\n sendJson(res, 401, { error: \"unauthorized\" });\n return;\n }\n sendJson(res, 200, loadedData !== undefined ? loadedData : null);\n return;\n }\n next();\n });\n // CSP middleware unshifts AFTER the route handler so it ends up\n // at index 0 — runs first on every request, sets the header,\n // then yields to the route or rsbuild static handlers. No-op\n // when csp resolves to null (the \"off\" default).\n if (cspHeader) {\n middlewares.unshift((_req, res, next) => {\n res.setHeader(\"Content-Security-Policy\", cspHeader);\n next();\n });\n }\n },\n ],\n },\n html: { template: htmlPath },\n tools: {\n rspack: {\n resolve: {\n alias: {\n react: reactPath,\n \"react-dom\": reactDomPath,\n },\n },\n },\n },\n },\n });\n\n const devServer = await rsbuild.startDevServer();\n const actualPort = devServer.port;\n const url = `http://127.0.0.1:${actualPort}`;\n const startedAt = Date.now();\n\n // DNS-rebinding gate for WebSocket upgrades. The setupMiddlewares stack\n // above only fires for `request` events — `upgrade` events bypass it\n // entirely and reach rsbuild's HMR socket directly. Prepend a listener\n // on the underlying http server so our Host check runs before rsbuild's\n // HMR upgrade handler; on reject we destroy the socket (pure TCP close,\n // no HTTP response) so no HMR frames can leak to a rebound origin.\n const httpServer = devServer.server.httpServer;\n const upgradeGate = (req: IncomingMessage, socket: Socket): void => {\n if (!isAllowedHost(req.headers.host, allowedHostSet)) {\n socket.destroy();\n }\n };\n httpServer?.prependListener(\"upgrade\", upgradeGate);\n\n let heartbeatWatcher: NodeJS.Timeout | undefined;\n\n const cleanup = async (): Promise<void> => {\n if (closeRequested) return;\n closeRequested = true;\n if (heartbeatWatcher) clearInterval(heartbeatWatcher);\n httpServer?.removeListener(\"upgrade\", upgradeGate);\n await devServer.server.close();\n await rm(dirToSweep, { recursive: true, force: true });\n // Listener is per-mount — unhook so long-lived hosts that mount many\n // views in sequence don't pile up exit handlers.\n if (cleanupOnExit) process.off(\"exit\", cleanupOnExit);\n if (restoreStdout) restoreStdout();\n resolveClosed();\n };\n\n heartbeatWatcher = setInterval(() => {\n const now = Date.now();\n if (now - startedAt < startupGraceMs) return;\n if (now - lastHeartbeatAt > heartbeatTimeoutMs) {\n void cleanup();\n }\n }, 1000);\n\n if (openBrowser) {\n if (shell === \"app\") {\n const launched = await openInAppMode(url);\n if (!launched) {\n process.stderr.write(\n `ui-leaf: shell:\"app\" requested but no Chromium browser found; falling back to default browser tab.\\n`,\n );\n await open(url);\n }\n } else {\n await open(url);\n }\n }\n\n return {\n url,\n port: actualPort,\n closed,\n close: cleanup,\n };\n } catch (err) {\n // Setup failed before close() got wired up, so the caller has no\n // close to call. The user's catch may keep the process alive doing\n // other work, so sweep the (possibly populated) tempDir now and\n // unhook the exit fallback rather than waiting on process exit.\n if (tempDir) {\n try {\n rmSync(tempDir, { recursive: true, force: true });\n } catch {\n // Best-effort; the next startup's sweep will catch it.\n }\n }\n if (cleanupOnExit) process.off(\"exit\", cleanupOnExit);\n restoreStdout?.();\n throw err;\n }\n}\n"],"mappings":"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","names":["resolve","resolve","checkAuth","sendJson","url","resolve","createRequire","require","config","resolve"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,8 +3,42 @@ import { M as MutationHandler, S as Shell, C as CspOption } from './dev-server-D
|
|
|
3
3
|
interface MountOptions {
|
|
4
4
|
/** View name. Resolves to <viewsRoot>/<view>.tsx. */
|
|
5
5
|
view: string;
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* JSON-serializable data passed to the view as a prop.
|
|
8
|
+
*
|
|
9
|
+
* Privacy caveat: ui-leaf serialises this payload into
|
|
10
|
+
* `<tmpdir()>/ui-leaf-XXXXXX/index.html` for the mount lifetime, and
|
|
11
|
+
* any same-UID local process that can reach `127.0.0.1:<port>` can
|
|
12
|
+
* fetch `/index.html` and read it — the per-launch token guards
|
|
13
|
+
* `/mutate` against drive-by cross-origin requests in the browser, not
|
|
14
|
+
* against other processes on the machine. For PHI, PCI, financial
|
|
15
|
+
* records, or anything else where a same-UID local reader is in your
|
|
16
|
+
* threat model, use `dataLoader` instead — the loader's return value
|
|
17
|
+
* is served at an authenticated `/api/data` endpoint and never written
|
|
18
|
+
* to disk. See the README section "Data-at-rest in the temp directory"
|
|
19
|
+
* and the `dataLoader` field below for details.
|
|
20
|
+
*/
|
|
21
|
+
data?: unknown;
|
|
22
|
+
/**
|
|
23
|
+
* Async function that supplies sensitive data to the view without
|
|
24
|
+
* writing it to disk. When provided, the loader is called once during
|
|
25
|
+
* mount setup; its resolved value is served at a token-gated
|
|
26
|
+
* `GET /api/data` endpoint (same per-launch token as `/mutate`) and
|
|
27
|
+
* the view fetches it on first render before calling `createRoot().render()`.
|
|
28
|
+
*
|
|
29
|
+
* Use this instead of `data` for PHI, PCI, financial records, or
|
|
30
|
+
* anything else where disk residency is in your threat model. `data`
|
|
31
|
+
* inlines the payload into `<tmpdir>/ui-leaf-XXXXXX/index.html` for
|
|
32
|
+
* the mount lifetime; `dataLoader` keeps it in memory only.
|
|
33
|
+
*
|
|
34
|
+
* Error semantics: if the loader rejects, the rejection propagates to
|
|
35
|
+
* the `mount()` caller (no automatic retry). Errors surface at mount
|
|
36
|
+
* time, matching the synchronous `data` path's behavior.
|
|
37
|
+
*
|
|
38
|
+
* Mutual exclusion: passing both `data` and `dataLoader` throws at
|
|
39
|
+
* mount time.
|
|
40
|
+
*/
|
|
41
|
+
dataLoader?: () => Promise<unknown>;
|
|
8
42
|
/**
|
|
9
43
|
* Mutation handlers the view can call via mutate(name, args).
|
|
10
44
|
* Each handler can self-type its args and return:
|
package/dist/index.js
CHANGED
|
@@ -147,6 +147,7 @@ async function startDevServer(opts) {
|
|
|
147
147
|
const {
|
|
148
148
|
view,
|
|
149
149
|
data,
|
|
150
|
+
dataLoader,
|
|
150
151
|
viewsRoot,
|
|
151
152
|
mutations = {},
|
|
152
153
|
title = "ui-leaf",
|
|
@@ -197,6 +198,9 @@ async function startDevServer(opts) {
|
|
|
197
198
|
`ui-leaf: view '${view}' not found at ${viewAbs} (looked for .tsx; viewsRoot=${viewsRoot})`
|
|
198
199
|
);
|
|
199
200
|
}
|
|
201
|
+
if (data !== void 0 && dataLoader) {
|
|
202
|
+
throw new Error("ui-leaf: pass data or dataLoader, not both");
|
|
203
|
+
}
|
|
200
204
|
const token = randomBytes(32).toString("hex");
|
|
201
205
|
tempDir = await mkdtemp(join(tmpdir(), "ui-leaf-"));
|
|
202
206
|
const dirToSweep = tempDir;
|
|
@@ -208,17 +212,12 @@ async function startDevServer(opts) {
|
|
|
208
212
|
};
|
|
209
213
|
process.on("exit", cleanupOnExit);
|
|
210
214
|
void sweepStaleTempDirs();
|
|
215
|
+
let loadedData;
|
|
216
|
+
if (dataLoader) {
|
|
217
|
+
loadedData = await dataLoader();
|
|
218
|
+
}
|
|
211
219
|
const entryPath = join(dirToSweep, "entry.tsx");
|
|
212
|
-
|
|
213
|
-
entryPath,
|
|
214
|
-
`import { createRoot } from "react-dom/client";
|
|
215
|
-
import View from ${JSON.stringify(viewAbs)};
|
|
216
|
-
|
|
217
|
-
const ctx = (globalThis).__UI_LEAF__ || {};
|
|
218
|
-
const data = ctx.data;
|
|
219
|
-
const token = ctx.token;
|
|
220
|
-
|
|
221
|
-
async function mutate(name, args) {
|
|
220
|
+
const sharedEntryFns = `async function mutate(name, args) {
|
|
222
221
|
const res = await fetch("/mutate", {
|
|
223
222
|
method: "POST",
|
|
224
223
|
headers: {
|
|
@@ -252,17 +251,50 @@ async function heartbeat() {
|
|
|
252
251
|
}
|
|
253
252
|
}
|
|
254
253
|
setInterval(heartbeat, 5000);
|
|
255
|
-
heartbeat()
|
|
254
|
+
heartbeat();`;
|
|
255
|
+
await writeFile(
|
|
256
|
+
entryPath,
|
|
257
|
+
dataLoader ? `import { createRoot } from "react-dom/client";
|
|
258
|
+
import View from ${JSON.stringify(viewAbs)};
|
|
259
|
+
|
|
260
|
+
const ctx = (globalThis).__UI_LEAF__ || {};
|
|
261
|
+
const token = ctx.token;
|
|
262
|
+
|
|
263
|
+
${sharedEntryFns}
|
|
264
|
+
|
|
265
|
+
// Heartbeat runs outside bootstrap so a slow loader cannot trip the watcher.
|
|
266
|
+
async function bootstrap() {
|
|
267
|
+
const res = await fetch("/api/data", {
|
|
268
|
+
headers: token ? { Authorization: "Bearer " + token } : {},
|
|
269
|
+
});
|
|
270
|
+
if (!res.ok) {
|
|
271
|
+
const text = await res.text().catch(function () { return ""; });
|
|
272
|
+
throw new Error("ui-leaf: /api/data fetch failed (" + res.status + "): " + text);
|
|
273
|
+
}
|
|
274
|
+
const data = await res.json();
|
|
275
|
+
const el = document.getElementById("root");
|
|
276
|
+
if (!el) throw new Error("ui-leaf: #root element missing");
|
|
277
|
+
createRoot(el).render(<View data={data} mutate={mutate} />);
|
|
278
|
+
}
|
|
279
|
+
bootstrap();
|
|
280
|
+
` : `import { createRoot } from "react-dom/client";
|
|
281
|
+
import View from ${JSON.stringify(viewAbs)};
|
|
282
|
+
|
|
283
|
+
const ctx = (globalThis).__UI_LEAF__ || {};
|
|
284
|
+
const data = ctx.data;
|
|
285
|
+
const token = ctx.token;
|
|
286
|
+
|
|
287
|
+
${sharedEntryFns}
|
|
256
288
|
|
|
257
289
|
const el = document.getElementById("root");
|
|
258
290
|
if (!el) throw new Error("ui-leaf: #root element missing");
|
|
259
291
|
createRoot(el).render(<View data={data} mutate={mutate} />);
|
|
260
292
|
`
|
|
261
293
|
);
|
|
262
|
-
const dataInline = escapeForScriptTag(JSON.stringify(JSON.stringify(data)));
|
|
263
294
|
const tokenInline = JSON.stringify(token);
|
|
264
295
|
const titleEscaped = title.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
265
296
|
const htmlPath = join(dirToSweep, "index.html");
|
|
297
|
+
const scriptContent = dataLoader ? `{ token: ${tokenInline} }` : `{ data: JSON.parse(${escapeForScriptTag(JSON.stringify(JSON.stringify(data ?? null)))}), token: ${tokenInline} }`;
|
|
266
298
|
await writeFile(
|
|
267
299
|
htmlPath,
|
|
268
300
|
`<!DOCTYPE html>
|
|
@@ -270,7 +302,7 @@ createRoot(el).render(<View data={data} mutate={mutate} />);
|
|
|
270
302
|
<head>
|
|
271
303
|
<meta charset="utf-8" />
|
|
272
304
|
<title>${titleEscaped}</title>
|
|
273
|
-
<script>window.__UI_LEAF__ =
|
|
305
|
+
<script>window.__UI_LEAF__ = ${scriptContent};</script>
|
|
274
306
|
</head>
|
|
275
307
|
<body>
|
|
276
308
|
<div id="root"></div>
|
|
@@ -361,6 +393,18 @@ createRoot(el).render(<View data={data} mutate={mutate} />);
|
|
|
361
393
|
}
|
|
362
394
|
return;
|
|
363
395
|
}
|
|
396
|
+
if (req.method === "GET" && url2 === "/api/data") {
|
|
397
|
+
if (!dataLoader) {
|
|
398
|
+
next();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (!checkAuth2(req)) {
|
|
402
|
+
sendJson2(res, 401, { error: "unauthorized" });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
sendJson2(res, 200, loadedData !== void 0 ? loadedData : null);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
364
408
|
next();
|
|
365
409
|
});
|
|
366
410
|
if (cspHeader) {
|
|
@@ -454,6 +498,7 @@ async function mount(opts) {
|
|
|
454
498
|
const server = await startDevServer({
|
|
455
499
|
view: opts.view,
|
|
456
500
|
data: opts.data,
|
|
501
|
+
dataLoader: opts.dataLoader,
|
|
457
502
|
viewsRoot,
|
|
458
503
|
mutations: opts.mutations,
|
|
459
504
|
title: opts.title,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/dev-server.ts"],"sourcesContent":["// ui-leaf — Customizable browser views, on demand, for any CLI.\n// https://github.com/OpenThinkAi/ui-leaf\n\nimport { resolve } from \"node:path\";\nimport {\n startDevServer,\n type CspOption,\n type MutationHandler,\n type Shell,\n} from \"./dev-server.js\";\n\nexport type { CspOption, MutationHandler, Shell };\n\nexport interface MountOptions {\n /** View name. Resolves to <viewsRoot>/<view>.tsx. */\n view: string;\n /** JSON-serializable data passed to the view as a prop. */\n data: unknown;\n /**\n * Mutation handlers the view can call via mutate(name, args).\n * Each handler can self-type its args and return:\n *\n * mutations: {\n * recategorize: async (args: { id: string; category: string }) => {\n * await db.recategorize(args.id, args.category);\n * return { ok: true };\n * },\n * }\n *\n * Each request body is capped at 1 MiB; oversized POSTs are rejected\n * with a 400 and the view's mutate() promise rejects with a clear error.\n */\n // biome-ignore lint/suspicious/noExplicitAny: each handler has its own\n // arg/return types; the map can't share one shape.\n mutations?: Record<string, MutationHandler<any, any>>;\n /** Root directory holding view .tsx files. Defaults to <cwd>/views. */\n viewsRoot?: string;\n /** Browser tab title. Defaults to \"ui-leaf\". */\n title?: string;\n /**\n * Port to bind. Defaults to 5810 — unused by the major Node dev tools.\n * If the port is unavailable, ui-leaf bumps to the next free port and\n * the actual bound port is reflected on the returned `url` and `port`.\n * Override only if you need a stable URL (e.g. an external bookmark).\n */\n port?: number;\n /**\n * Open the browser when ready. Defaults to true. When false, mount()\n * returns the URL on its resolved value so the caller can drive a\n * headless browser, log the address, etc.\n */\n openBrowser?: boolean;\n /**\n * Browser shell. Defaults to \"tab\".\n *\n * - `\"tab\"` — open in the user's default browser as a regular tab.\n * Works everywhere; URL bar is visible.\n *\n * - `\"app\"` — try Chromium's `--app` mode for a chromeless window\n * (no URL bar, no tabs, looks like a desktop app). Available on\n * Chrome, Edge, and Brave. If no Chromium browser is installed,\n * ui-leaf falls back to \"tab\" with a stderr note. Safari and\n * Firefox always fall back.\n *\n * Pair with the share-link pattern (see \"Sharing views across users\"\n * in the README) when you want users to never see a localhost URL.\n */\n shell?: Shell;\n /**\n * Abort to close the dev server early. The returned `closed` promise\n * resolves either way; if you need to distinguish a signal-driven close\n * from a natural tab-close, check `signal.aborted` after the await.\n */\n signal?: AbortSignal;\n /**\n * Browser silence (ms) that triggers shutdown after the startup grace\n * window. Defaults to 75000 — chosen to survive a single browser\n * background-tab throttle (browsers clamp setInterval in hidden tabs to\n * roughly once per minute). Lower it if you want faster shutdown on tab\n * close; raise it if your debugger pauses the page or your machine\n * sleeps mid-session.\n */\n heartbeatTimeoutMs?: number;\n /**\n * Content-Security-Policy enforcement. Defaults to \"off\".\n *\n * - `\"off\"` — no CSP header sent. Views can fetch arbitrary URLs and\n * embed external resources freely. The data/mutations convention is\n * honor-system.\n *\n * - `\"strict\"` — ui-leaf sends a balanced preset: locks `connect-src`\n * to same-origin (the architectural lock — views cannot fetch\n * external APIs, so all data flows through `data` and `mutations`),\n * while permitting common needs (HTTPS images / fonts, inline\n * styles for React, eval for rsbuild HMR). View files can only\n * *add* further restrictions via meta tag, never remove them.\n *\n * - `string` — raw CSP header value for full control. Use when the\n * \"strict\" preset doesn't fit (e.g. you need `connect-src` to\n * include a Sentry endpoint).\n *\n * Trade-off: when set to \"strict\" or a custom string, a view file\n * cannot relax the policy at runtime. Switching back requires changing\n * the mount() call. That rigidity is a feature.\n */\n csp?: CspOption;\n /**\n * Extra hostnames accepted in the request `Host` and `Origin` headers\n * on top of the built-in loopback set (`localhost`, `127.0.0.1`, `[::1]`).\n *\n * The dev server gates every request on this set to defend against\n * DNS-rebinding attacks; non-matching requests get HTTP 403. Use this\n * escape hatch when you need to reach the dev server through a custom\n * `/etc/hosts` alias (e.g. `[\"my-app.local\"]`) or any other loopback\n * name. Hostnames are matched case-insensitively, port-agnostic.\n *\n * Be deliberate: any hostname you add becomes a viable DNS-rebinding\n * target. Don't add wildcards, public DNS names, or LAN hostnames you\n * don't fully control.\n */\n allowedHosts?: string[];\n /**\n * Suppress ui-leaf / rsbuild output to stdout. Default: false.\n *\n * When you drive `mount()` programmatically — e.g. as part of a Node\n * bridge for a non-Node CLI that's spawned ui-leaf as a subprocess —\n * stdout is usually reserved for a structured protocol (line-delimited\n * JSON, etc.). Without this option, rsbuild's banner and build messages\n * collide with that protocol and silently corrupt it on the consumer\n * side.\n *\n * Setting `silent: true`:\n * - Sets rsbuild's logLevel to 'silent' (no banner, build, or\n * deprecation messages emitted by rsbuild)\n * - Redirects `process.stdout.write` to `process.stderr` for the\n * lifetime of the dev server, restored on close\n *\n * Tradeoff: any other code in the same process that writes to stdout\n * during the dev server's lifetime is also redirected. Hold the\n * captured `process.stdout.write` reference yourself if you need to\n * write to the *real* stdout from the same process.\n */\n silent?: boolean;\n /**\n * Grace period (ms) after server start before the heartbeat watcher arms.\n * Cold-loading clients sometimes take a few seconds to send their first\n * heartbeat. Defaults to 30000.\n *\n * If no client connects within (startupGraceMs + heartbeatTimeoutMs),\n * the server shuts down on its own.\n */\n startupGraceMs?: number;\n}\n\nexport interface MountedView {\n /** URL the view is reachable at (http://127.0.0.1:<port>). */\n url: string;\n /** Bound port. Useful when port: 0 was requested. */\n port: number;\n /** Resolves when the view closes (heartbeat timeout) or close() is called. */\n closed: Promise<void>;\n /** Force-close the dev server early. */\n close: () => Promise<void>;\n}\n\n/**\n * Mount a customizable browser view from a CLI. Spins up a local dev server\n * and renders the chosen view with the given data. Returns once the server\n * is ready; await `result.closed` to block until the user closes the\n * browser tab.\n *\n * Mutations triggered in the view are dispatched to the registered handlers\n * here; the view never reaches the CLI's backing API directly.\n *\n * Multi-tab note: if the user opens the served URL in additional tabs (or\n * duplicates the tab), each tab heartbeats independently and the server\n * stays alive while *any* tab is open. Closing the original tab does not\n * shut down the CLI if a duplicate is still loaded.\n *\n * Ctrl+C: this function installs SIGINT and SIGTERM handlers that close\n * the dev server (and clean up its temp directory) before exiting.\n */\nexport async function mount(opts: MountOptions): Promise<MountedView> {\n const viewsRoot = opts.viewsRoot ?? resolve(process.cwd(), \"views\");\n\n const server = await startDevServer({\n view: opts.view,\n data: opts.data,\n viewsRoot,\n mutations: opts.mutations,\n title: opts.title,\n port: opts.port,\n openBrowser: opts.openBrowser,\n shell: opts.shell,\n heartbeatTimeoutMs: opts.heartbeatTimeoutMs,\n startupGraceMs: opts.startupGraceMs,\n csp: opts.csp,\n allowedHosts: opts.allowedHosts,\n silent: opts.silent,\n });\n\n const onSignal = (signal: NodeJS.Signals): void => {\n void (async () => {\n await server.close();\n // Re-raise so default exit codes still apply.\n process.kill(process.pid, signal);\n })();\n };\n const sigint = (): void => onSignal(\"SIGINT\");\n const sigterm = (): void => onSignal(\"SIGTERM\");\n process.once(\"SIGINT\", sigint);\n process.once(\"SIGTERM\", sigterm);\n\n if (opts.signal) {\n if (opts.signal.aborted) {\n process.off(\"SIGINT\", sigint);\n process.off(\"SIGTERM\", sigterm);\n await server.close();\n return {\n url: server.url,\n port: server.port,\n closed: Promise.resolve(),\n close: server.close,\n };\n }\n opts.signal.addEventListener(\n \"abort\",\n () => void server.close(),\n { once: true },\n );\n }\n\n const closed = server.closed.finally(() => {\n process.off(\"SIGINT\", sigint);\n process.off(\"SIGTERM\", sigterm);\n });\n\n return {\n url: server.url,\n port: server.port,\n closed,\n close: server.close,\n };\n}\n","// Spike: dev server with mutation bridge + heartbeat-based shutdown.\n// Builds on Spike #1; adds the bridge that makes ui-leaf actually useful.\n\nimport { randomBytes, timingSafeEqual as nodeTimingSafeEqual } from \"node:crypto\";\nimport { rmSync } from \"node:fs\";\nimport { mkdtemp, readdir, rm, stat, writeFile } from \"node:fs/promises\";\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { createRequire } from \"node:module\";\nimport { createServer as createTcpServer, type Socket } from \"node:net\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join, resolve, sep } from \"node:path\";\nimport { createRsbuild } from \"@rsbuild/core\";\nimport { pluginReact } from \"@rsbuild/plugin-react\";\nimport open, { apps } from \"open\";\n\n// Resolve react / react-dom from ui-leaf's installed location using\n// Node's actual resolver. With hoisting (npm/pnpm/bun), these end up in\n// the consumer's top-level node_modules, NOT under ui-leaf/node_modules.\n// Aliasing the resolved directory paths in rspack lets the bundled view\n// always find react no matter where the package manager put it.\nconst uiLeafRequire = createRequire(import.meta.url);\nconst reactPath = dirname(uiLeafRequire.resolve(\"react/package.json\"));\nconst reactDomPath = dirname(uiLeafRequire.resolve(\"react-dom/package.json\"));\n\n// Module-level stdout redirect state. Captured ONCE at module load so\n// concurrent silent: true mounts share the same \"original\" reference and\n// restore-order doesn't matter. Refcounted so the last close restores.\nconst ORIGINAL_STDOUT_WRITE = process.stdout.write.bind(process.stdout);\nlet stdoutRedirectCount = 0;\n\n/**\n * Ask the OS for a free port and return it. Used when the consumer\n * requests `port: 0`. There's a small race window between close() and\n * rsbuild's bind, but in practice it's never been an issue for dev\n * tooling.\n */\nasync function findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const server = createTcpServer();\n server.unref();\n server.on(\"error\", reject);\n server.listen(0, \"127.0.0.1\", () => {\n const addr = server.address();\n if (!addr || typeof addr !== \"object\") {\n server.close();\n reject(new Error(\"ui-leaf: failed to obtain a free port from the OS\"));\n return;\n }\n const port = addr.port;\n server.close(() => resolve(port));\n });\n });\n}\n\n// Stale-tempdir sweep threshold. Each mount writes index.html with the\n// consumer's data inlined; a crashed run leaves the dir behind until OS\n// rotation. 24h is well past any realistic single-session lifetime and\n// well short of the macOS reboot-only rotation cadence.\nconst STALE_TEMPDIR_AGE_MS = 24 * 60 * 60 * 1000;\n\n/**\n * Best-effort removal of `ui-leaf-*` siblings in `tmpdir()` whose mtime\n * is older than the stale threshold. Fire-and-forget; never throws.\n * Filters by mtime so concurrent ui-leaf processes' active dirs are left\n * alone (they're created fresh per mount).\n */\nasync function sweepStaleTempDirs(): Promise<void> {\n try {\n const root = tmpdir();\n const entries = await readdir(root, { withFileTypes: true });\n const cutoff = Date.now() - STALE_TEMPDIR_AGE_MS;\n await Promise.all(\n entries\n .filter((e) => e.isDirectory() && e.name.startsWith(\"ui-leaf-\"))\n .map(async (e) => {\n const path = join(root, e.name);\n try {\n const info = await stat(path);\n if (info.mtimeMs < cutoff) {\n await rm(path, { recursive: true, force: true });\n }\n } catch {\n // Another process may be mid-cleanup, or the dir may belong\n // to a different UID; either way, skip silently.\n }\n }),\n );\n } catch {\n // tmpdir() unreadable is rare and not actionable here.\n }\n}\n\n/**\n * Redirect process.stdout.write to process.stderr until the returned\n * function is called. Safe under concurrent silent mounts.\n */\nfunction redirectStdoutToStderr(): () => void {\n stdoutRedirectCount++;\n if (stdoutRedirectCount === 1) {\n // biome-ignore lint/suspicious/noExplicitAny: stdout.write has overloaded\n // signatures; forward exactly what comes in.\n process.stdout.write = ((chunk: any, enc?: any, cb?: any) =>\n process.stderr.write(chunk, enc, cb)) as typeof process.stdout.write;\n }\n let released = false;\n return () => {\n if (released) return;\n released = true;\n stdoutRedirectCount--;\n if (stdoutRedirectCount === 0) {\n process.stdout.write = ORIGINAL_STDOUT_WRITE;\n }\n };\n}\n\nexport type MutationHandler<TArgs = unknown, TResult = unknown> = (\n args: TArgs,\n) => TResult | Promise<TResult>;\n\n// `(string & {})` preserves the \"off\" / \"strict\" autocomplete suggestions\n// while still allowing arbitrary CSP strings. Plain string would collapse\n// the union and lose IntelliSense for the literals.\nexport type CspOption = \"off\" | \"strict\" | (string & {});\n\nexport type Shell = \"tab\" | \"app\";\n\n/**\n * Try to open `url` in a Chromium browser's --app mode (chromeless window:\n * no URL bar, no tabs). Returns true if a Chromium browser was found and\n * launched, false if no Chromium variant is installed (caller should fall\n * back to the default-browser tab).\n */\nasync function openInAppMode(url: string): Promise<boolean> {\n // Order: most-common Chromium variants first.\n const candidates = [apps.chrome, apps.edge, apps.brave];\n for (const app of candidates) {\n try {\n await open(url, { app: { name: app, arguments: [`--app=${url}`] } });\n return true;\n } catch {\n // Try next candidate; `open` throws if the binary isn't installed.\n }\n }\n return false;\n}\n\n/**\n * Strict preset: locks `connect-src` to same-origin (the architectural\n * lock that forces views to route mutations through the CLI), while\n * permitting common needs (HTTPS images/fonts, inline styles for React,\n * eval/inline-scripts for rsbuild's HMR client). A future \"production\"\n * mode could ship a tighter preset once HMR isn't in the picture.\n */\nconst STRICT_CSP = [\n \"default-src 'self'\",\n \"connect-src 'self'\",\n \"img-src 'self' data: https:\",\n \"font-src 'self' https: data:\",\n \"style-src 'self' 'unsafe-inline'\",\n \"script-src 'self' 'unsafe-eval' 'unsafe-inline'\",\n].join(\"; \");\n\nfunction resolveCsp(opt: CspOption | undefined): string | null {\n if (!opt || opt === \"off\") return null;\n if (opt === \"strict\") return STRICT_CSP;\n return opt;\n}\n\nexport interface DevServerOptions {\n view: string;\n data: unknown;\n viewsRoot: string;\n // biome-ignore lint/suspicious/noExplicitAny: each handler has its own\n // arg/return types; the map can't share one shape.\n mutations?: Record<string, MutationHandler<any, any>>;\n /** Browser tab title. Defaults to \"ui-leaf\". */\n title?: string;\n port?: number;\n openBrowser?: boolean;\n /**\n * Browser shell. Defaults to \"tab\".\n *\n * - \"tab\" — open in user's default browser as a regular tab.\n * - \"app\" — try Chromium's --app mode (chromeless window). Falls back\n * to \"tab\" if no Chromium browser is installed (Chrome/Edge/Brave),\n * with a stderr note. Safari and Firefox always fall back.\n */\n shell?: Shell;\n /** Heartbeat-stop window in ms. Browser silence longer than this triggers shutdown. */\n heartbeatTimeoutMs?: number;\n /** Grace period after server start before the heartbeat watcher is armed. */\n startupGraceMs?: number;\n /** Content-Security-Policy enforcement. See MountOptions.csp. */\n csp?: CspOption;\n /**\n * Extra hostnames (beyond `localhost`, `127.0.0.1`, `[::1]`) accepted in\n * the request `Host` and `Origin` headers. Use to allow a custom\n * `/etc/hosts` alias or another loopback name; values are matched by\n * hostname only (port-agnostic). Anything outside this set + the\n * loopback defaults is rejected with HTTP 403 to defend against\n * DNS-rebinding attacks. Default: empty.\n */\n allowedHosts?: string[];\n /**\n * Suppress ui-leaf / rsbuild output to stdout. When true:\n * - rsbuildConfig.logLevel is set to 'silent' (no banner, build, or\n * deprecation messages)\n * - process.stdout.write is redirected to process.stderr for the\n * lifetime of the dev server, restored on close()\n *\n * Use when driving mount() programmatically and stdout is reserved for\n * a structured protocol (e.g. line-delimited JSON to a parent process).\n * Default: false.\n */\n silent?: boolean;\n}\n\nexport interface DevServer {\n url: string;\n port: number;\n /** Resolves when the view is closed (heartbeat timeout) or close() is called. */\n closed: Promise<void>;\n close: () => Promise<void>;\n}\n\nfunction escapeForScriptTag(json: string): string {\n // Defend against </script> break-out and U+2028 / U+2029 line terminators\n // that JSON.stringify emits raw but JS string literals don't accept.\n return json\n .replace(/</g, \"\\\\u003c\")\n .replace(/\\u2028/g, \"\\\\u2028\")\n .replace(/\\u2029/g, \"\\\\u2029\");\n}\n\nasync function readJsonBody(req: IncomingMessage): Promise<unknown> {\n const chunks: Buffer[] = [];\n let total = 0;\n for await (const chunk of req) {\n const buf = chunk as Buffer;\n total += buf.length;\n // 1 MiB cap per request — protects against accidental huge payloads.\n if (total > 1024 * 1024) {\n throw new Error(\"request body exceeds 1 MiB limit\");\n }\n chunks.push(buf);\n }\n const text = Buffer.concat(chunks).toString(\"utf8\");\n return text ? JSON.parse(text) : undefined;\n}\n\nfunction timingSafeEqual(a: string, b: string): boolean {\n // Length check is not timing-safe but is fine — the token length is fixed\n // and known to attackers regardless. The byte compare must be timing-safe.\n if (a.length !== b.length) return false;\n return nodeTimingSafeEqual(Buffer.from(a, \"utf8\"), Buffer.from(b, \"utf8\"));\n}\n\nconst DEFAULT_LOOPBACK_HOSTNAMES = [\"127.0.0.1\", \"localhost\", \"::1\"] as const;\n\n// Extract the hostname portion of a Host header value, stripping the port.\n// IPv6 hosts arrive bracketed (`[::1]:5810`); plain hosts as `host:port`\n// or bare `host`. Returns lowercased hostname or null on shapes we don't\n// recognise (caller treats null as \"reject\").\nfunction parseHostHeader(value: string): string | null {\n const trimmed = value.trim();\n if (trimmed === \"\") return null;\n if (trimmed.startsWith(\"[\")) {\n const close = trimmed.indexOf(\"]\");\n if (close === -1) return null;\n return trimmed.slice(1, close).toLowerCase();\n }\n const colon = trimmed.indexOf(\":\");\n return (colon === -1 ? trimmed : trimmed.slice(0, colon)).toLowerCase();\n}\n\n// DNS-rebinding defence: every request must arrive with a Host header\n// pointing at one of the allowed names. Same gate applies to Origin when\n// the browser sends one. Absent Origin is fine — many legitimate\n// same-origin requests omit it. `Origin: null` is allowed because\n// sandboxed iframes and `file://` pages send it; the Host check still\n// constrains the network path so the Origin allowance isn't load-bearing.\nfunction isAllowedHost(value: string | undefined, allowed: Set<string>): boolean {\n const host = value === undefined ? null : parseHostHeader(value);\n return host !== null && allowed.has(host);\n}\n\nfunction isAllowedOrigin(value: string | undefined, allowed: Set<string>): boolean {\n if (value === undefined || value === \"\" || value === \"null\") return true;\n try {\n // WHATWG URL keeps the brackets on IPv6 hostnames (`[::1]`), but the\n // allow-list stores them stripped (matching parseHostHeader's output)\n // so origins and hosts compare consistently.\n let hostname = new URL(value).hostname.toLowerCase();\n if (hostname.startsWith(\"[\") && hostname.endsWith(\"]\")) {\n hostname = hostname.slice(1, -1);\n }\n return allowed.has(hostname);\n } catch {\n return false;\n }\n}\n\nexport async function startDevServer(opts: DevServerOptions): Promise<DevServer> {\n const {\n view,\n data,\n viewsRoot,\n mutations = {},\n title = \"ui-leaf\",\n port,\n openBrowser = true,\n shell = \"tab\",\n heartbeatTimeoutMs = 75_000,\n startupGraceMs = 30_000,\n csp,\n allowedHosts,\n silent = false,\n } = opts;\n const cspHeader = resolveCsp(csp);\n const allowedHostSet = new Set<string>(DEFAULT_LOOPBACK_HOSTNAMES);\n for (const h of allowedHosts ?? []) allowedHostSet.add(h.toLowerCase());\n const allowedHostList = [...allowedHostSet].join(\", \");\n\n // Resolve port: 0 means \"let the OS pick\" — but rsbuild doesn't honor\n // port: 0 (it literally binds to 0 and reports back 0). Pre-allocate a\n // free port via Node's net layer and pass that explicit port to rsbuild\n // instead. Default 5810 if no port specified.\n const resolvedPort = port === 0 ? await findFreePort() : (port ?? 5810);\n\n // Programmatic consumers (esp. non-Node CLIs spawning ui-leaf as a\n // subprocess) often reserve stdout for a structured protocol. Belt-and-\n // -suspenders: rsbuild's logLevel:'silent' catches its own logger output,\n // and process.stdout.write is redirected to stderr to catch anything\n // that bypasses rsbuild's logger (banner before logger init, third-party\n // module writes, etc.).\n const restoreStdout: (() => void) | null = silent ? redirectStdoutToStderr() : null;\n\n // Hoisted so the outer catch can sweep them on a setup-time throw.\n let tempDir: string | null = null;\n let cleanupOnExit: (() => void) | null = null;\n\n try {\n if (view.includes(\"/\") || view.includes(\"\\\\\")) {\n throw new Error(\n `ui-leaf: view '${view}' must be a bare identifier with no path separators`,\n );\n }\n const viewsRootAbs = resolve(viewsRoot);\n const viewAbs = resolve(viewsRootAbs, `${view}.tsx`);\n if (!viewAbs.startsWith(viewsRootAbs + sep)) {\n throw new Error(\n `ui-leaf: view '${view}' resolves outside viewsRoot`,\n );\n }\n try {\n await stat(viewAbs);\n } catch {\n throw new Error(\n `ui-leaf: view '${view}' not found at ${viewAbs} (looked for .tsx; viewsRoot=${viewsRoot})`,\n );\n }\n\n const token = randomBytes(32).toString(\"hex\");\n tempDir = await mkdtemp(join(tmpdir(), \"ui-leaf-\"));\n\n // Synchronous fallback for any exit path that bypasses cleanup() —\n // uncaught throws and programmatic process.exit. SIGKILL, OOM-kill,\n // and power loss skip every Node hook, so the next startup's sweep is\n // the backstop for those. rmSync with force is idempotent when the\n // dir is already gone, so this no-ops safely if cleanup() ran first.\n const dirToSweep = tempDir;\n cleanupOnExit = (): void => {\n try {\n rmSync(dirToSweep, { recursive: true, force: true });\n } catch {\n // Exit handlers must not throw.\n }\n };\n process.on(\"exit\", cleanupOnExit);\n\n // Mop up any abandoned ui-leaf-* dirs from prior crashed runs that\n // SIGKILL'd or otherwise skipped both cleanup() and the exit handler.\n // Fire-and-forget so the sweep never blocks startup.\n void sweepStaleTempDirs();\n\n const entryPath = join(dirToSweep, \"entry.tsx\");\n await writeFile(\n entryPath,\n `import { createRoot } from \"react-dom/client\";\nimport View from ${JSON.stringify(viewAbs)};\n\nconst ctx = (globalThis).__UI_LEAF__ || {};\nconst data = ctx.data;\nconst token = ctx.token;\n\nasync function mutate(name, args) {\n const res = await fetch(\"/mutate\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n ...(token ? { Authorization: \"Bearer \" + token } : {}),\n },\n body: JSON.stringify({ name, args }),\n });\n const text = await res.text().catch(function () { return \"\"; });\n if (!res.ok) {\n var detail = text;\n try {\n var parsed = text ? JSON.parse(text) : null;\n if (parsed && typeof parsed === \"object\" && typeof parsed.error === \"string\") {\n detail = parsed.error;\n }\n } catch (_) { /* keep raw text */ }\n throw new Error(\"ui-leaf: mutation '\" + name + \"' failed (\" + res.status + \"): \" + detail);\n }\n return text ? JSON.parse(text) : undefined;\n}\n\nasync function heartbeat() {\n try {\n await fetch(\"/heartbeat\", {\n method: \"POST\",\n headers: token ? { Authorization: \"Bearer \" + token } : {},\n });\n } catch {\n /* server may have shut down; ignore */\n }\n}\nsetInterval(heartbeat, 5000);\nheartbeat();\n\nconst el = document.getElementById(\"root\");\nif (!el) throw new Error(\"ui-leaf: #root element missing\");\ncreateRoot(el).render(<View data={data} mutate={mutate} />);\n`,\n );\n\n // Double-stringify encodes data as a JS string literal so the inline\n // assignment uses JSON.parse(…) at load time rather than an object literal.\n // This sidesteps ECMAScript Annex B.3.1, which lets a `__proto__` key in\n // an object literal mutate the prototype — JSON.parse has no such carve-out.\n const dataInline = escapeForScriptTag(JSON.stringify(JSON.stringify(data)));\n const tokenInline = JSON.stringify(token);\n const titleEscaped = title\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\");\n const htmlPath = join(dirToSweep, \"index.html\");\n await writeFile(\n htmlPath,\n `<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <title>${titleEscaped}</title>\n <script>window.__UI_LEAF__ = { data: JSON.parse(${dataInline}), token: ${tokenInline} };</script>\n </head>\n <body>\n <div id=\"root\"></div>\n </body>\n</html>\n`,\n );\n\n let lastHeartbeatAt = Date.now();\n let closeRequested = false;\n let resolveClosed: () => void = () => {};\n const closed = new Promise<void>((r) => {\n resolveClosed = r;\n });\n\n function checkAuth(req: IncomingMessage): boolean {\n const header = req.headers.authorization ?? \"\";\n const match = /^Bearer (.+)$/.exec(header);\n if (!match) return false;\n return timingSafeEqual(match[1]!, token);\n }\n\n function sendJson(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, { \"Content-Type\": \"application/json\" });\n res.end(body === undefined ? \"\" : JSON.stringify(body));\n }\n\n const rsbuild = await createRsbuild({\n cwd: dirToSweep,\n rsbuildConfig: {\n plugins: [pluginReact()],\n ...(silent ? { logLevel: \"silent\" as const } : {}),\n source: { entry: { index: entryPath } },\n // 5810 is unused by the major Node dev tools (vite=5173, parcel=1234,\n // webpack=8080, next/CRA=3000). rsbuild auto-bumps to the next free\n // port if 5810 is busy, so collisions are graceful.\n server: { port: resolvedPort, host: \"127.0.0.1\" },\n // Note: `dev.setupMiddlewares` is deprecated as of rsbuild 2.x in\n // favor of `server.setup`, but the new API has a different signature\n // and bypasses the rsbuild CSRF middleware in ways that break our\n // POST endpoints. Sticking with the deprecated path for v1.\n dev: {\n setupMiddlewares: [\n (middlewares) => {\n middlewares.unshift(async (req, res, next) => {\n // DNS-rebinding gate: reject anything not arriving with an\n // allowed Host (and Origin, when present) before any auth\n // or routing runs. Done in ui-leaf's own middleware rather\n // than relying on rsbuild so the property is explicit and\n // survives upstream API churn.\n const hostOk = isAllowedHost(req.headers.host, allowedHostSet);\n const originOk = isAllowedOrigin(req.headers.origin, allowedHostSet);\n if (!hostOk || !originOk) {\n const offender = !hostOk\n ? `Host \"${req.headers.host ?? \"(absent)\"}\"`\n : `Origin \"${req.headers.origin}\"`;\n res.statusCode = 403;\n res.setHeader(\"Content-Type\", \"text/plain; charset=utf-8\");\n res.end(\n `ui-leaf: refusing request with ${offender} — only the following hostnames are accepted to prevent DNS rebinding: ${allowedHostList}. Open the dev server at http://localhost:${resolvedPort}/ or http://127.0.0.1:${resolvedPort}/, or pass { allowedHosts: [\"my-alias\"] } to mount() to permit a custom alias.\\n`,\n );\n return;\n }\n const url = req.url ?? \"\";\n if (req.method === \"POST\" && url === \"/heartbeat\") {\n if (!checkAuth(req)) {\n sendJson(res, 401, { error: \"unauthorized\" });\n return;\n }\n lastHeartbeatAt = Date.now();\n sendJson(res, 204, undefined);\n return;\n }\n if (req.method === \"POST\" && url === \"/mutate\") {\n if (!checkAuth(req)) {\n sendJson(res, 401, { error: \"unauthorized\" });\n return;\n }\n let body: { name?: string; args?: unknown };\n try {\n body = (await readJsonBody(req)) as typeof body;\n } catch (err) {\n sendJson(res, 400, {\n error: err instanceof Error ? err.message : \"bad request\",\n });\n return;\n }\n const name = body?.name;\n if (typeof name !== \"string\" || name.length === 0) {\n sendJson(res, 400, { error: \"missing mutation name\" });\n return;\n }\n if (!Object.hasOwn(mutations, name)) {\n sendJson(res, 404, {\n error: `ui-leaf: no mutation handler registered for '${name}'. Add it to the mutations: { } map passed to mount().`,\n });\n return;\n }\n const handler = mutations[name]!;\n try {\n const result = await handler(body.args);\n sendJson(res, 200, result ?? null);\n } catch (err) {\n sendJson(res, 500, {\n error: err instanceof Error ? err.message : String(err),\n });\n }\n return;\n }\n next();\n });\n // CSP middleware unshifts AFTER the route handler so it ends up\n // at index 0 — runs first on every request, sets the header,\n // then yields to the route or rsbuild static handlers. No-op\n // when csp resolves to null (the \"off\" default).\n if (cspHeader) {\n middlewares.unshift((_req, res, next) => {\n res.setHeader(\"Content-Security-Policy\", cspHeader);\n next();\n });\n }\n },\n ],\n },\n html: { template: htmlPath },\n tools: {\n rspack: {\n resolve: {\n alias: {\n react: reactPath,\n \"react-dom\": reactDomPath,\n },\n },\n },\n },\n },\n });\n\n const devServer = await rsbuild.startDevServer();\n const actualPort = devServer.port;\n const url = `http://127.0.0.1:${actualPort}`;\n const startedAt = Date.now();\n\n // DNS-rebinding gate for WebSocket upgrades. The setupMiddlewares stack\n // above only fires for `request` events — `upgrade` events bypass it\n // entirely and reach rsbuild's HMR socket directly. Prepend a listener\n // on the underlying http server so our Host check runs before rsbuild's\n // HMR upgrade handler; on reject we destroy the socket (pure TCP close,\n // no HTTP response) so no HMR frames can leak to a rebound origin.\n const httpServer = devServer.server.httpServer;\n const upgradeGate = (req: IncomingMessage, socket: Socket): void => {\n if (!isAllowedHost(req.headers.host, allowedHostSet)) {\n socket.destroy();\n }\n };\n httpServer?.prependListener(\"upgrade\", upgradeGate);\n\n let heartbeatWatcher: NodeJS.Timeout | undefined;\n\n const cleanup = async (): Promise<void> => {\n if (closeRequested) return;\n closeRequested = true;\n if (heartbeatWatcher) clearInterval(heartbeatWatcher);\n httpServer?.removeListener(\"upgrade\", upgradeGate);\n await devServer.server.close();\n await rm(dirToSweep, { recursive: true, force: true });\n // Listener is per-mount — unhook so long-lived hosts that mount many\n // views in sequence don't pile up exit handlers.\n if (cleanupOnExit) process.off(\"exit\", cleanupOnExit);\n if (restoreStdout) restoreStdout();\n resolveClosed();\n };\n\n heartbeatWatcher = setInterval(() => {\n const now = Date.now();\n if (now - startedAt < startupGraceMs) return;\n if (now - lastHeartbeatAt > heartbeatTimeoutMs) {\n void cleanup();\n }\n }, 1000);\n\n if (openBrowser) {\n if (shell === \"app\") {\n const launched = await openInAppMode(url);\n if (!launched) {\n process.stderr.write(\n `ui-leaf: shell:\"app\" requested but no Chromium browser found; falling back to default browser tab.\\n`,\n );\n await open(url);\n }\n } else {\n await open(url);\n }\n }\n\n return {\n url,\n port: actualPort,\n closed,\n close: cleanup,\n };\n } catch (err) {\n // Setup failed before close() got wired up, so the caller has no\n // close to call. The user's catch may keep the process alive doing\n // other work, so sweep the (possibly populated) tempDir now and\n // unhook the exit fallback rather than waiting on process exit.\n if (tempDir) {\n try {\n rmSync(tempDir, { recursive: true, force: true });\n } catch {\n // Best-effort; the next startup's sweep will catch it.\n }\n }\n if (cleanupOnExit) process.off(\"exit\", cleanupOnExit);\n restoreStdout?.();\n throw err;\n }\n}\n"],"mappings":"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","names":["resolve","resolve","checkAuth","sendJson","url","resolve"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/dev-server.ts"],"sourcesContent":["// ui-leaf — Customizable browser views, on demand, for any CLI.\n// https://github.com/OpenThinkAi/ui-leaf\n\nimport { resolve } from \"node:path\";\nimport {\n startDevServer,\n type CspOption,\n type MutationHandler,\n type Shell,\n} from \"./dev-server.js\";\n\nexport type { CspOption, MutationHandler, Shell };\n\nexport interface MountOptions {\n /** View name. Resolves to <viewsRoot>/<view>.tsx. */\n view: string;\n /**\n * JSON-serializable data passed to the view as a prop.\n *\n * Privacy caveat: ui-leaf serialises this payload into\n * `<tmpdir()>/ui-leaf-XXXXXX/index.html` for the mount lifetime, and\n * any same-UID local process that can reach `127.0.0.1:<port>` can\n * fetch `/index.html` and read it — the per-launch token guards\n * `/mutate` against drive-by cross-origin requests in the browser, not\n * against other processes on the machine. For PHI, PCI, financial\n * records, or anything else where a same-UID local reader is in your\n * threat model, use `dataLoader` instead — the loader's return value\n * is served at an authenticated `/api/data` endpoint and never written\n * to disk. See the README section \"Data-at-rest in the temp directory\"\n * and the `dataLoader` field below for details.\n */\n data?: unknown;\n /**\n * Async function that supplies sensitive data to the view without\n * writing it to disk. When provided, the loader is called once during\n * mount setup; its resolved value is served at a token-gated\n * `GET /api/data` endpoint (same per-launch token as `/mutate`) and\n * the view fetches it on first render before calling `createRoot().render()`.\n *\n * Use this instead of `data` for PHI, PCI, financial records, or\n * anything else where disk residency is in your threat model. `data`\n * inlines the payload into `<tmpdir>/ui-leaf-XXXXXX/index.html` for\n * the mount lifetime; `dataLoader` keeps it in memory only.\n *\n * Error semantics: if the loader rejects, the rejection propagates to\n * the `mount()` caller (no automatic retry). Errors surface at mount\n * time, matching the synchronous `data` path's behavior.\n *\n * Mutual exclusion: passing both `data` and `dataLoader` throws at\n * mount time.\n */\n dataLoader?: () => Promise<unknown>;\n /**\n * Mutation handlers the view can call via mutate(name, args).\n * Each handler can self-type its args and return:\n *\n * mutations: {\n * recategorize: async (args: { id: string; category: string }) => {\n * await db.recategorize(args.id, args.category);\n * return { ok: true };\n * },\n * }\n *\n * Each request body is capped at 1 MiB; oversized POSTs are rejected\n * with a 400 and the view's mutate() promise rejects with a clear error.\n */\n // biome-ignore lint/suspicious/noExplicitAny: each handler has its own\n // arg/return types; the map can't share one shape.\n mutations?: Record<string, MutationHandler<any, any>>;\n /** Root directory holding view .tsx files. Defaults to <cwd>/views. */\n viewsRoot?: string;\n /** Browser tab title. Defaults to \"ui-leaf\". */\n title?: string;\n /**\n * Port to bind. Defaults to 5810 — unused by the major Node dev tools.\n * If the port is unavailable, ui-leaf bumps to the next free port and\n * the actual bound port is reflected on the returned `url` and `port`.\n * Override only if you need a stable URL (e.g. an external bookmark).\n */\n port?: number;\n /**\n * Open the browser when ready. Defaults to true. When false, mount()\n * returns the URL on its resolved value so the caller can drive a\n * headless browser, log the address, etc.\n */\n openBrowser?: boolean;\n /**\n * Browser shell. Defaults to \"tab\".\n *\n * - `\"tab\"` — open in the user's default browser as a regular tab.\n * Works everywhere; URL bar is visible.\n *\n * - `\"app\"` — try Chromium's `--app` mode for a chromeless window\n * (no URL bar, no tabs, looks like a desktop app). Available on\n * Chrome, Edge, and Brave. If no Chromium browser is installed,\n * ui-leaf falls back to \"tab\" with a stderr note. Safari and\n * Firefox always fall back.\n *\n * Pair with the share-link pattern (see \"Sharing views across users\"\n * in the README) when you want users to never see a localhost URL.\n */\n shell?: Shell;\n /**\n * Abort to close the dev server early. The returned `closed` promise\n * resolves either way; if you need to distinguish a signal-driven close\n * from a natural tab-close, check `signal.aborted` after the await.\n */\n signal?: AbortSignal;\n /**\n * Browser silence (ms) that triggers shutdown after the startup grace\n * window. Defaults to 75000 — chosen to survive a single browser\n * background-tab throttle (browsers clamp setInterval in hidden tabs to\n * roughly once per minute). Lower it if you want faster shutdown on tab\n * close; raise it if your debugger pauses the page or your machine\n * sleeps mid-session.\n */\n heartbeatTimeoutMs?: number;\n /**\n * Content-Security-Policy enforcement. Defaults to \"off\".\n *\n * - `\"off\"` — no CSP header sent. Views can fetch arbitrary URLs and\n * embed external resources freely. The data/mutations convention is\n * honor-system.\n *\n * - `\"strict\"` — ui-leaf sends a balanced preset: locks `connect-src`\n * to same-origin (the architectural lock — views cannot fetch\n * external APIs, so all data flows through `data` and `mutations`),\n * while permitting common needs (HTTPS images / fonts, inline\n * styles for React, eval for rsbuild HMR). View files can only\n * *add* further restrictions via meta tag, never remove them.\n *\n * - `string` — raw CSP header value for full control. Use when the\n * \"strict\" preset doesn't fit (e.g. you need `connect-src` to\n * include a Sentry endpoint).\n *\n * Trade-off: when set to \"strict\" or a custom string, a view file\n * cannot relax the policy at runtime. Switching back requires changing\n * the mount() call. That rigidity is a feature.\n */\n csp?: CspOption;\n /**\n * Extra hostnames accepted in the request `Host` and `Origin` headers\n * on top of the built-in loopback set (`localhost`, `127.0.0.1`, `[::1]`).\n *\n * The dev server gates every request on this set to defend against\n * DNS-rebinding attacks; non-matching requests get HTTP 403. Use this\n * escape hatch when you need to reach the dev server through a custom\n * `/etc/hosts` alias (e.g. `[\"my-app.local\"]`) or any other loopback\n * name. Hostnames are matched case-insensitively, port-agnostic.\n *\n * Be deliberate: any hostname you add becomes a viable DNS-rebinding\n * target. Don't add wildcards, public DNS names, or LAN hostnames you\n * don't fully control.\n */\n allowedHosts?: string[];\n /**\n * Suppress ui-leaf / rsbuild output to stdout. Default: false.\n *\n * When you drive `mount()` programmatically — e.g. as part of a Node\n * bridge for a non-Node CLI that's spawned ui-leaf as a subprocess —\n * stdout is usually reserved for a structured protocol (line-delimited\n * JSON, etc.). Without this option, rsbuild's banner and build messages\n * collide with that protocol and silently corrupt it on the consumer\n * side.\n *\n * Setting `silent: true`:\n * - Sets rsbuild's logLevel to 'silent' (no banner, build, or\n * deprecation messages emitted by rsbuild)\n * - Redirects `process.stdout.write` to `process.stderr` for the\n * lifetime of the dev server, restored on close\n *\n * Tradeoff: any other code in the same process that writes to stdout\n * during the dev server's lifetime is also redirected. Hold the\n * captured `process.stdout.write` reference yourself if you need to\n * write to the *real* stdout from the same process.\n */\n silent?: boolean;\n /**\n * Grace period (ms) after server start before the heartbeat watcher arms.\n * Cold-loading clients sometimes take a few seconds to send their first\n * heartbeat. Defaults to 30000.\n *\n * If no client connects within (startupGraceMs + heartbeatTimeoutMs),\n * the server shuts down on its own.\n */\n startupGraceMs?: number;\n}\n\nexport interface MountedView {\n /** URL the view is reachable at (http://127.0.0.1:<port>). */\n url: string;\n /** Bound port. Useful when port: 0 was requested. */\n port: number;\n /** Resolves when the view closes (heartbeat timeout) or close() is called. */\n closed: Promise<void>;\n /** Force-close the dev server early. */\n close: () => Promise<void>;\n}\n\n/**\n * Mount a customizable browser view from a CLI. Spins up a local dev server\n * and renders the chosen view with the given data. Returns once the server\n * is ready; await `result.closed` to block until the user closes the\n * browser tab.\n *\n * Mutations triggered in the view are dispatched to the registered handlers\n * here; the view never reaches the CLI's backing API directly.\n *\n * Multi-tab note: if the user opens the served URL in additional tabs (or\n * duplicates the tab), each tab heartbeats independently and the server\n * stays alive while *any* tab is open. Closing the original tab does not\n * shut down the CLI if a duplicate is still loaded.\n *\n * Ctrl+C: this function installs SIGINT and SIGTERM handlers that close\n * the dev server (and clean up its temp directory) before exiting.\n */\nexport async function mount(opts: MountOptions): Promise<MountedView> {\n const viewsRoot = opts.viewsRoot ?? resolve(process.cwd(), \"views\");\n\n const server = await startDevServer({\n view: opts.view,\n data: opts.data,\n dataLoader: opts.dataLoader,\n viewsRoot,\n mutations: opts.mutations,\n title: opts.title,\n port: opts.port,\n openBrowser: opts.openBrowser,\n shell: opts.shell,\n heartbeatTimeoutMs: opts.heartbeatTimeoutMs,\n startupGraceMs: opts.startupGraceMs,\n csp: opts.csp,\n allowedHosts: opts.allowedHosts,\n silent: opts.silent,\n });\n\n const onSignal = (signal: NodeJS.Signals): void => {\n void (async () => {\n await server.close();\n // Re-raise so default exit codes still apply.\n process.kill(process.pid, signal);\n })();\n };\n const sigint = (): void => onSignal(\"SIGINT\");\n const sigterm = (): void => onSignal(\"SIGTERM\");\n process.once(\"SIGINT\", sigint);\n process.once(\"SIGTERM\", sigterm);\n\n if (opts.signal) {\n if (opts.signal.aborted) {\n process.off(\"SIGINT\", sigint);\n process.off(\"SIGTERM\", sigterm);\n await server.close();\n return {\n url: server.url,\n port: server.port,\n closed: Promise.resolve(),\n close: server.close,\n };\n }\n opts.signal.addEventListener(\n \"abort\",\n () => void server.close(),\n { once: true },\n );\n }\n\n const closed = server.closed.finally(() => {\n process.off(\"SIGINT\", sigint);\n process.off(\"SIGTERM\", sigterm);\n });\n\n return {\n url: server.url,\n port: server.port,\n closed,\n close: server.close,\n };\n}\n","// Spike: dev server with mutation bridge + heartbeat-based shutdown.\n// Builds on Spike #1; adds the bridge that makes ui-leaf actually useful.\n\nimport { randomBytes, timingSafeEqual as nodeTimingSafeEqual } from \"node:crypto\";\nimport { rmSync } from \"node:fs\";\nimport { mkdtemp, readdir, rm, stat, writeFile } from \"node:fs/promises\";\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { createRequire } from \"node:module\";\nimport { createServer as createTcpServer, type Socket } from \"node:net\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join, resolve, sep } from \"node:path\";\nimport { createRsbuild } from \"@rsbuild/core\";\nimport { pluginReact } from \"@rsbuild/plugin-react\";\nimport open, { apps } from \"open\";\n\n// Resolve react / react-dom from ui-leaf's installed location using\n// Node's actual resolver. With hoisting (npm/pnpm/bun), these end up in\n// the consumer's top-level node_modules, NOT under ui-leaf/node_modules.\n// Aliasing the resolved directory paths in rspack lets the bundled view\n// always find react no matter where the package manager put it.\nconst uiLeafRequire = createRequire(import.meta.url);\nconst reactPath = dirname(uiLeafRequire.resolve(\"react/package.json\"));\nconst reactDomPath = dirname(uiLeafRequire.resolve(\"react-dom/package.json\"));\n\n// Module-level stdout redirect state. Captured ONCE at module load so\n// concurrent silent: true mounts share the same \"original\" reference and\n// restore-order doesn't matter. Refcounted so the last close restores.\nconst ORIGINAL_STDOUT_WRITE = process.stdout.write.bind(process.stdout);\nlet stdoutRedirectCount = 0;\n\n/**\n * Ask the OS for a free port and return it. Used when the consumer\n * requests `port: 0`. There's a small race window between close() and\n * rsbuild's bind, but in practice it's never been an issue for dev\n * tooling.\n */\nasync function findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const server = createTcpServer();\n server.unref();\n server.on(\"error\", reject);\n server.listen(0, \"127.0.0.1\", () => {\n const addr = server.address();\n if (!addr || typeof addr !== \"object\") {\n server.close();\n reject(new Error(\"ui-leaf: failed to obtain a free port from the OS\"));\n return;\n }\n const port = addr.port;\n server.close(() => resolve(port));\n });\n });\n}\n\n// Stale-tempdir sweep threshold. Each mount writes index.html with the\n// consumer's data inlined; a crashed run leaves the dir behind until OS\n// rotation. 24h is well past any realistic single-session lifetime and\n// well short of the macOS reboot-only rotation cadence.\nconst STALE_TEMPDIR_AGE_MS = 24 * 60 * 60 * 1000;\n\n/**\n * Best-effort removal of `ui-leaf-*` siblings in `tmpdir()` whose mtime\n * is older than the stale threshold. Fire-and-forget; never throws.\n * Filters by mtime so concurrent ui-leaf processes' active dirs are left\n * alone (they're created fresh per mount).\n */\nasync function sweepStaleTempDirs(): Promise<void> {\n try {\n const root = tmpdir();\n const entries = await readdir(root, { withFileTypes: true });\n const cutoff = Date.now() - STALE_TEMPDIR_AGE_MS;\n await Promise.all(\n entries\n .filter((e) => e.isDirectory() && e.name.startsWith(\"ui-leaf-\"))\n .map(async (e) => {\n const path = join(root, e.name);\n try {\n const info = await stat(path);\n if (info.mtimeMs < cutoff) {\n await rm(path, { recursive: true, force: true });\n }\n } catch {\n // Another process may be mid-cleanup, or the dir may belong\n // to a different UID; either way, skip silently.\n }\n }),\n );\n } catch {\n // tmpdir() unreadable is rare and not actionable here.\n }\n}\n\n/**\n * Redirect process.stdout.write to process.stderr until the returned\n * function is called. Safe under concurrent silent mounts.\n */\nfunction redirectStdoutToStderr(): () => void {\n stdoutRedirectCount++;\n if (stdoutRedirectCount === 1) {\n // biome-ignore lint/suspicious/noExplicitAny: stdout.write has overloaded\n // signatures; forward exactly what comes in.\n process.stdout.write = ((chunk: any, enc?: any, cb?: any) =>\n process.stderr.write(chunk, enc, cb)) as typeof process.stdout.write;\n }\n let released = false;\n return () => {\n if (released) return;\n released = true;\n stdoutRedirectCount--;\n if (stdoutRedirectCount === 0) {\n process.stdout.write = ORIGINAL_STDOUT_WRITE;\n }\n };\n}\n\nexport type MutationHandler<TArgs = unknown, TResult = unknown> = (\n args: TArgs,\n) => TResult | Promise<TResult>;\n\n// `(string & {})` preserves the \"off\" / \"strict\" autocomplete suggestions\n// while still allowing arbitrary CSP strings. Plain string would collapse\n// the union and lose IntelliSense for the literals.\nexport type CspOption = \"off\" | \"strict\" | (string & {});\n\nexport type Shell = \"tab\" | \"app\";\n\n/**\n * Try to open `url` in a Chromium browser's --app mode (chromeless window:\n * no URL bar, no tabs). Returns true if a Chromium browser was found and\n * launched, false if no Chromium variant is installed (caller should fall\n * back to the default-browser tab).\n */\nasync function openInAppMode(url: string): Promise<boolean> {\n // Order: most-common Chromium variants first.\n const candidates = [apps.chrome, apps.edge, apps.brave];\n for (const app of candidates) {\n try {\n await open(url, { app: { name: app, arguments: [`--app=${url}`] } });\n return true;\n } catch {\n // Try next candidate; `open` throws if the binary isn't installed.\n }\n }\n return false;\n}\n\n/**\n * Strict preset: locks `connect-src` to same-origin (the architectural\n * lock that forces views to route mutations through the CLI), while\n * permitting common needs (HTTPS images/fonts, inline styles for React,\n * eval/inline-scripts for rsbuild's HMR client). A future \"production\"\n * mode could ship a tighter preset once HMR isn't in the picture.\n */\nconst STRICT_CSP = [\n \"default-src 'self'\",\n \"connect-src 'self'\",\n \"img-src 'self' data: https:\",\n \"font-src 'self' https: data:\",\n \"style-src 'self' 'unsafe-inline'\",\n \"script-src 'self' 'unsafe-eval' 'unsafe-inline'\",\n].join(\"; \");\n\nfunction resolveCsp(opt: CspOption | undefined): string | null {\n if (!opt || opt === \"off\") return null;\n if (opt === \"strict\") return STRICT_CSP;\n return opt;\n}\n\nexport interface DevServerOptions {\n view: string;\n data?: unknown;\n dataLoader?: () => Promise<unknown>;\n viewsRoot: string;\n // biome-ignore lint/suspicious/noExplicitAny: each handler has its own\n // arg/return types; the map can't share one shape.\n mutations?: Record<string, MutationHandler<any, any>>;\n /** Browser tab title. Defaults to \"ui-leaf\". */\n title?: string;\n port?: number;\n openBrowser?: boolean;\n /**\n * Browser shell. Defaults to \"tab\".\n *\n * - \"tab\" — open in user's default browser as a regular tab.\n * - \"app\" — try Chromium's --app mode (chromeless window). Falls back\n * to \"tab\" if no Chromium browser is installed (Chrome/Edge/Brave),\n * with a stderr note. Safari and Firefox always fall back.\n */\n shell?: Shell;\n /** Heartbeat-stop window in ms. Browser silence longer than this triggers shutdown. */\n heartbeatTimeoutMs?: number;\n /** Grace period after server start before the heartbeat watcher is armed. */\n startupGraceMs?: number;\n /** Content-Security-Policy enforcement. See MountOptions.csp. */\n csp?: CspOption;\n /**\n * Extra hostnames (beyond `localhost`, `127.0.0.1`, `[::1]`) accepted in\n * the request `Host` and `Origin` headers. Use to allow a custom\n * `/etc/hosts` alias or another loopback name; values are matched by\n * hostname only (port-agnostic). Anything outside this set + the\n * loopback defaults is rejected with HTTP 403 to defend against\n * DNS-rebinding attacks. Default: empty.\n */\n allowedHosts?: string[];\n /**\n * Suppress ui-leaf / rsbuild output to stdout. When true:\n * - rsbuildConfig.logLevel is set to 'silent' (no banner, build, or\n * deprecation messages)\n * - process.stdout.write is redirected to process.stderr for the\n * lifetime of the dev server, restored on close()\n *\n * Use when driving mount() programmatically and stdout is reserved for\n * a structured protocol (e.g. line-delimited JSON to a parent process).\n * Default: false.\n */\n silent?: boolean;\n}\n\nexport interface DevServer {\n url: string;\n port: number;\n /** Resolves when the view is closed (heartbeat timeout) or close() is called. */\n closed: Promise<void>;\n close: () => Promise<void>;\n}\n\nfunction escapeForScriptTag(json: string): string {\n // Defend against </script> break-out and U+2028 / U+2029 line terminators\n // that JSON.stringify emits raw but JS string literals don't accept.\n return json\n .replace(/</g, \"\\\\u003c\")\n .replace(/\\u2028/g, \"\\\\u2028\")\n .replace(/\\u2029/g, \"\\\\u2029\");\n}\n\nasync function readJsonBody(req: IncomingMessage): Promise<unknown> {\n const chunks: Buffer[] = [];\n let total = 0;\n for await (const chunk of req) {\n const buf = chunk as Buffer;\n total += buf.length;\n // 1 MiB cap per request — protects against accidental huge payloads.\n if (total > 1024 * 1024) {\n throw new Error(\"request body exceeds 1 MiB limit\");\n }\n chunks.push(buf);\n }\n const text = Buffer.concat(chunks).toString(\"utf8\");\n return text ? JSON.parse(text) : undefined;\n}\n\nfunction timingSafeEqual(a: string, b: string): boolean {\n // Length check is not timing-safe but is fine — the token length is fixed\n // and known to attackers regardless. The byte compare must be timing-safe.\n if (a.length !== b.length) return false;\n return nodeTimingSafeEqual(Buffer.from(a, \"utf8\"), Buffer.from(b, \"utf8\"));\n}\n\nconst DEFAULT_LOOPBACK_HOSTNAMES = [\"127.0.0.1\", \"localhost\", \"::1\"] as const;\n\n// Extract the hostname portion of a Host header value, stripping the port.\n// IPv6 hosts arrive bracketed (`[::1]:5810`); plain hosts as `host:port`\n// or bare `host`. Returns lowercased hostname or null on shapes we don't\n// recognise (caller treats null as \"reject\").\nfunction parseHostHeader(value: string): string | null {\n const trimmed = value.trim();\n if (trimmed === \"\") return null;\n if (trimmed.startsWith(\"[\")) {\n const close = trimmed.indexOf(\"]\");\n if (close === -1) return null;\n return trimmed.slice(1, close).toLowerCase();\n }\n const colon = trimmed.indexOf(\":\");\n return (colon === -1 ? trimmed : trimmed.slice(0, colon)).toLowerCase();\n}\n\n// DNS-rebinding defence: every request must arrive with a Host header\n// pointing at one of the allowed names. Same gate applies to Origin when\n// the browser sends one. Absent Origin is fine — many legitimate\n// same-origin requests omit it. `Origin: null` is allowed because\n// sandboxed iframes and `file://` pages send it; the Host check still\n// constrains the network path so the Origin allowance isn't load-bearing.\nfunction isAllowedHost(value: string | undefined, allowed: Set<string>): boolean {\n const host = value === undefined ? null : parseHostHeader(value);\n return host !== null && allowed.has(host);\n}\n\nfunction isAllowedOrigin(value: string | undefined, allowed: Set<string>): boolean {\n if (value === undefined || value === \"\" || value === \"null\") return true;\n try {\n // WHATWG URL keeps the brackets on IPv6 hostnames (`[::1]`), but the\n // allow-list stores them stripped (matching parseHostHeader's output)\n // so origins and hosts compare consistently.\n let hostname = new URL(value).hostname.toLowerCase();\n if (hostname.startsWith(\"[\") && hostname.endsWith(\"]\")) {\n hostname = hostname.slice(1, -1);\n }\n return allowed.has(hostname);\n } catch {\n return false;\n }\n}\n\nexport async function startDevServer(opts: DevServerOptions): Promise<DevServer> {\n const {\n view,\n data,\n dataLoader,\n viewsRoot,\n mutations = {},\n title = \"ui-leaf\",\n port,\n openBrowser = true,\n shell = \"tab\",\n heartbeatTimeoutMs = 75_000,\n startupGraceMs = 30_000,\n csp,\n allowedHosts,\n silent = false,\n } = opts;\n const cspHeader = resolveCsp(csp);\n const allowedHostSet = new Set<string>(DEFAULT_LOOPBACK_HOSTNAMES);\n for (const h of allowedHosts ?? []) allowedHostSet.add(h.toLowerCase());\n const allowedHostList = [...allowedHostSet].join(\", \");\n\n // Resolve port: 0 means \"let the OS pick\" — but rsbuild doesn't honor\n // port: 0 (it literally binds to 0 and reports back 0). Pre-allocate a\n // free port via Node's net layer and pass that explicit port to rsbuild\n // instead. Default 5810 if no port specified.\n const resolvedPort = port === 0 ? await findFreePort() : (port ?? 5810);\n\n // Programmatic consumers (esp. non-Node CLIs spawning ui-leaf as a\n // subprocess) often reserve stdout for a structured protocol. Belt-and-\n // -suspenders: rsbuild's logLevel:'silent' catches its own logger output,\n // and process.stdout.write is redirected to stderr to catch anything\n // that bypasses rsbuild's logger (banner before logger init, third-party\n // module writes, etc.).\n const restoreStdout: (() => void) | null = silent ? redirectStdoutToStderr() : null;\n\n // Hoisted so the outer catch can sweep them on a setup-time throw.\n let tempDir: string | null = null;\n let cleanupOnExit: (() => void) | null = null;\n\n try {\n if (view.includes(\"/\") || view.includes(\"\\\\\")) {\n throw new Error(\n `ui-leaf: view '${view}' must be a bare identifier with no path separators`,\n );\n }\n const viewsRootAbs = resolve(viewsRoot);\n const viewAbs = resolve(viewsRootAbs, `${view}.tsx`);\n if (!viewAbs.startsWith(viewsRootAbs + sep)) {\n throw new Error(\n `ui-leaf: view '${view}' resolves outside viewsRoot`,\n );\n }\n try {\n await stat(viewAbs);\n } catch {\n throw new Error(\n `ui-leaf: view '${view}' not found at ${viewAbs} (looked for .tsx; viewsRoot=${viewsRoot})`,\n );\n }\n\n if (data !== undefined && dataLoader) {\n throw new Error(\"ui-leaf: pass data or dataLoader, not both\");\n }\n\n const token = randomBytes(32).toString(\"hex\");\n tempDir = await mkdtemp(join(tmpdir(), \"ui-leaf-\"));\n\n // Synchronous fallback for any exit path that bypasses cleanup() —\n // uncaught throws and programmatic process.exit. SIGKILL, OOM-kill,\n // and power loss skip every Node hook, so the next startup's sweep is\n // the backstop for those. rmSync with force is idempotent when the\n // dir is already gone, so this no-ops safely if cleanup() ran first.\n const dirToSweep = tempDir;\n cleanupOnExit = (): void => {\n try {\n rmSync(dirToSweep, { recursive: true, force: true });\n } catch {\n // Exit handlers must not throw.\n }\n };\n process.on(\"exit\", cleanupOnExit);\n\n // Mop up any abandoned ui-leaf-* dirs from prior crashed runs that\n // SIGKILL'd or otherwise skipped both cleanup() and the exit handler.\n // Fire-and-forget so the sweep never blocks startup.\n void sweepStaleTempDirs();\n\n // Eagerly invoke the loader before writing any files. The resolved value\n // lives only in this closure — it is never written to disk. If the loader\n // rejects, the setup-failure catch below sweeps tempDir and removes the\n // exit fallback before re-throwing, so nothing lingers on disk.\n let loadedData: unknown;\n if (dataLoader) {\n loadedData = await dataLoader();\n }\n\n const entryPath = join(dirToSweep, \"entry.tsx\");\n\n // Shared browser-side functions: same across both entry variants.\n const sharedEntryFns = `async function mutate(name, args) {\n const res = await fetch(\"/mutate\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n ...(token ? { Authorization: \"Bearer \" + token } : {}),\n },\n body: JSON.stringify({ name, args }),\n });\n const text = await res.text().catch(function () { return \"\"; });\n if (!res.ok) {\n var detail = text;\n try {\n var parsed = text ? JSON.parse(text) : null;\n if (parsed && typeof parsed === \"object\" && typeof parsed.error === \"string\") {\n detail = parsed.error;\n }\n } catch (_) { /* keep raw text */ }\n throw new Error(\"ui-leaf: mutation '\" + name + \"' failed (\" + res.status + \"): \" + detail);\n }\n return text ? JSON.parse(text) : undefined;\n}\n\nasync function heartbeat() {\n try {\n await fetch(\"/heartbeat\", {\n method: \"POST\",\n headers: token ? { Authorization: \"Bearer \" + token } : {},\n });\n } catch {\n /* server may have shut down; ignore */\n }\n}\nsetInterval(heartbeat, 5000);\nheartbeat();`;\n\n await writeFile(\n entryPath,\n dataLoader\n ? `import { createRoot } from \"react-dom/client\";\nimport View from ${JSON.stringify(viewAbs)};\n\nconst ctx = (globalThis).__UI_LEAF__ || {};\nconst token = ctx.token;\n\n${sharedEntryFns}\n\n// Heartbeat runs outside bootstrap so a slow loader cannot trip the watcher.\nasync function bootstrap() {\n const res = await fetch(\"/api/data\", {\n headers: token ? { Authorization: \"Bearer \" + token } : {},\n });\n if (!res.ok) {\n const text = await res.text().catch(function () { return \"\"; });\n throw new Error(\"ui-leaf: /api/data fetch failed (\" + res.status + \"): \" + text);\n }\n const data = await res.json();\n const el = document.getElementById(\"root\");\n if (!el) throw new Error(\"ui-leaf: #root element missing\");\n createRoot(el).render(<View data={data} mutate={mutate} />);\n}\nbootstrap();\n`\n : `import { createRoot } from \"react-dom/client\";\nimport View from ${JSON.stringify(viewAbs)};\n\nconst ctx = (globalThis).__UI_LEAF__ || {};\nconst data = ctx.data;\nconst token = ctx.token;\n\n${sharedEntryFns}\n\nconst el = document.getElementById(\"root\");\nif (!el) throw new Error(\"ui-leaf: #root element missing\");\ncreateRoot(el).render(<View data={data} mutate={mutate} />);\n`,\n );\n\n // Double-stringify encodes data as a JS string literal so the inline\n // assignment uses JSON.parse(…) at load time rather than an object literal.\n // This sidesteps ECMAScript Annex B.3.1, which lets a `__proto__` key in\n // an object literal mutate the prototype — JSON.parse has no such carve-out.\n const tokenInline = JSON.stringify(token);\n const titleEscaped = title\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\");\n const htmlPath = join(dirToSweep, \"index.html\");\n // When dataLoader is set, omit data from the inline script — the loader\n // value is served at /api/data and never written to disk.\n const scriptContent = dataLoader\n ? `{ token: ${tokenInline} }`\n : `{ data: JSON.parse(${escapeForScriptTag(JSON.stringify(JSON.stringify(data ?? null)))}), token: ${tokenInline} }`;\n await writeFile(\n htmlPath,\n `<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <title>${titleEscaped}</title>\n <script>window.__UI_LEAF__ = ${scriptContent};</script>\n </head>\n <body>\n <div id=\"root\"></div>\n </body>\n</html>\n`,\n );\n\n let lastHeartbeatAt = Date.now();\n let closeRequested = false;\n let resolveClosed: () => void = () => {};\n const closed = new Promise<void>((r) => {\n resolveClosed = r;\n });\n\n function checkAuth(req: IncomingMessage): boolean {\n const header = req.headers.authorization ?? \"\";\n const match = /^Bearer (.+)$/.exec(header);\n if (!match) return false;\n return timingSafeEqual(match[1]!, token);\n }\n\n function sendJson(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, { \"Content-Type\": \"application/json\" });\n res.end(body === undefined ? \"\" : JSON.stringify(body));\n }\n\n const rsbuild = await createRsbuild({\n cwd: dirToSweep,\n rsbuildConfig: {\n plugins: [pluginReact()],\n ...(silent ? { logLevel: \"silent\" as const } : {}),\n source: { entry: { index: entryPath } },\n // 5810 is unused by the major Node dev tools (vite=5173, parcel=1234,\n // webpack=8080, next/CRA=3000). rsbuild auto-bumps to the next free\n // port if 5810 is busy, so collisions are graceful.\n server: { port: resolvedPort, host: \"127.0.0.1\" },\n // Note: `dev.setupMiddlewares` is deprecated as of rsbuild 2.x in\n // favor of `server.setup`, but the new API has a different signature\n // and bypasses the rsbuild CSRF middleware in ways that break our\n // POST endpoints. Sticking with the deprecated path for v1.\n dev: {\n setupMiddlewares: [\n (middlewares) => {\n middlewares.unshift(async (req, res, next) => {\n // DNS-rebinding gate: reject anything not arriving with an\n // allowed Host (and Origin, when present) before any auth\n // or routing runs. Done in ui-leaf's own middleware rather\n // than relying on rsbuild so the property is explicit and\n // survives upstream API churn.\n const hostOk = isAllowedHost(req.headers.host, allowedHostSet);\n const originOk = isAllowedOrigin(req.headers.origin, allowedHostSet);\n if (!hostOk || !originOk) {\n const offender = !hostOk\n ? `Host \"${req.headers.host ?? \"(absent)\"}\"`\n : `Origin \"${req.headers.origin}\"`;\n res.statusCode = 403;\n res.setHeader(\"Content-Type\", \"text/plain; charset=utf-8\");\n res.end(\n `ui-leaf: refusing request with ${offender} — only the following hostnames are accepted to prevent DNS rebinding: ${allowedHostList}. Open the dev server at http://localhost:${resolvedPort}/ or http://127.0.0.1:${resolvedPort}/, or pass { allowedHosts: [\"my-alias\"] } to mount() to permit a custom alias.\\n`,\n );\n return;\n }\n const url = req.url ?? \"\";\n if (req.method === \"POST\" && url === \"/heartbeat\") {\n if (!checkAuth(req)) {\n sendJson(res, 401, { error: \"unauthorized\" });\n return;\n }\n lastHeartbeatAt = Date.now();\n sendJson(res, 204, undefined);\n return;\n }\n if (req.method === \"POST\" && url === \"/mutate\") {\n if (!checkAuth(req)) {\n sendJson(res, 401, { error: \"unauthorized\" });\n return;\n }\n let body: { name?: string; args?: unknown };\n try {\n body = (await readJsonBody(req)) as typeof body;\n } catch (err) {\n sendJson(res, 400, {\n error: err instanceof Error ? err.message : \"bad request\",\n });\n return;\n }\n const name = body?.name;\n if (typeof name !== \"string\" || name.length === 0) {\n sendJson(res, 400, { error: \"missing mutation name\" });\n return;\n }\n if (!Object.hasOwn(mutations, name)) {\n sendJson(res, 404, {\n error: `ui-leaf: no mutation handler registered for '${name}'. Add it to the mutations: { } map passed to mount().`,\n });\n return;\n }\n const handler = mutations[name]!;\n try {\n const result = await handler(body.args);\n sendJson(res, 200, result ?? null);\n } catch (err) {\n sendJson(res, 500, {\n error: err instanceof Error ? err.message : String(err),\n });\n }\n return;\n }\n if (req.method === \"GET\" && url === \"/api/data\") {\n if (!dataLoader) {\n next();\n return;\n }\n if (!checkAuth(req)) {\n sendJson(res, 401, { error: \"unauthorized\" });\n return;\n }\n sendJson(res, 200, loadedData !== undefined ? loadedData : null);\n return;\n }\n next();\n });\n // CSP middleware unshifts AFTER the route handler so it ends up\n // at index 0 — runs first on every request, sets the header,\n // then yields to the route or rsbuild static handlers. No-op\n // when csp resolves to null (the \"off\" default).\n if (cspHeader) {\n middlewares.unshift((_req, res, next) => {\n res.setHeader(\"Content-Security-Policy\", cspHeader);\n next();\n });\n }\n },\n ],\n },\n html: { template: htmlPath },\n tools: {\n rspack: {\n resolve: {\n alias: {\n react: reactPath,\n \"react-dom\": reactDomPath,\n },\n },\n },\n },\n },\n });\n\n const devServer = await rsbuild.startDevServer();\n const actualPort = devServer.port;\n const url = `http://127.0.0.1:${actualPort}`;\n const startedAt = Date.now();\n\n // DNS-rebinding gate for WebSocket upgrades. The setupMiddlewares stack\n // above only fires for `request` events — `upgrade` events bypass it\n // entirely and reach rsbuild's HMR socket directly. Prepend a listener\n // on the underlying http server so our Host check runs before rsbuild's\n // HMR upgrade handler; on reject we destroy the socket (pure TCP close,\n // no HTTP response) so no HMR frames can leak to a rebound origin.\n const httpServer = devServer.server.httpServer;\n const upgradeGate = (req: IncomingMessage, socket: Socket): void => {\n if (!isAllowedHost(req.headers.host, allowedHostSet)) {\n socket.destroy();\n }\n };\n httpServer?.prependListener(\"upgrade\", upgradeGate);\n\n let heartbeatWatcher: NodeJS.Timeout | undefined;\n\n const cleanup = async (): Promise<void> => {\n if (closeRequested) return;\n closeRequested = true;\n if (heartbeatWatcher) clearInterval(heartbeatWatcher);\n httpServer?.removeListener(\"upgrade\", upgradeGate);\n await devServer.server.close();\n await rm(dirToSweep, { recursive: true, force: true });\n // Listener is per-mount — unhook so long-lived hosts that mount many\n // views in sequence don't pile up exit handlers.\n if (cleanupOnExit) process.off(\"exit\", cleanupOnExit);\n if (restoreStdout) restoreStdout();\n resolveClosed();\n };\n\n heartbeatWatcher = setInterval(() => {\n const now = Date.now();\n if (now - startedAt < startupGraceMs) return;\n if (now - lastHeartbeatAt > heartbeatTimeoutMs) {\n void cleanup();\n }\n }, 1000);\n\n if (openBrowser) {\n if (shell === \"app\") {\n const launched = await openInAppMode(url);\n if (!launched) {\n process.stderr.write(\n `ui-leaf: shell:\"app\" requested but no Chromium browser found; falling back to default browser tab.\\n`,\n );\n await open(url);\n }\n } else {\n await open(url);\n }\n }\n\n return {\n url,\n port: actualPort,\n closed,\n close: cleanup,\n };\n } catch (err) {\n // Setup failed before close() got wired up, so the caller has no\n // close to call. The user's catch may keep the process alive doing\n // other work, so sweep the (possibly populated) tempDir now and\n // unhook the exit fallback rather than waiting on process exit.\n if (tempDir) {\n try {\n rmSync(tempDir, { recursive: true, force: true });\n } catch {\n // Best-effort; the next startup's sweep will catch it.\n }\n }\n if (cleanupOnExit) process.off(\"exit\", cleanupOnExit);\n restoreStdout?.();\n throw err;\n }\n}\n"],"mappings":"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","names":["resolve","resolve","checkAuth","sendJson","url","resolve"]}
|