@openthink/ui-leaf 0.4.0 → 0.6.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
@@ -78,11 +78,15 @@ Three reasons:
78
78
 
79
79
  ## How it works
80
80
 
81
- `mount()` spins up a local dev server (rsbuild + React under the hood), bundles your view file, injects the data into `window.__UI_LEAF__.data`, and opens the user's default browser. Mutations from the view POST back to a localhost endpoint with a per-launch random token; the runtime dispatches them to the handlers you registered. Browser tab close → heartbeat stops → server shuts down → `view.closed` resolves and your CLI continues.
81
+ `mount()` compiles your view file once with `Bun.build`, injects data into `window.__UI_LEAF__`, starts a `Bun.serve` HTTP server, and opens the user's default browser. Mutations from the view POST back to a localhost endpoint with a per-launch random token; the runtime dispatches them to the handlers you registered.
82
82
 
83
- The transport is HTTP + JSON over loopback. The token is in `window.__UI_LEAF__.token`, and it's served inline in the HTML at `/index.html`so the token only protects against drive-by cross-origin requests in the user's browser, not against other processes on the same machine. Any local process that can reach `127.0.0.1:<port>` can fetch the page, grep the token out, and call `/mutate` with it; treat any local process you don't trust as having the same access as the view. View bundling resolves React from `ui-leaf`'s installed location, so your project doesn't need to install React.
83
+ **Heartbeat and lifecycle.** The browser tab sends periodic heartbeats while the page is open. When heartbeats stop (tab closed or navigated away), `mount()` emits a `disconnected` event but the server stays running — the port, token, and in-memory data are all preserved. When a tab reconnects (heartbeat resumes), a `reconnected` event fires. Any data updates or view swaps that arrive during the disconnected window take effect on the next load. The mount terminates only on an explicit caller close (`view.close()` or the inbound `{type:"close"}` message), a signal (SIGINT/SIGTERM), or an internal error — and `view.closed` resolves to the reason (`"caller"`, `"signal"`, or `"error"`).
84
84
 
85
- The same trust boundary applies to the `data` you pass to `mount()`. The payload is JSON-inlined into `window.__UI_LEAF__.data` in the same `/index.html`, and is also written to `<tmpdir()>/ui-leaf-XXXXXX/index.html` on disk for the mount lifetime — readable by the same set of same-UID local processes that can read the token. For PHI, PCI, financial records, or anything else where a same-UID local reader is in your threat model, don't pass the sensitive payload through `data`; keep it in your CLI's memory and inject it into the view via an authenticated `connect-src 'self'` fetch on boot. See "Data-at-rest in the temp directory" below for the disk-residency details.
85
+ Multi-tab note: `disconnected` fires only when **all** open tabs go silent the heartbeat is a single high-water mark across tabs, so closing one tab while another is open emits no event.
86
+
87
+ The transport is HTTP + JSON over loopback. The token is in `window.__UI_LEAF__.token`, served inline in the compiled HTML — so the token only protects against drive-by cross-origin requests in the user's browser, not against other processes on the same machine. Any local process that can reach `127.0.0.1:<port>` can fetch `GET /`, grep the token out, and call `/mutate` or `/api/data` with it; treat any local process you don't trust as having the same access as the view. View bundling resolves React from `ui-leaf`'s installed location, so your project doesn't need to install React.
88
+
89
+ The same trust boundary applies to the `data` you pass to `mount()`. The payload is JSON-inlined into `window.__UI_LEAF__.data` in the served HTML and held in memory for the mount lifetime — readable by the same set of same-UID local processes that can read the token. For PHI, PCI, financial records, or anything else where a same-UID local reader is in your threat model, use `dataLoader` instead — the loader's return value is served at a token-gated `/api/data` endpoint and never appears in the HTML. See "Data-at-rest" below for details.
86
90
 
87
91
  ## API surface
88
92
 
