@openthink/ui-leaf 0.6.0 → 0.7.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.
@@ -33,13 +33,15 @@ var reactAliasPlugin = {
33
33
  });
34
34
  }
35
35
  };
36
+ var SESSION_ENDED_HTML = '<div style="font-family:sans-serif;padding:2em;color:#555"><p>Session ended \u2014 re-launch the CLI to continue.</p></div>';
37
+ var CLOSED_OVERLAY_HTML = '<div style="font-family:sans-serif;padding:2em;color:#555"><p>This view has closed.</p></div>';
36
38
  var SHARED_BRIDGE = `
37
39
  async function mutate(name: string, args?: unknown): Promise<unknown> {
38
40
  const res = await fetch("/mutate", {
39
41
  method: "POST",
40
42
  headers: {
41
43
  "Content-Type": "application/json",
42
- ...(token ? { Authorization: "Bearer " + token } : {}),
44
+ ...(token ? { "X-UI-Leaf-Token": token } : {}),
43
45
  },
44
46
  body: JSON.stringify({ name, args }),
45
47
  });
@@ -61,12 +63,64 @@ async function heartbeat(): Promise<void> {
61
63
  try {
62
64
  await fetch("/heartbeat", {
63
65
  method: "POST",
64
- headers: token ? { Authorization: "Bearer " + token } : {},
66
+ headers: token ? { "X-UI-Leaf-Token": token } : {},
65
67
  });
66
68
  } catch { /* server may have shut down; ignore */ }
67
69
  }
68
70
  setInterval(heartbeat, 5000);
69
- heartbeat();`;
71
+ heartbeat();
72
+
73
+ function subscribeEvents(onEvent: (ev: { type: string; [k: string]: unknown }) => void): void {
74
+ let delay = 250;
75
+ const budget = 30_000;
76
+ const started = Date.now();
77
+ let done = false;
78
+
79
+ async function connect(): Promise<void> {
80
+ try {
81
+ const res = await fetch("/events", {
82
+ headers: token ? { "X-UI-Leaf-Token": token } : {},
83
+ });
84
+ if (!res.ok || !res.body) throw new Error("bad status " + res.status);
85
+ delay = 250;
86
+ const reader = res.body.getReader();
87
+ const dec = new TextDecoder("utf-8");
88
+ let buf = "";
89
+ while (true) {
90
+ const { done: streamDone, value } = await reader.read();
91
+ if (streamDone) break;
92
+ buf += dec.decode(value, { stream: true });
93
+ let idx: number;
94
+ while ((idx = buf.indexOf("\\n\\n")) !== -1) {
95
+ const chunk = buf.slice(0, idx);
96
+ buf = buf.slice(idx + 2);
97
+ for (const line of chunk.split("\\n")) {
98
+ if (line.startsWith("data:")) {
99
+ try {
100
+ const ev = JSON.parse(line.slice(5).trimStart()) as { type: string; [k: string]: unknown };
101
+ if (ev.type === "closing") done = true;
102
+ onEvent(ev);
103
+ } catch { /* skip malformed event */ }
104
+ }
105
+ }
106
+ }
107
+ if (done) return;
108
+ }
109
+ } catch {
110
+ if (done) return;
111
+ }
112
+ if (done) return;
113
+ if (Date.now() - started > budget) {
114
+ onEvent({ type: "closing", reason: "error" });
115
+ return;
116
+ }
117
+ await new Promise<void>((r) => setTimeout(r, delay));
118
+ delay = Math.min(delay * 2, 5_000);
119
+ void connect();
120
+ }
121
+
122
+ void connect();
123
+ }`;
70
124
  async function runBunBuild(entryPath) {
71
125
  let buildOutput;
72
126
  try {
@@ -99,21 +153,21 @@ async function runBunBuild(entryPath) {
99
153
  return { js: await output.text() };
100
154
  }
101
155
  function assembleHtml(opts) {
102
- const { js, title, csp, data, token, dataLoader } = opts;
156
+ const { js, title, csp, data, dataLoader } = opts;
103
157
  const safeJs = js.replace(/<\/script>/gi, "<\\/script>");
104
158
  const titleEscaped = title.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
105
159
  const cspMeta = csp ? ` <meta http-equiv="Content-Security-Policy" content="${csp.replace(/&/g, "&amp;").replace(/"/g, "&quot;")}" />
