@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 +43 -19
- package/package.json +14 -17
- package/packages/cli/dist/cli.js +986 -0
- package/packages/cli/dist/cli.js.map +1 -0
- package/{dist → packages/cli/dist}/index.d.ts +88 -57
- package/packages/cli/dist/index.js +721 -0
- package/packages/cli/dist/index.js.map +1 -0
- package/packages/cli/dist/server-3vbR-tuu.d.ts +7 -0
- package/{dist → packages/cli/dist}/view.d.ts +1 -1
- package/packages/cli/package.json +22 -0
- package/dist/cli.js +0 -725
- package/dist/cli.js.map +0 -1
- package/dist/dev-server-DapOoULX.d.ts +0 -5
- package/dist/index.js +0 -556
- package/dist/index.js.map +0 -1
- /package/{dist → packages/cli/dist}/cli.d.ts +0 -0
- /package/{dist → packages/cli/dist}/view.js +0 -0
- /package/{dist → packages/cli/dist}/view.js.map +0 -0
package/README.md
CHANGED
|
@@ -78,11 +78,15 @@ Three reasons:
|
|
|
78
78
|
|
|
79
79
|
## How it works
|
|
80
80
|
|
|
81
|
-
`mount()`
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
163
|
+
### Data-at-rest
|
|
160
164
|
|
|
161
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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":"
|
|
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
|
|
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":"
|
|
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)
|
|
289
|
-
- **Kill the child on parent shutdown** rather than relying on heartbeat — `kill <pid>` from the parent
|
|
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
|
|
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.
|
|
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": "
|
|
48
|
-
"dev": "
|
|
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"
|