@openthink/ui-leaf 0.3.3 → 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 CHANGED
@@ -92,7 +92,8 @@ import type { ViewProps, MutationHandler } from "@openthink/ui-leaf/view";
92
92
 
93
93
  await mount({
94
94
  view, // resolves <viewsRoot>/<view>.tsx
95
- 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)
96
97
  mutations, // Record<string, MutationHandler> (optional)
97
98
  viewsRoot, // optional, default: <cwd>/views
98
99
  title, // optional, default: "ui-leaf"
@@ -159,7 +160,18 @@ Be deliberate — every name you add becomes a viable rebinding target. Don't ad
159
160
 
160
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.
161
162
 
162
- 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, keep it in memory in your CLI and inject it into the view via an authenticated `connect-src 'self'` fetch on boot rather than passing it through `data`.
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.
163
175
 
164
176
  ## Sharing views across users
165
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
- await writeFile(
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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__ = { data: JSON.parse(${dataInline}), token: ${tokenInline} };</script>
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 /**\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, do not pass the sensitive payload through `data`;\n * keep it in memory in your CLI and inject it into the view via an\n * authenticated `connect-src 'self'` fetch on boot. See the README\n * sections \"How it works\" and \"Data-at-rest in the temp directory\"\n * for the full framing.\n */\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, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\");\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, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\");\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
@@ -13,13 +13,32 @@ interface MountOptions {
13
13
  * `/mutate` against drive-by cross-origin requests in the browser, not
14
14
  * against other processes on the machine. For PHI, PCI, financial
15
15
  * records, or anything else where a same-UID local reader is in your
16
- * threat model, do not pass the sensitive payload through `data`;
17
- * keep it in memory in your CLI and inject it into the view via an
18
- * authenticated `connect-src 'self'` fetch on boot. See the README
19
- * sections "How it works" and "Data-at-rest in the temp directory"
20
- * for the full framing.
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.
21
20
  */
22
- data: unknown;
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>;
23
42
  /**
24
43
  * Mutation handlers the view can call via mutate(name, args).
25
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
- await writeFile(
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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__ = { data: JSON.parse(${dataInline}), token: ${tokenInline} };</script>
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 /**\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, do not pass the sensitive payload through `data`;\n * keep it in memory in your CLI and inject it into the view via an\n * authenticated `connect-src 'self'` fetch on boot. See the README\n * sections \"How it works\" and \"Data-at-rest in the temp directory\"\n * for the full framing.\n */\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, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\");\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, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\");\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":";AAGA,SAAS,WAAAA,gBAAe;;;ACAxB,SAAS,aAAa,mBAAmB,2BAA2B;AACpE,SAAS,cAAc;AACvB,SAAS,SAAS,SAAS,IAAI,MAAM,iBAAiB;AAEtD,SAAS,qBAAqB;AAC9B,SAAS,gBAAgB,uBAAoC;AAC7D,SAAS,cAAc;AACvB,SAAS,SAAS,MAAM,SAAS,WAAW;AAC5C,SAAS,qBAAqB;AAC9B,SAAS,mBAAmB;AAC5B,OAAO,QAAQ,YAAY;AAO3B,IAAM,gBAAgB,cAAc,YAAY,GAAG;AACnD,IAAM,YAAY,QAAQ,cAAc,QAAQ,oBAAoB,CAAC;AACrE,IAAM,eAAe,QAAQ,cAAc,QAAQ,wBAAwB,CAAC;AAK5E,IAAM,wBAAwB,QAAQ,OAAO,MAAM,KAAK,QAAQ,MAAM;AACtE,IAAI,sBAAsB;AAQ1B,eAAe,eAAgC;AAC7C,SAAO,IAAI,QAAQ,CAACC,UAAS,WAAW;AACtC,UAAM,SAAS,gBAAgB;AAC/B,WAAO,MAAM;AACb,WAAO,GAAG,SAAS,MAAM;AACzB,WAAO,OAAO,GAAG,aAAa,MAAM;AAClC,YAAM,OAAO,OAAO,QAAQ;AAC5B,UAAI,CAAC,QAAQ,OAAO,SAAS,UAAU;AACrC,eAAO,MAAM;AACb,eAAO,IAAI,MAAM,mDAAmD,CAAC;AACrE;AAAA,MACF;AACA,YAAM,OAAO,KAAK;AAClB,aAAO,MAAM,MAAMA,SAAQ,IAAI,CAAC;AAAA,IAClC,CAAC;AAAA,EACH,CAAC;AACH;AAMA,IAAM,uBAAuB,KAAK,KAAK,KAAK;AAQ5C,eAAe,qBAAoC;AACjD,MAAI;AACF,UAAM,OAAO,OAAO;AACpB,UAAM,UAAU,MAAM,QAAQ,MAAM,EAAE,eAAe,KAAK,CAAC;AAC3D,UAAM,SAAS,KAAK,IAAI,IAAI;AAC5B,UAAM,QAAQ;AAAA,MACZ,QACG,OAAO,CAAC,MAAM,EAAE,YAAY,KAAK,EAAE,KAAK,WAAW,UAAU,CAAC,EAC9D,IAAI,OAAO,MAAM;AAChB,cAAM,OAAO,KAAK,MAAM,EAAE,IAAI;AAC9B,YAAI;AACF,gBAAM,OAAO,MAAM,KAAK,IAAI;AAC5B,cAAI,KAAK,UAAU,QAAQ;AACzB,kBAAM,GAAG,MAAM,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC;AAAA,UACjD;AAAA,QACF,QAAQ;AAAA,QAGR;AAAA,MACF,CAAC;AAAA,IACL;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAMA,SAAS,yBAAqC;AAC5C;AACA,MAAI,wBAAwB,GAAG;AAG7B,YAAQ,OAAO,SAAS,CAAC,OAAY,KAAW,OAC9C,QAAQ,OAAO,MAAM,OAAO,KAAK,EAAE;AAAA,EACvC;AACA,MAAI,WAAW;AACf,SAAO,MAAM;AACX,QAAI,SAAU;AACd,eAAW;AACX;AACA,QAAI,wBAAwB,GAAG;AAC7B,cAAQ,OAAO,QAAQ;AAAA,IACzB;AAAA,EACF;AACF;AAmBA,eAAe,cAAc,KAA+B;AAE1D,QAAM,aAAa,CAAC,KAAK,QAAQ,KAAK,MAAM,KAAK,KAAK;AACtD,aAAW,OAAO,YAAY;AAC5B,QAAI;AACF,YAAM,KAAK,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,WAAW,CAAC,SAAS,GAAG,EAAE,EAAE,EAAE,CAAC;AACnE,aAAO;AAAA,IACT,QAAQ;AAAA,IAER;AAAA,EACF;AACA,SAAO;AACT;AASA,IAAM,aAAa;AAAA,EACjB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,EAAE,KAAK,IAAI;AAEX,SAAS,WAAW,KAA2C;AAC7D,MAAI,CAAC,OAAO,QAAQ,MAAO,QAAO;AAClC,MAAI,QAAQ,SAAU,QAAO;AAC7B,SAAO;AACT;AA4DA,SAAS,mBAAmB,MAAsB;AAGhD,SAAO,KACJ,QAAQ,MAAM,SAAS,EACvB,QAAQ,WAAW,SAAS,EAC5B,QAAQ,WAAW,SAAS;AACjC;AAEA,eAAe,aAAa,KAAwC;AAClE,QAAM,SAAmB,CAAC;AAC1B,MAAI,QAAQ;AACZ,mBAAiB,SAAS,KAAK;AAC7B,UAAM,MAAM;AACZ,aAAS,IAAI;AAEb,QAAI,QAAQ,OAAO,MAAM;AACvB,YAAM,IAAI,MAAM,kCAAkC;AAAA,IACpD;AACA,WAAO,KAAK,GAAG;AAAA,EACjB;AACA,QAAM,OAAO,OAAO,OAAO,MAAM,EAAE,SAAS,MAAM;AAClD,SAAO,OAAO,KAAK,MAAM,IAAI,IAAI;AACnC;AAEA,SAAS,gBAAgB,GAAW,GAAoB;AAGtD,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,SAAO,oBAAoB,OAAO,KAAK,GAAG,MAAM,GAAG,OAAO,KAAK,GAAG,MAAM,CAAC;AAC3E;AAEA,IAAM,6BAA6B,CAAC,aAAa,aAAa,KAAK;AAMnE,SAAS,gBAAgB,OAA8B;AACrD,QAAM,UAAU,MAAM,KAAK;AAC3B,MAAI,YAAY,GAAI,QAAO;AAC3B,MAAI,QAAQ,WAAW,GAAG,GAAG;AAC3B,UAAM,QAAQ,QAAQ,QAAQ,GAAG;AACjC,QAAI,UAAU,GAAI,QAAO;AACzB,WAAO,QAAQ,MAAM,GAAG,KAAK,EAAE,YAAY;AAAA,EAC7C;AACA,QAAM,QAAQ,QAAQ,QAAQ,GAAG;AACjC,UAAQ,UAAU,KAAK,UAAU,QAAQ,MAAM,GAAG,KAAK,GAAG,YAAY;AACxE;AAQA,SAAS,cAAc,OAA2B,SAA+B;AAC/E,QAAM,OAAO,UAAU,SAAY,OAAO,gBAAgB,KAAK;AAC/D,SAAO,SAAS,QAAQ,QAAQ,IAAI,IAAI;AAC1C;AAEA,SAAS,gBAAgB,OAA2B,SAA+B;AACjF,MAAI,UAAU,UAAa,UAAU,MAAM,UAAU,OAAQ,QAAO;AACpE,MAAI;AAIF,QAAI,WAAW,IAAI,IAAI,KAAK,EAAE,SAAS,YAAY;AACnD,QAAI,SAAS,WAAW,GAAG,KAAK,SAAS,SAAS,GAAG,GAAG;AACtD,iBAAW,SAAS,MAAM,GAAG,EAAE;AAAA,IACjC;AACA,WAAO,QAAQ,IAAI,QAAQ;AAAA,EAC7B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAsB,eAAe,MAA4C;AAC/E,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,YAAY,CAAC;AAAA,IACb,QAAQ;AAAA,IACR;AAAA,IACA,cAAc;AAAA,IACd,QAAQ;AAAA,IACR,qBAAqB;AAAA,IACrB,iBAAiB;AAAA,IACjB;AAAA,IACA;AAAA,IACA,SAAS;AAAA,EACX,IAAI;AACJ,QAAM,YAAY,WAAW,GAAG;AAChC,QAAM,iBAAiB,IAAI,IAAY,0BAA0B;AACjE,aAAW,KAAK,gBAAgB,CAAC,EAAG,gBAAe,IAAI,EAAE,YAAY,CAAC;AACtE,QAAM,kBAAkB,CAAC,GAAG,cAAc,EAAE,KAAK,IAAI;AAMrD,QAAM,eAAe,SAAS,IAAI,MAAM,aAAa,IAAK,QAAQ;AAQlE,QAAM,gBAAqC,SAAS,uBAAuB,IAAI;AAG/E,MAAI,UAAyB;AAC7B,MAAI,gBAAqC;AAEzC,MAAI;AAgLJ,QAASC,aAAT,SAAmB,KAA+B;AAChD,YAAM,SAAS,IAAI,QAAQ,iBAAiB;AAC5C,YAAM,QAAQ,gBAAgB,KAAK,MAAM;AACzC,UAAI,CAAC,MAAO,QAAO;AACnB,aAAO,gBAAgB,MAAM,CAAC,GAAI,KAAK;AAAA,IACzC,GAESC,YAAT,SAAkB,KAAqB,QAAgB,MAAqB;AAC1E,UAAI,UAAU,QAAQ,EAAE,gBAAgB,mBAAmB,CAAC;AAC5D,UAAI,IAAI,SAAS,SAAY,KAAK,KAAK,UAAU,IAAI,CAAC;AAAA,IACxD;AAVS,oBAAAD,YAOA,WAAAC;AAtLT,QAAI,KAAK,SAAS,GAAG,KAAK,KAAK,SAAS,IAAI,GAAG;AAC7C,YAAM,IAAI;AAAA,QACR,kBAAkB,IAAI;AAAA,MACxB;AAAA,IACF;AACA,UAAM,eAAe,QAAQ,SAAS;AACtC,UAAM,UAAU,QAAQ,cAAc,GAAG,IAAI,MAAM;AACnD,QAAI,CAAC,QAAQ,WAAW,eAAe,GAAG,GAAG;AAC3C,YAAM,IAAI;AAAA,QACR,kBAAkB,IAAI;AAAA,MACxB;AAAA,IACF;AACA,QAAI;AACF,YAAM,KAAK,OAAO;AAAA,IACpB,QAAQ;AACN,YAAM,IAAI;AAAA,QACR,kBAAkB,IAAI,kBAAkB,OAAO,gCAAgC,SAAS;AAAA,MAC1F;AAAA,IACF;AAEA,QAAI,SAAS,UAAa,YAAY;AACpC,YAAM,IAAI,MAAM,4CAA4C;AAAA,IAC9D;AAEA,UAAM,QAAQ,YAAY,EAAE,EAAE,SAAS,KAAK;AAC5C,cAAU,MAAM,QAAQ,KAAK,OAAO,GAAG,UAAU,CAAC;AAOlD,UAAM,aAAa;AACnB,oBAAgB,MAAY;AAC1B,UAAI;AACF,eAAO,YAAY,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC;AAAA,MACrD,QAAQ;AAAA,MAER;AAAA,IACF;AACA,YAAQ,GAAG,QAAQ,aAAa;AAKhC,SAAK,mBAAmB;AAMxB,QAAI;AACJ,QAAI,YAAY;AACd,mBAAa,MAAM,WAAW;AAAA,IAChC;AAEA,UAAM,YAAY,KAAK,YAAY,WAAW;AAG9C,UAAM,iBAAiB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAoCvB,UAAM;AAAA,MACJ;AAAA,MACA,aACI;AAAA,mBACW,KAAK,UAAU,OAAO,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA,EAKxC,cAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAkBR;AAAA,mBACW,KAAK,UAAU,OAAO,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMxC,cAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMd;AAMA,UAAM,cAAc,KAAK,UAAU,KAAK;AACxC,UAAM,eAAe,MAClB,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM;AACvB,UAAM,WAAW,KAAK,YAAY,YAAY;AAG9C,UAAM,gBAAgB,aAClB,YAAY,WAAW,OACvB,sBAAsB,mBAAmB,KAAK,UAAU,KAAK,UAAU,QAAQ,IAAI,CAAC,CAAC,CAAC,aAAa,WAAW;AAClH,UAAM;AAAA,MACJ;AAAA,MACA;AAAA;AAAA;AAAA;AAAA,aAIS,YAAY;AAAA,mCACU,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAO9C;AAEA,QAAI,kBAAkB,KAAK,IAAI;AAC/B,QAAI,iBAAiB;AACrB,QAAI,gBAA4B,MAAM;AAAA,IAAC;AACvC,UAAM,SAAS,IAAI,QAAc,CAAC,MAAM;AACtC,sBAAgB;AAAA,IAClB,CAAC;AAcD,UAAM,UAAU,MAAM,cAAc;AAAA,MAClC,KAAK;AAAA,MACL,eAAe;AAAA,QACb,SAAS,CAAC,YAAY,CAAC;AAAA,QACvB,GAAI,SAAS,EAAE,UAAU,SAAkB,IAAI,CAAC;AAAA,QAChD,QAAQ,EAAE,OAAO,EAAE,OAAO,UAAU,EAAE;AAAA;AAAA;AAAA;AAAA,QAItC,QAAQ,EAAE,MAAM,cAAc,MAAM,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA,QAKhD,KAAK;AAAA,UACH,kBAAkB;AAAA,YAChB,CAAC,gBAAgB;AACf,0BAAY,QAAQ,OAAO,KAAK,KAAK,SAAS;AAM5C,sBAAM,SAAS,cAAc,IAAI,QAAQ,MAAM,cAAc;AAC7D,sBAAM,WAAW,gBAAgB,IAAI,QAAQ,QAAQ,cAAc;AACnE,oBAAI,CAAC,UAAU,CAAC,UAAU;AACxB,wBAAM,WAAW,CAAC,SACd,SAAS,IAAI,QAAQ,QAAQ,UAAU,MACvC,WAAW,IAAI,QAAQ,MAAM;AACjC,sBAAI,aAAa;AACjB,sBAAI,UAAU,gBAAgB,2BAA2B;AACzD,sBAAI;AAAA,oBACF,kCAAkC,QAAQ,+EAA0E,eAAe,6CAA6C,YAAY,yBAAyB,YAAY;AAAA;AAAA,kBACnO;AACA;AAAA,gBACF;AACA,sBAAMC,OAAM,IAAI,OAAO;AACvB,oBAAI,IAAI,WAAW,UAAUA,SAAQ,cAAc;AACjD,sBAAI,CAACF,WAAU,GAAG,GAAG;AACnB,oBAAAC,UAAS,KAAK,KAAK,EAAE,OAAO,eAAe,CAAC;AAC5C;AAAA,kBACF;AACA,oCAAkB,KAAK,IAAI;AAC3B,kBAAAA,UAAS,KAAK,KAAK,MAAS;AAC5B;AAAA,gBACF;AACA,oBAAI,IAAI,WAAW,UAAUC,SAAQ,WAAW;AAC9C,sBAAI,CAACF,WAAU,GAAG,GAAG;AACnB,oBAAAC,UAAS,KAAK,KAAK,EAAE,OAAO,eAAe,CAAC;AAC5C;AAAA,kBACF;AACA,sBAAI;AACJ,sBAAI;AACF,2BAAQ,MAAM,aAAa,GAAG;AAAA,kBAChC,SAAS,KAAK;AACZ,oBAAAA,UAAS,KAAK,KAAK;AAAA,sBACjB,OAAO,eAAe,QAAQ,IAAI,UAAU;AAAA,oBAC9C,CAAC;AACD;AAAA,kBACF;AACA,wBAAM,OAAO,MAAM;AACnB,sBAAI,OAAO,SAAS,YAAY,KAAK,WAAW,GAAG;AACjD,oBAAAA,UAAS,KAAK,KAAK,EAAE,OAAO,wBAAwB,CAAC;AACrD;AAAA,kBACF;AACA,sBAAI,CAAC,OAAO,OAAO,WAAW,IAAI,GAAG;AACnC,oBAAAA,UAAS,KAAK,KAAK;AAAA,sBACjB,OAAO,gDAAgD,IAAI;AAAA,oBAC7D,CAAC;AACD;AAAA,kBACF;AACA,wBAAM,UAAU,UAAU,IAAI;AAC9B,sBAAI;AACF,0BAAM,SAAS,MAAM,QAAQ,KAAK,IAAI;AACtC,oBAAAA,UAAS,KAAK,KAAK,UAAU,IAAI;AAAA,kBACnC,SAAS,KAAK;AACZ,oBAAAA,UAAS,KAAK,KAAK;AAAA,sBACjB,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,oBACxD,CAAC;AAAA,kBACH;AACA;AAAA,gBACF;AACA,oBAAI,IAAI,WAAW,SAASC,SAAQ,aAAa;AAC/C,sBAAI,CAAC,YAAY;AACf,yBAAK;AACL;AAAA,kBACF;AACA,sBAAI,CAACF,WAAU,GAAG,GAAG;AACnB,oBAAAC,UAAS,KAAK,KAAK,EAAE,OAAO,eAAe,CAAC;AAC5C;AAAA,kBACF;AACA,kBAAAA,UAAS,KAAK,KAAK,eAAe,SAAY,aAAa,IAAI;AAC/D;AAAA,gBACF;AACA,qBAAK;AAAA,cACP,CAAC;AAKD,kBAAI,WAAW;AACb,4BAAY,QAAQ,CAAC,MAAM,KAAK,SAAS;AACvC,sBAAI,UAAU,2BAA2B,SAAS;AAClD,uBAAK;AAAA,gBACP,CAAC;AAAA,cACH;AAAA,YACF;AAAA,UACF;AAAA,QACF;AAAA,QACA,MAAM,EAAE,UAAU,SAAS;AAAA,QAC3B,OAAO;AAAA,UACL,QAAQ;AAAA,YACN,SAAS;AAAA,cACP,OAAO;AAAA,gBACL,OAAO;AAAA,gBACP,aAAa;AAAA,cACf;AAAA,YACF;AAAA,UACF;AAAA,QACF;AAAA,MACF;AAAA,IACF,CAAC;AAED,UAAM,YAAY,MAAM,QAAQ,eAAe;AAC/C,UAAM,aAAa,UAAU;AAC7B,UAAM,MAAM,oBAAoB,UAAU;AAC1C,UAAM,YAAY,KAAK,IAAI;AAQ3B,UAAM,aAAa,UAAU,OAAO;AACpC,UAAM,cAAc,CAAC,KAAsB,WAAyB;AAClE,UAAI,CAAC,cAAc,IAAI,QAAQ,MAAM,cAAc,GAAG;AACpD,eAAO,QAAQ;AAAA,MACjB;AAAA,IACF;AACA,gBAAY,gBAAgB,WAAW,WAAW;AAElD,QAAI;AAEJ,UAAM,UAAU,YAA2B;AACzC,UAAI,eAAgB;AACpB,uBAAiB;AACjB,UAAI,iBAAkB,eAAc,gBAAgB;AACpD,kBAAY,eAAe,WAAW,WAAW;AACjD,YAAM,UAAU,OAAO,MAAM;AAC7B,YAAM,GAAG,YAAY,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC;AAGrD,UAAI,cAAe,SAAQ,IAAI,QAAQ,aAAa;AACpD,UAAI,cAAe,eAAc;AACjC,oBAAc;AAAA,IAChB;AAEA,uBAAmB,YAAY,MAAM;AACnC,YAAM,MAAM,KAAK,IAAI;AACrB,UAAI,MAAM,YAAY,eAAgB;AACtC,UAAI,MAAM,kBAAkB,oBAAoB;AAC9C,aAAK,QAAQ;AAAA,MACf;AAAA,IACF,GAAG,GAAI;AAEP,QAAI,aAAa;AACf,UAAI,UAAU,OAAO;AACnB,cAAM,WAAW,MAAM,cAAc,GAAG;AACxC,YAAI,CAAC,UAAU;AACb,kBAAQ,OAAO;AAAA,YACb;AAAA;AAAA,UACF;AACA,gBAAM,KAAK,GAAG;AAAA,QAChB;AAAA,MACF,OAAO;AACL,cAAM,KAAK,GAAG;AAAA,MAChB;AAAA,IACF;AAEA,WAAO;AAAA,MACL;AAAA,MACA,MAAM;AAAA,MACN;AAAA,MACA,OAAO;AAAA,IACT;AAAA,EACA,SAAS,KAAK;AAKZ,QAAI,SAAS;AACX,UAAI;AACF,eAAO,SAAS,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC;AAAA,MAClD,QAAQ;AAAA,MAER;AAAA,IACF;AACA,QAAI,cAAe,SAAQ,IAAI,QAAQ,aAAa;AACpD,oBAAgB;AAChB,UAAM;AAAA,EACR;AACF;;;ADrgBA,eAAsB,MAAM,MAA0C;AACpE,QAAM,YAAY,KAAK,aAAaE,SAAQ,QAAQ,IAAI,GAAG,OAAO;AAElE,QAAM,SAAS,MAAM,eAAe;AAAA,IAClC,MAAM,KAAK;AAAA,IACX,MAAM,KAAK;AAAA,IACX,YAAY,KAAK;AAAA,IACjB;AAAA,IACA,WAAW,KAAK;AAAA,IAChB,OAAO,KAAK;AAAA,IACZ,MAAM,KAAK;AAAA,IACX,aAAa,KAAK;AAAA,IAClB,OAAO,KAAK;AAAA,IACZ,oBAAoB,KAAK;AAAA,IACzB,gBAAgB,KAAK;AAAA,IACrB,KAAK,KAAK;AAAA,IACV,cAAc,KAAK;AAAA,IACnB,QAAQ,KAAK;AAAA,EACf,CAAC;AAED,QAAM,WAAW,CAAC,WAAiC;AACjD,UAAM,YAAY;AAChB,YAAM,OAAO,MAAM;AAEnB,cAAQ,KAAK,QAAQ,KAAK,MAAM;AAAA,IAClC,GAAG;AAAA,EACL;AACA,QAAM,SAAS,MAAY,SAAS,QAAQ;AAC5C,QAAM,UAAU,MAAY,SAAS,SAAS;AAC9C,UAAQ,KAAK,UAAU,MAAM;AAC7B,UAAQ,KAAK,WAAW,OAAO;AAE/B,MAAI,KAAK,QAAQ;AACf,QAAI,KAAK,OAAO,SAAS;AACvB,cAAQ,IAAI,UAAU,MAAM;AAC5B,cAAQ,IAAI,WAAW,OAAO;AAC9B,YAAM,OAAO,MAAM;AACnB,aAAO;AAAA,QACL,KAAK,OAAO;AAAA,QACZ,MAAM,OAAO;AAAA,QACb,QAAQ,QAAQ,QAAQ;AAAA,QACxB,OAAO,OAAO;AAAA,MAChB;AAAA,IACF;AACA,SAAK,OAAO;AAAA,MACV;AAAA,MACA,MAAM,KAAK,OAAO,MAAM;AAAA,MACxB,EAAE,MAAM,KAAK;AAAA,IACf;AAAA,EACF;AAEA,QAAM,SAAS,OAAO,OAAO,QAAQ,MAAM;AACzC,YAAQ,IAAI,UAAU,MAAM;AAC5B,YAAQ,IAAI,WAAW,OAAO;AAAA,EAChC,CAAC;AAED,SAAO;AAAA,IACL,KAAK,OAAO;AAAA,IACZ,MAAM,OAAO;AAAA,IACb;AAAA,IACA,OAAO,OAAO;AAAA,EAChB;AACF;","names":["resolve","resolve","checkAuth","sendJson","url","resolve"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openthink/ui-leaf",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Customizable browser views, on demand, for any CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Matt Pardini",