106
160
  ` : "";
107
- const safeToken = token ? escapeForScriptTag(JSON.stringify(token)) : null;
108
- const tokenField = safeToken ? `, token: ${safeToken}` : "";
109
- const bootstrapValue = dataLoader ? `{ token: ${safeToken ?? '""'} }` : `{ data: JSON.parse(${escapeForScriptTag(JSON.stringify(JSON.stringify(data ?? null)))})${tokenField} }`;
161
+ const dataInit = dataLoader ? "window.__UI_LEAF__ = {};" : `window.__UI_LEAF__ = { data: JSON.parse(${escapeForScriptTag(JSON.stringify(JSON.stringify(data ?? null)))}) };`;
162
+ const bootstrapScript = `${dataInit}
163
+ (function(){var m=/[#&]token=([^&#]*)/.exec(window.location.hash);if(m){try{window.__UI_LEAF__.token=decodeURIComponent(m[1]);history.replaceState(null,"",window.location.pathname+window.location.search);}catch(e){window.__UI_LEAF__.sessionEnded=true;}}else{window.__UI_LEAF__.sessionEnded=true;}})();`;
110
164
  return `<!doctype html>
111
165
  <html lang="en">
112
166
  <head>
113
167
  <meta charset="utf-8" />
114
168
  <title>${titleEscaped}</title>
115
169
  ${cspMeta} <!-- ui-leaf bootstrap -->
116
- <script>window.__UI_LEAF__ = ${bootstrapValue};</script>
170
+ <script>${bootstrapScript}</script>
117
171
  </head>
118
172
  <body>
119
173
  <div id="root"></div>
@@ -128,9 +182,9 @@ async function compileView(opts) {
128
182
  data,
129
183
  title = "ui-leaf",
130
184
  csp,
131
- // allowedHosts has no compile-time effect; accepted for API symmetry.
185
+ // allowedHosts and token have no compile-time effect; accepted for API symmetry.
132
186
  allowedHosts: _allowedHosts,
133
- token,
187
+ token: _token,
134
188
  dataLoader = false
135
189
  } = opts;
136
190
  const viewsRootAbs = resolve(viewsRoot);
@@ -170,41 +224,75 @@ async function compileView(opts) {
170
224
  const entryContent = dataLoader ? `import { createRoot } from "react-dom/client";
171
225
  import View from ${JSON.stringify(viewAbs)};
172
226
 
173
- const ctx = (globalThis as { __UI_LEAF__?: { token?: string } }).__UI_LEAF__ ?? {};
227
+ const ctx = (globalThis as { __UI_LEAF__?: { token?: string; sessionEnded?: boolean } }).__UI_LEAF__ ?? {};
174
228
  const token = ctx.token;
229
+
230
+ if (ctx.sessionEnded) {
231
+ const root = document.getElementById("root");
232
+ if (root) root.innerHTML = ${JSON.stringify(SESSION_ENDED_HTML)};
233
+ } else {
175
234
  ${SHARED_BRIDGE}
176
235
 
177
- async function bootstrap(): Promise<void> {
178
- const res = await fetch("/api/data", {
179
- headers: token ? { Authorization: "Bearer " + token } : {},
180
- });
181
- if (!res.ok) {
182
- const text = await res.text().catch(() => "");
183
- throw new Error("ui-leaf: /api/data fetch failed (" + res.status + "): " + text);
236
+ async function bootstrap(): Promise<void> {
237
+ const res = await fetch("/api/data", {
238
+ headers: token ? { "X-UI-Leaf-Token": token } : {},
239
+ });
240
+ if (!res.ok) {
241
+ const text = await res.text().catch(() => "");
242
+ throw new Error("ui-leaf: /api/data fetch failed (" + res.status + "): " + text);
243
+ }
244
+ let currentData: unknown = await res.json();
245
+ const el = document.getElementById("root");
246
+ if (!el) throw new Error("ui-leaf: #root element missing");
247
+ const root = createRoot(el);
248
+ root.render(<View data={currentData} mutate={mutate} />);
249
+ subscribeEvents((ev) => {
250
+ if (ev.type === "data-updated") {
251
+ currentData = ev.data;
252
+ root.render(<View data={currentData} mutate={mutate} />);
253
+ } else if (ev.type === "view-swapped") {
254
+ window.location.reload();
255
+ } else if (ev.type === "closing") {
256
+ el.innerHTML = ${JSON.stringify(CLOSED_OVERLAY_HTML)};
257
+ }
258
+ });
184
259
  }
185
- const data = await res.json();
186
- const el = document.getElementById("root");
187
- if (!el) throw new Error("ui-leaf: #root element missing");
188
- createRoot(el).render(<View data={data} mutate={mutate} />);
260
+ bootstrap();
189
261
  }
190
- bootstrap();
191
262
  ` : `import { createRoot } from "react-dom/client";
192
263
  import View from ${JSON.stringify(viewAbs)};
193
264
 
194
- const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string } }).__UI_LEAF__ ?? {};
195
- const data = ctx.data;
265
+ const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string; sessionEnded?: boolean } }).__UI_LEAF__ ?? {};
196
266
  const token = ctx.token;