@@ -97,7 +101,7 @@ await mount({
97
101
  mutations, // Record<string, MutationHandler> (optional)
98
102
  viewsRoot, // optional, default: <cwd>/views
99
103
  title, // optional, default: "ui-leaf"
100
- port, // optional, default: 5810 (auto-bumps if busy)
104
+ port, // optional, default: 5810 (auto-bumps if busy; pass 0 for OS-assigned)
101
105
  openBrowser, // optional, default: true
102
106
  shell, // optional, "tab" | "app", default: "tab"
103
107
  csp, // optional, default: "off" (see Hardening)
@@ -130,7 +134,7 @@ mount({
130
134
 
131
135
  - **Locks `connect-src` to same-origin** — the architectural lock. Views cannot fetch external APIs; all data flows through `data` and `mutations`.
132
136
  - **Permits HTTPS images and fonts** so views can load CDN assets normally.
133
- - **Allows inline styles, eval, and inline scripts** for React + rsbuild's HMR.
137
+ - **Allows inline styles and inline scripts** for React.
134
138
 
135
139
  Because the policy is sent as an HTTP response header, views cannot relax it at runtime. The only way to weaken the policy is to change the `mount()` call (i.e. fork the consumer CLI, not the view).
136
140
 
@@ -156,22 +160,22 @@ mount({
156
160
 
157
161
  Be deliberate — every name you add becomes a viable rebinding target. Don't add public DNS names or LAN hostnames you don't fully control.
158
162
 
159
- ### Data-at-rest in the temp directory
163
+ ### Data-at-rest
160
164
 
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.
165
+ The `data` you pass to `mount()` is JSON-inlined into the compiled HTML that `GET /` serves, and held in memory for the mount lifetime. It is **not written to disk** the compiled HTML exists only in process memory. A same-UID local process that can reach `127.0.0.1:<port>` can still recover the data by fetching `GET /` (no token required), so `data` is appropriate for routine payloads but not for PHI, PCI, or financial records where in-HTML exposure is in your threat model.
162
166
 
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`:
167
+ For sensitive payloads use `dataLoader` instead of `data`:
164
168
 
165
169
  ```ts
166
170
  mount({
167
171
  view: "report",
168
172
  dataLoader: async () => {
169
- return await db.fetchSensitiveRecords(); // stays in memory; never touches disk
173
+ return await db.fetchSensitiveRecords(); // stays in memory; never appears in HTML
170
174
  },
171
175
  });
172
176
  ```
173
177
 
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.
178
+ `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. The payload never appears in the HTML it can only be read by a caller that already has the token. Note that a local process that can fetch `GET /` (no auth) can still read the token from `window.__UI_LEAF__.token` and then call `/api/data` so `dataLoader` hardens against HTML-level exposure, not against a determined same-UID reader.
175
179
 
176
180
  ## Sharing views across users
177
181
 
@@ -237,12 +241,22 @@ What the consumer CLI is responsible for (out of ui-leaf's scope):
237
241
  {"type":"result","id":1,"value":{"ok":true}}
238
242
  {"type":"error","id":2,"message":"…"}
239
243
  ```
244
+ - **Or live-update / control messages:**
245
+ ```json
246
+ {"version":"1","type":"update","data":{}}
247
+ {"version":"1","type":"view","source":"<tsx>"}
248
+ {"version":"1","type":"patch","data":{},"view":{"source":"<tsx>"}}
249
+ {"version":"1","type":"reopen"}
250
+ {"version":"1","type":"close"}
251
+ ```
240
252
  - **stdout (line-delimited JSON):**
241
253
  - `{"type":"ready","url":"http://127.0.0.1:54321","port":54321}` — emitted once when the dev server is up
242
254
  - `{"type":"mutate","id":1,"name":"refresh","args":{}}` — emitted when a view triggers a mutation; respond on stdin
243
- - `{"type":"closed"}` — emitted on natural close (browser tab closed, heartbeat timeout)
255
+ - `{"type":"disconnected"}` — browser tab stopped heartbeating; mount stays alive
256
+ - `{"type":"reconnected"}` — browser reconnected after a disconnect
257
+ - `{"type":"closed","reason":"caller"}` — mount terminated; reason is `caller` | `signal` | `error`
244
258
  - `{"type":"error","message":"…"}` — emitted on internal failure
245
- - **Lifecycle:** binary exits 0 on natural close, 1 on internal error; closing stdin from the parent triggers shutdown.
259
+ - **Lifecycle:** binary exits 0 on `closed`, 1 on internal error. The mount does **not** auto-terminate when the browser disconnects — only an explicit `{type:"close"}` message, stdin close, SIGINT/SIGTERM, or internal error terminates it. Closing stdin from the parent triggers a `caller` close.
246
260
 
247
261
  ### Minimal Bash example (read-only view, no mutations)
248
262
 
@@ -250,8 +264,10 @@ What the consumer CLI is responsible for (out of ui-leaf's scope):
250
264
  CONFIG='{"view":"spec","viewsRoot":"/abs/path/to/views","data":{"markdown":"# hi"},"port":0}'
251
265
  echo "$CONFIG" | ui-leaf mount
252
266
  # → {"type":"ready","url":"http://127.0.0.1:54321","port":54321}
253
- # (browser opens; user closes tab)
254
- # → {"type":"closed"}
267
+ # (browser opens; user closes tab → disconnected; mount stays running)
268
+ # → {"type":"disconnected"}
269
+ # (send {"type":"close"} on stdin to terminate)
270
+ # → {"type":"closed","reason":"caller"}
255
271
  ```
256
272
 
257
273
  ### Worked example with mutations
@@ -273,10 +289,18 @@ Child → parent stdout:
273
289
  Parent → child stdin (after handling the mutation):
274
290
  {"type":"result","id":1,"value":{"count":1}}
275
291
 
276
- (user closes tab)
292
+ (user closes tab → mount stays running)
293
+
294
+ Child → parent stdout:
295
+ {"type":"disconnected"}
296
+
297
+ (parent decides to shut down)
298
+
299
+ Parent → child stdin:
300
+ {"type":"close"}
277
301
 
278
302
  Child → parent stdout:
279
- {"type":"closed"}
303
+ {"type":"closed","reason":"caller"}
280
304
  ```
281
305
 
282
306
  Each pending mutation has a unique `id`. Multiple mutations can be in flight concurrently — match `result`/`error` responses by id.
@@ -285,13 +309,13 @@ Each pending mutation has a unique `id`. Multiple mutations can be in flight con
285
309
 
286
310
  - **Pass `viewsRoot` as an absolute path.** No `cwd/views` default games when invoked from another process.
287
311
  - **Pass `port: 0`.** ui-leaf asks the OS for a free port and reports it back in the `ready` event. Lets you run concurrent views without collision.
288
- - **Lower `heartbeatTimeoutMs`** (e.g. 5000) so orphaned ui-leaf children exit fast if your parent process dies. The default 75000 is tuned for human-direct use (survives one browser background-tab throttle) and is too long when a parent process is supervising.
289
- - **Kill the child on parent shutdown** rather than relying on heartbeat — `kill <pid>` from the parent. Closing stdin also triggers a clean shutdown.
312
+ - **Lower `heartbeatTimeoutMs`** (e.g. 5000) to get faster `disconnected` events. Note that heartbeat timeout no longer terminates the mount the mount only terminates on an explicit `{type:"close"}` message, stdin close, or signal. If you want fast shutdown on tab close, listen for `disconnected` and send `{type:"close"}` on stdin.
313
+ - **Kill the child on parent shutdown** rather than relying on heartbeat — `kill <pid>` from the parent, or close stdin (which triggers a `caller` close).
290
314
  - **Declare every mutation name** the view will call in the `mutations: []` array. The binary only routes mutations whose names appear in the list; calls to undeclared names get a 404 from `/mutate` with the standard "no mutation handler registered for X" error, and the view's `mutate()` promise rejects.
291
315
 
292
316
  ### Driving from Node via `mount()` directly
293
317
 
294
- If your consumer is itself Node (or you want a thin in-process integration), use the SDK directly. Pass `silent: true` to suppress rsbuild output so you can keep stdout clean for your own protocol (capture `process.stdout.write` *before* calling `mount()`, since the option redirects stdout to stderr for the lifetime of the dev server):
318
+ If your consumer is itself Node (or you want a thin in-process integration), use the SDK directly. Pass `silent: true` to redirect stdout to stderr for the lifetime of the server, keeping stdout clean for your own protocol (capture `process.stdout.write` *before* calling `mount()` if you need to write to real stdout from the same process):
295
319
 
296
320
  ```ts
297
321
  const realStdoutWrite = process.stdout.write.bind(process.stdout);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openthink/ui-leaf",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Customizable browser views, on demand, for any CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Matt Pardini",
@@ -25,41 +25,38 @@
25
25
  },
26
26
  "exports": {
27
27
  ".": {
28
- "types": "./dist/index.d.ts",
29
- "import": "./dist/index.js"
28
+ "types": "./packages/cli/dist/index.d.ts",
29
+ "import": "./packages/cli/dist/index.js"
30
30
  },
31
31
  "./view": {
32
- "types": "./dist/view.d.ts",
33
- "import": "./dist/view.js"
32
+ "types": "./packages/cli/dist/view.d.ts",
33
+ "import": "./packages/cli/dist/view.js"
34
34
  }
35
35
  },
36
- "main": "./dist/index.js",
37
- "types": "./dist/index.d.ts",
36
+ "main": "./packages/cli/dist/index.js",
37
+ "types": "./packages/cli/dist/index.d.ts",
38
38
  "bin": {
39
- "ui-leaf": "./dist/cli.js"
39
+ "ui-leaf": "./packages/cli/dist/cli.js"
40
40
  },
41
41
  "files": [
42
- "dist",
42
+ "packages/cli/dist",
43
+ "packages/cli/package.json",
43
44
  "README.md",
44
45
  "LICENSE"
45
46
  ],
47
+ "workspaces": ["packages/*"],
46
48
  "scripts": {
47
- "build": "tsup",
48
- "dev": "tsup --watch",
49
- "typecheck": "tsc --noEmit",
49
+ "build": "bun run --filter '@openthink/ui-leaf-cli' build",
50
+ "dev": "bun run --filter '@openthink/ui-leaf-cli' dev",
51
+ "typecheck": "tsc --noEmit -p packages/cli/tsconfig.src.json",
50
52
  "test": "bun test",
51
53
  "prepublishOnly": "bun run build"
52
54
  },
53
55
  "devDependencies": {
54
56
  "@types/node": "^25.6.0",
55
- "@types/react": "^19.2.14",
56
- "@types/react-dom": "^19.2.3",
57
- "tsup": "^8.4.0",
58
57
  "typescript": "^6.0.0"
59
58
  },
60
59
  "dependencies": {
61
- "@rsbuild/core": "^2.0.1",
62
- "@rsbuild/plugin-react": "^2.0.0",
63
60
  "open": "^11.0.0",
64
61
  "react": "^19.2.5",
65
62
  "react-dom": "^19.2.5"