267
+
268
+ if (ctx.sessionEnded) {
269
+ const root = document.getElementById("root");
270
+ if (root) root.innerHTML = ${JSON.stringify(SESSION_ENDED_HTML)};
271
+ } else {
272
+ let currentData: unknown = ctx.data;
197
273
  ${SHARED_BRIDGE}
198
274
 
199
- const el = document.getElementById("root");
200
- if (!el) throw new Error("ui-leaf: #root element missing");
201
- createRoot(el).render(<View data={data} mutate={mutate} />);
275
+ const el = document.getElementById("root");
276
+ if (!el) throw new Error("ui-leaf: #root element missing");
277
+ const root = createRoot(el);
278
+ root.render(<View data={currentData} mutate={mutate} />);
279
+ subscribeEvents((ev) => {
280
+ if (ev.type === "data-updated") {
281
+ currentData = ev.data;
282
+ root.render(<View data={currentData} mutate={mutate} />);
283
+ } else if (ev.type === "view-swapped") {
284
+ window.location.reload();
285
+ } else if (ev.type === "closing") {
286
+ el.innerHTML = ${JSON.stringify(CLOSED_OVERLAY_HTML)};
287
+ }
288
+ });
289
+ }
202
290
  `;
203
291
  await writeFile(entryPath, entryContent);
204
292
  const buildResult = await runBunBuild(entryPath);
205
293
  if ("errors" in buildResult) return { html: "", errors: buildResult.errors };
206
294
  return {
207
- html: assembleHtml({ js: buildResult.js, title, csp, data, token, dataLoader }),
295
+ html: assembleHtml({ js: buildResult.js, title, csp, data, dataLoader }),
208
296
  errors: []
209
297
  };
210
298
  } finally {
@@ -212,7 +300,7 @@ createRoot(el).render(<View data={data} mutate={mutate} />);
212
300
  }
213
301
  }
214
302
  async function compileSource(opts) {
215
- const { source, data, title = "ui-leaf", csp, token } = opts;
303
+ const { source, data, title = "ui-leaf", csp, token: _token } = opts;
216
304
  const tempDir = await mkdtemp(join(tmpdir(), "ui-leaf-src-"));
217
305
  try {
218
306
  const viewPath = join(tempDir, "view.tsx");
@@ -221,20 +309,37 @@ async function compileSource(opts) {
221
309
  const entryContent = `import { createRoot } from "react-dom/client";
222
310
  import View from ${JSON.stringify(viewPath)};
223
311
 
224
- const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string } }).__UI_LEAF__ ?? {};
225
- const data = ctx.data;
312
+ const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string; sessionEnded?: boolean } }).__UI_LEAF__ ?? {};
226
313
  const token = ctx.token;
314
+
315
+ if (ctx.sessionEnded) {
316
+ const root = document.getElementById("root");
317
+ if (root) root.innerHTML = ${JSON.stringify(SESSION_ENDED_HTML)};
318
+ } else {
319
+ let currentData: unknown = ctx.data;
227
320
  ${SHARED_BRIDGE}
228
321
 
229
- const el = document.getElementById("root");
230
- if (!el) throw new Error("ui-leaf: #root element missing");
231
- createRoot(el).render(<View data={data} mutate={mutate} />);
322
+ const el = document.getElementById("root");
323
+ if (!el) throw new Error("ui-leaf: #root element missing");
324
+ const root = createRoot(el);
325
+ root.render(<View data={currentData} mutate={mutate} />);
326
+ subscribeEvents((ev) => {
327
+ if (ev.type === "data-updated") {
328
+ currentData = ev.data;
329
+ root.render(<View data={currentData} mutate={mutate} />);
330
+ } else if (ev.type === "view-swapped") {
331
+ window.location.reload();
332
+ } else if (ev.type === "closing") {
333
+ el.innerHTML = ${JSON.stringify(CLOSED_OVERLAY_HTML)};
334
+ }
335
+ });
336
+ }
232
337
  `;
233
338
  await writeFile(entryPath, entryContent);
234
339
  const buildResult = await runBunBuild(entryPath);
235
340
  if ("errors" in buildResult) return { html: "", errors: buildResult.errors };
236
341
  return {
237
- html: assembleHtml({ js: buildResult.js, title, csp, data, token, dataLoader: false }),
342
+ html: assembleHtml({ js: buildResult.js, title, csp, data, dataLoader: false }),
238
343
  errors: []
239
344
  };
240
345
  } finally {
@@ -343,8 +448,19 @@ async function startDevServer(opts) {
343
448
  try {
344
449
  let fireEvent2 = function(event) {
345
450
  for (const fn of listeners.get(event)) fn();
451
+ }, broadcast2 = function(event) {
452
+ const frame = sseEncoder.encode(`data: ${JSON.stringify(event)}
453
+
454
+ `);
455
+ for (const controller of sseClients) {
456
+ try {
457
+ controller.enqueue(frame);
458
+ } catch {
459
+ sseClients.delete(controller);
460
+ }
461
+ }
346
462
  };
347
- var fireEvent = fireEvent2;
463
+ var fireEvent = fireEvent2, broadcast = broadcast2;
348
464
  if (view.includes("/") || view.includes("\\")) {
349
465
  throw new Error(
350
466
  `ui-leaf: view '${view}' must be a bare identifier with no path separators`
@@ -378,6 +494,8 @@ async function startDevServer(opts) {
378
494
  ["disconnected", /* @__PURE__ */ new Set()],
379
495
  ["reconnected", /* @__PURE__ */ new Set()]
380
496
  ]);
497
+ const sseClients = /* @__PURE__ */ new Set();
498
+ const sseEncoder = new TextEncoder();
381
499
  let lastHeartbeatAt = Date.now();
382
500
  let closeRequested = false;
383
501
  let connectionState = "connecting";
@@ -416,10 +534,7 @@ async function startDevServer(opts) {
416
534
  }
417
535
  if (method === "POST" && path === "/heartbeat") {
418
536
  if (!checkAuth(req, token)) {
419
- return new Response(JSON.stringify({ error: "unauthorized" }), {
420
- status: 401,
421
- headers: { ...headers, "Content-Type": "application/json" }
422
- });
537
+ return new Response("", { status: 401, headers });
423
538
  }
424
539
  lastHeartbeatAt = Date.now();
425
540
  if (connectionState === "disconnected") {
@@ -432,10 +547,7 @@ async function startDevServer(opts) {
432
547
  }
433
548
  if (method === "POST" && path === "/mutate") {
434
549
  if (!checkAuth(req, token)) {
435
- return new Response(JSON.stringify({ error: "unauthorized" }), {
436
- status: 401,
437
- headers: { ...headers, "Content-Type": "application/json" }
438
- });
550
+ return new Response("", { status: 401, headers });
439
551
  }
440
552
  return handleMutate(req, mutations, headers);
441
553
  }
@@ -447,16 +559,44 @@ async function startDevServer(opts) {
447
559
  });
448
560
  }
449
561
  if (!checkAuth(req, token)) {
450
- return new Response(JSON.stringify({ error: "unauthorized" }), {
451
- status: 401,
452
- headers: { ...headers, "Content-Type": "application/json" }
453
- });
562
+ return new Response("", { status: 401, headers });
454
563
  }
455
564
  return new Response(JSON.stringify(viewState.data !== void 0 ? viewState.data : null), {
456
565
  status: 200,
457
566
  headers: { ...headers, "Content-Type": "application/json" }
458
567
  });
459
568
  }
569
+ if (method === "GET" && path === "/events") {
570
+ if (!checkAuth(req, token)) {
571
+ return new Response("", { status: 401, headers });
572
+ }
573
+ let sseController;
574
+ const stream = new ReadableStream({
575
+ start(controller) {
576
+ sseController = controller;
577
+ sseClients.add(controller);
578
+ controller.enqueue(sseEncoder.encode(": connected\n\n"));
579
+ req.signal?.addEventListener("abort", () => {
580
+ sseClients.delete(sseController);
581
+ try {
582
+ sseController.close();
583
+ } catch {
584
+ }
585
+ });
586
+ },
587
+ cancel() {
588
+ sseClients.delete(sseController);
589
+ }
590
+ });
591
+ return new Response(stream, {
592
+ status: 200,
593
+ headers: {
594
+ ...headers,
595
+ "Content-Type": "text/event-stream",
596
+ "Cache-Control": "no-cache"
597
+ }
598
+ });
599
+ }
460
600
  return new Response(JSON.stringify({ error: "not found" }), {
461
601
  status: 404,
462
602
  headers: { ...headers, "Content-Type": "application/json" }
@@ -468,7 +608,15 @@ async function startDevServer(opts) {
468
608
  if (closeRequested) return;
469
609
  closeRequested = true;
470
610
  if (heartbeatWatcher) clearInterval(heartbeatWatcher);
471
- await bunServer.stop(true);
611
+ broadcast2({ type: "closing", reason });
612
+ for (const controller of sseClients) {
613
+ try {
614
+ controller.close();
615
+ } catch {
616
+ }
617
+ }
618
+ sseClients.clear();
619
+ await bunServer.stop();
472
620
  if (restoreStdout) restoreStdout();
473
621
  resolveClosed(reason);
474
622
  };
@@ -481,12 +629,12 @@ async function startDevServer(opts) {
481
629
  };
482
630
  bunServer = (() => {
483
631
  if (bunPort === 0) {
484
- return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler, error: serverErrorHandler });
632
+ return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler, error: serverErrorHandler, idleTimeout: 0 });
485
633
  }
486
634
  const MAX_PORT_ATTEMPTS = 10;
487
635
  for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) {
488
636
  try {
489
- return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler, error: serverErrorHandler });
637
+ return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler, error: serverErrorHandler, idleTimeout: 0 });
490
638
  } catch (err) {
491
639
  const isAddrinuse = err instanceof Error && err.message.includes("EADDRINUSE");
492
640
  if (!isAddrinuse || i === MAX_PORT_ATTEMPTS - 1) {
@@ -515,18 +663,19 @@ async function startDevServer(opts) {
515
663
  }
516
664
  }
517
665
  }, _heartbeatCheckIntervalMs);
518
- const doOpen = _opener ? () => _opener(url) : async () => {
666
+ const openUrl = `${url}/#token=${token}`;
667
+ const doOpen = _opener ? () => _opener(openUrl) : async () => {
519
668
  if (shell === "app") {
520
- const launched = await openInAppMode(url);
669
+ const launched = await openInAppMode(openUrl);
521
670
  if (!launched) {
522
671
  process.stderr.write(
523
672
  `ui-leaf: shell:"app" requested but no Chromium browser found; falling back to default browser tab.
524
673
  `
525
674
  );
526
- await open(url);
675
+ await open(openUrl);
527
676
  }
528
677
  } else {
529
- await open(url);
678
+ await open(openUrl);
530
679
  }
531
680
  };
532
681
  if (openBrowser) {
@@ -545,6 +694,7 @@ async function startDevServer(opts) {
545
694
  },
546
695
  update(newData) {
547
696
  viewState.data = newData;
697
+ broadcast2({ type: "data-updated", data: newData });
548
698
  fireEvent2("data-updated");
549
699
  },
550
700
  async swapView(source) {
@@ -557,6 +707,7 @@ async function startDevServer(opts) {
557
707
  });
558
708
  if (r.errors.length > 0) return r.errors;
559
709
  viewState.html = r.html;
710
+ broadcast2({ type: "view-swapped" });
560
711
  fireEvent2("view-swapped");
561
712
  return [];
562
713
  },
@@ -571,6 +722,8 @@ async function startDevServer(opts) {
571
722
  if (r.errors.length > 0) return r.errors;
572
723
  viewState.data = newData;
573
724
  viewState.html = r.html;
725
+ broadcast2({ type: "data-updated", data: newData });
726
+ broadcast2({ type: "view-swapped" });
574
727
  fireEvent2("data-updated");
575
728
  fireEvent2("view-swapped");
576
729
  return [];
@@ -585,10 +738,9 @@ async function startDevServer(opts) {
585
738
  }
586
739
  }
587
740
  function checkAuth(req, token) {
588
- const header = req.headers.get("authorization") ?? "";
589
- const match = /^Bearer (.+)$/.exec(header);
590
- if (!match) return false;
591
- return timingSafeEqual(match[1], token);
741
+ const value = req.headers.get("x-ui-leaf-token") ?? "";
742
+ if (!value) return false;
743
+ return timingSafeEqual(value, token);
592
744
  }
593
745
  async function handleMutate(req, mutations, headers) {
594
746
  const contentLength = req.headers.get("content-length");