@openthink/ui-leaf 0.5.0 → 0.6.1

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.
@@ -38,13 +38,15 @@ var reactAliasPlugin = {
38
38
  });
39
39
  }
40
40
  };
41
+ 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>';
42
+ var CLOSED_OVERLAY_HTML = '<div style="font-family:sans-serif;padding:2em;color:#555"><p>This view has closed.</p></div>';
41
43
  var SHARED_BRIDGE = `
42
44
  async function mutate(name: string, args?: unknown): Promise<unknown> {
43
45
  const res = await fetch("/mutate", {
44
46
  method: "POST",
45
47
  headers: {
46
48
  "Content-Type": "application/json",
47
- ...(token ? { Authorization: "Bearer " + token } : {}),
49
+ ...(token ? { "X-UI-Leaf-Token": token } : {}),
48
50
  },
49
51
  body: JSON.stringify({ name, args }),
50
52
  });
@@ -66,12 +68,64 @@ async function heartbeat(): Promise<void> {
66
68
  try {
67
69
  await fetch("/heartbeat", {
68
70
  method: "POST",
69
- headers: token ? { Authorization: "Bearer " + token } : {},
71
+ headers: token ? { "X-UI-Leaf-Token": token } : {},
70
72
  });
71
73
  } catch { /* server may have shut down; ignore */ }
72
74
  }
73
75
  setInterval(heartbeat, 5000);
74
- heartbeat();`;
76
+ heartbeat();
77
+
78
+ function subscribeEvents(onEvent: (ev: { type: string; [k: string]: unknown }) => void): void {
79
+ let delay = 250;
80
+ const budget = 30_000;
81
+ const started = Date.now();
82
+ let done = false;
83
+
84
+ async function connect(): Promise<void> {
85
+ try {
86
+ const res = await fetch("/events", {
87
+ headers: token ? { "X-UI-Leaf-Token": token } : {},
88
+ });
89
+ if (!res.ok || !res.body) throw new Error("bad status " + res.status);
90
+ delay = 250;
91
+ const reader = res.body.getReader();
92
+ const dec = new TextDecoder("utf-8");
93
+ let buf = "";
94
+ while (true) {
95
+ const { done: streamDone, value } = await reader.read();
96
+ if (streamDone) break;
97
+ buf += dec.decode(value, { stream: true });
98
+ let idx: number;
99
+ while ((idx = buf.indexOf("\\n\\n")) !== -1) {
100
+ const chunk = buf.slice(0, idx);
101
+ buf = buf.slice(idx + 2);
102
+ for (const line of chunk.split("\\n")) {
103
+ if (line.startsWith("data:")) {
104
+ try {
105
+ const ev = JSON.parse(line.slice(5).trimStart()) as { type: string; [k: string]: unknown };
106
+ if (ev.type === "closing") done = true;
107
+ onEvent(ev);
108
+ } catch { /* skip malformed event */ }
109
+ }
110
+ }
111
+ }
112
+ if (done) return;
113
+ }
114
+ } catch {
115
+ if (done) return;
116
+ }
117
+ if (done) return;
118
+ if (Date.now() - started > budget) {
119
+ onEvent({ type: "closing", reason: "error" });
120
+ return;
121
+ }
122
+ await new Promise<void>((r) => setTimeout(r, delay));
123
+ delay = Math.min(delay * 2, 5_000);
124
+ void connect();
125
+ }
126
+
127
+ void connect();
128
+ }`;
75
129
  async function runBunBuild(entryPath) {
76
130
  let buildOutput;
77
131
  try {
@@ -104,21 +158,21 @@ async function runBunBuild(entryPath) {
104
158
  return { js: await output.text() };
105
159
  }
106
160
  function assembleHtml(opts) {
107
- const { js, title, csp, data, token, dataLoader } = opts;
161
+ const { js, title, csp, data, dataLoader } = opts;
108
162
  const safeJs = js.replace(/<\/script>/gi, "<\\/script>");
109
163
  const titleEscaped = title.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
110
164
  const cspMeta = csp ? ` <meta http-equiv="Content-Security-Policy" content="${csp.replace(/&/g, "&amp;").replace(/"/g, "&quot;")}" />
111
165
  ` : "";
112
- const safeToken = token ? escapeForScriptTag(JSON.stringify(token)) : null;
113
- const tokenField = safeToken ? `, token: ${safeToken}` : "";
114
- const bootstrapValue = dataLoader ? `{ token: ${safeToken ?? '""'} }` : `{ data: JSON.parse(${escapeForScriptTag(JSON.stringify(JSON.stringify(data ?? null)))})${tokenField} }`;
166
+ const dataInit = dataLoader ? "window.__UI_LEAF__ = {};" : `window.__UI_LEAF__ = { data: JSON.parse(${escapeForScriptTag(JSON.stringify(JSON.stringify(data ?? null)))}) };`;
167
+ const bootstrapScript = `${dataInit}
168
+ (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;}})();`;
115
169
  return `<!doctype html>
116
170
  <html lang="en">
117
171
  <head>
118
172
  <meta charset="utf-8" />
119
173
  <title>${titleEscaped}</title>
120
174
  ${cspMeta} <!-- ui-leaf bootstrap -->
121
- <script>window.__UI_LEAF__ = ${bootstrapValue};</script>
175
+ <script>${bootstrapScript}</script>
122
176
  </head>
123
177
  <body>
124
178
  <div id="root"></div>
@@ -133,9 +187,9 @@ async function compileView(opts) {
133
187
  data,
134
188
  title = "ui-leaf",
135
189
  csp,
136
- // allowedHosts has no compile-time effect; accepted for API symmetry.
190
+ // allowedHosts and token have no compile-time effect; accepted for API symmetry.
137
191
  allowedHosts: _allowedHosts,
138
- token,
192
+ token: _token,
139
193
  dataLoader = false
140
194
  } = opts;
141
195
  const viewsRootAbs = resolve(viewsRoot);
@@ -175,41 +229,75 @@ async function compileView(opts) {
175
229
  const entryContent = dataLoader ? `import { createRoot } from "react-dom/client";
176
230
  import View from ${JSON.stringify(viewAbs)};
177
231
 
178
- const ctx = (globalThis as { __UI_LEAF__?: { token?: string } }).__UI_LEAF__ ?? {};
232
+ const ctx = (globalThis as { __UI_LEAF__?: { token?: string; sessionEnded?: boolean } }).__UI_LEAF__ ?? {};
179
233
  const token = ctx.token;
234
+
235
+ if (ctx.sessionEnded) {
236
+ const root = document.getElementById("root");
237
+ if (root) root.innerHTML = ${JSON.stringify(SESSION_ENDED_HTML)};
238
+ } else {
180
239
  ${SHARED_BRIDGE}
181
240
 
182
- async function bootstrap(): Promise<void> {
183
- const res = await fetch("/api/data", {
184
- headers: token ? { Authorization: "Bearer " + token } : {},
185
- });
186
- if (!res.ok) {
187
- const text = await res.text().catch(() => "");
188
- throw new Error("ui-leaf: /api/data fetch failed (" + res.status + "): " + text);
241
+ async function bootstrap(): Promise<void> {
242
+ const res = await fetch("/api/data", {
243
+ headers: token ? { "X-UI-Leaf-Token": token } : {},
244
+ });
245
+ if (!res.ok) {
246
+ const text = await res.text().catch(() => "");
247
+ throw new Error("ui-leaf: /api/data fetch failed (" + res.status + "): " + text);
248
+ }
249
+ let currentData: unknown = await res.json();
250
+ const el = document.getElementById("root");
251
+ if (!el) throw new Error("ui-leaf: #root element missing");
252
+ const root = createRoot(el);
253
+ root.render(<View data={currentData} mutate={mutate} />);
254
+ subscribeEvents((ev) => {
255
+ if (ev.type === "data-updated") {
256
+ currentData = ev.data;
257
+ root.render(<View data={currentData} mutate={mutate} />);
258
+ } else if (ev.type === "view-swapped") {
259
+ window.location.reload();
260
+ } else if (ev.type === "closing") {
261
+ el.innerHTML = ${JSON.stringify(CLOSED_OVERLAY_HTML)};
262
+ }
263
+ });
189
264
  }
190
- const data = await res.json();
191
- const el = document.getElementById("root");
192
- if (!el) throw new Error("ui-leaf: #root element missing");
193
- createRoot(el).render(<View data={data} mutate={mutate} />);
265
+ bootstrap();
194
266
  }
195
- bootstrap();
196
267
  ` : `import { createRoot } from "react-dom/client";
197
268
  import View from ${JSON.stringify(viewAbs)};
198
269
 
199
- const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string } }).__UI_LEAF__ ?? {};
200
- const data = ctx.data;
270
+ const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string; sessionEnded?: boolean } }).__UI_LEAF__ ?? {};
201
271
  const token = ctx.token;
272
+
273
+ if (ctx.sessionEnded) {
274
+ const root = document.getElementById("root");
275
+ if (root) root.innerHTML = ${JSON.stringify(SESSION_ENDED_HTML)};
276
+ } else {
277
+ let currentData: unknown = ctx.data;
202
278
  ${SHARED_BRIDGE}
203
279
 
204
- const el = document.getElementById("root");
205
- if (!el) throw new Error("ui-leaf: #root element missing");
206
- createRoot(el).render(<View data={data} mutate={mutate} />);
280
+ const el = document.getElementById("root");
281
+ if (!el) throw new Error("ui-leaf: #root element missing");
282
+ const root = createRoot(el);
283
+ root.render(<View data={currentData} mutate={mutate} />);
284
+ subscribeEvents((ev) => {
285
+ if (ev.type === "data-updated") {
286
+ currentData = ev.data;
287
+ root.render(<View data={currentData} mutate={mutate} />);
288
+ } else if (ev.type === "view-swapped") {
289
+ window.location.reload();
290
+ } else if (ev.type === "closing") {
291
+ el.innerHTML = ${JSON.stringify(CLOSED_OVERLAY_HTML)};
292
+ }
293
+ });
294
+ }
207
295
  `;
208
296
  await writeFile(entryPath, entryContent);
209
297
  const buildResult = await runBunBuild(entryPath);
210
298
  if ("errors" in buildResult) return { html: "", errors: buildResult.errors };
211
299
  return {
212
- html: assembleHtml({ js: buildResult.js, title, csp, data, token, dataLoader }),
300
+ html: assembleHtml({ js: buildResult.js, title, csp, data, dataLoader }),
213
301
  errors: []
214
302
  };
215
303
  } finally {
@@ -217,7 +305,7 @@ createRoot(el).render(<View data={data} mutate={mutate} />);
217
305
  }
218
306
  }
219
307
  async function compileSource(opts) {
220
- const { source, data, title = "ui-leaf", csp, token } = opts;
308
+ const { source, data, title = "ui-leaf", csp, token: _token } = opts;
221
309
  const tempDir = await mkdtemp(join(tmpdir(), "ui-leaf-src-"));
222
310
  try {
223
311
  const viewPath = join(tempDir, "view.tsx");
@@ -226,20 +314,37 @@ async function compileSource(opts) {
226
314
  const entryContent = `import { createRoot } from "react-dom/client";
227
315
  import View from ${JSON.stringify(viewPath)};
228
316
 
229
- const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string } }).__UI_LEAF__ ?? {};
230
- const data = ctx.data;
317
+ const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string; sessionEnded?: boolean } }).__UI_LEAF__ ?? {};
231
318
  const token = ctx.token;
319
+
320
+ if (ctx.sessionEnded) {
321
+ const root = document.getElementById("root");
322
+ if (root) root.innerHTML = ${JSON.stringify(SESSION_ENDED_HTML)};
323
+ } else {
324
+ let currentData: unknown = ctx.data;
232
325
  ${SHARED_BRIDGE}
233
326
 
234
- const el = document.getElementById("root");
235
- if (!el) throw new Error("ui-leaf: #root element missing");
236
- createRoot(el).render(<View data={data} mutate={mutate} />);
327
+ const el = document.getElementById("root");
328
+ if (!el) throw new Error("ui-leaf: #root element missing");
329
+ const root = createRoot(el);
330
+ root.render(<View data={currentData} mutate={mutate} />);
331
+ subscribeEvents((ev) => {
332
+ if (ev.type === "data-updated") {
333
+ currentData = ev.data;
334
+ root.render(<View data={currentData} mutate={mutate} />);
335
+ } else if (ev.type === "view-swapped") {
336
+ window.location.reload();
337
+ } else if (ev.type === "closing") {
338
+ el.innerHTML = ${JSON.stringify(CLOSED_OVERLAY_HTML)};
339
+ }
340
+ });
341
+ }
237
342
  `;
238
343
  await writeFile(entryPath, entryContent);
239
344
  const buildResult = await runBunBuild(entryPath);
240
345
  if ("errors" in buildResult) return { html: "", errors: buildResult.errors };
241
346
  return {
242
- html: assembleHtml({ js: buildResult.js, title, csp, data, token, dataLoader: false }),
347
+ html: assembleHtml({ js: buildResult.js, title, csp, data, dataLoader: false }),
243
348
  errors: []
244
349
  };
245
350
  } finally {
@@ -337,7 +442,8 @@ async function startDevServer(opts) {
337
442
  csp,
338
443
  allowedHosts,
339
444
  silent = false,
340
- _opener
445
+ _opener,
446
+ _heartbeatCheckIntervalMs = 1e3
341
447
  } = opts;
342
448
  const cspHeader = resolveCsp(csp);
343
449
  const allowedHostSet = new Set(DEFAULT_LOOPBACK_HOSTNAMES);
@@ -347,8 +453,19 @@ async function startDevServer(opts) {
347
453
  try {
348
454
  let fireEvent2 = function(event) {
349
455
  for (const fn of listeners.get(event)) fn();
456
+ }, broadcast2 = function(event) {
457
+ const frame = sseEncoder.encode(`data: ${JSON.stringify(event)}
458
+
459
+ `);
460
+ for (const controller of sseClients) {
461
+ try {
462
+ controller.enqueue(frame);
463
+ } catch {
464
+ sseClients.delete(controller);
465
+ }
466
+ }
350
467
  };
351
- var fireEvent = fireEvent2;
468
+ var fireEvent = fireEvent2, broadcast = broadcast2;
352
469
  if (view.includes("/") || view.includes("\\")) {
353
470
  throw new Error(
354
471
  `ui-leaf: view '${view}' must be a bare identifier with no path separators`
@@ -378,10 +495,15 @@ async function startDevServer(opts) {
378
495
  const viewState = { html: result.html, data: dataLoader ? loadedData : data };
379
496
  const listeners = /* @__PURE__ */ new Map([
380
497
  ["data-updated", /* @__PURE__ */ new Set()],
381
- ["view-swapped", /* @__PURE__ */ new Set()]
498
+ ["view-swapped", /* @__PURE__ */ new Set()],
499
+ ["disconnected", /* @__PURE__ */ new Set()],
500
+ ["reconnected", /* @__PURE__ */ new Set()]
382
501
  ]);
502
+ const sseClients = /* @__PURE__ */ new Set();
503
+ const sseEncoder = new TextEncoder();
383
504
  let lastHeartbeatAt = Date.now();
384
505
  let closeRequested = false;
506
+ let connectionState = "connecting";
385
507
  let resolveClosed = () => {
386
508
  };
387
509
  const closed = new Promise((r) => {
@@ -389,82 +511,135 @@ async function startDevServer(opts) {
389
511
  });
390
512
  const bunPort = port === void 0 ? 5810 : port;
391
513
  let actualPort = bunPort;
392
- const server = (() => {
393
- const handler = (req) => {
394
- const host = req.headers.get("host") ?? void 0;
395
- const origin = req.headers.get("origin") ?? void 0;
396
- const hostOk = isAllowedHost(host, allowedHostSet);
397
- const originOk = isAllowedOrigin(origin, allowedHostSet);
398
- if (!hostOk || !originOk) {
399
- const offender = !hostOk ? `Host "${host ?? "(absent)"}"` : `Origin "${origin}"`;
400
- return new Response(
401
- `ui-leaf: refusing request with ${offender} \u2014 only the following hostnames are accepted to prevent DNS rebinding: ${allowedHostList}. Open the server at http://localhost:${actualPort}/ or http://127.0.0.1:${actualPort}/, or pass { allowedHosts: ["my-alias"] } to mount() to permit a custom alias.
514
+ const handler = (req) => {
515
+ const host = req.headers.get("host") ?? void 0;
516
+ const origin = req.headers.get("origin") ?? void 0;
517
+ const hostOk = isAllowedHost(host, allowedHostSet);
518
+ const originOk = isAllowedOrigin(origin, allowedHostSet);
519
+ if (!hostOk || !originOk) {
520
+ const offender = !hostOk ? `Host "${host ?? "(absent)"}"` : `Origin "${origin}"`;
521
+ return new Response(
522
+ `ui-leaf: refusing request with ${offender} \u2014 only the following hostnames are accepted to prevent DNS rebinding: ${allowedHostList}. Open the server at http://localhost:${actualPort}/ or http://127.0.0.1:${actualPort}/, or pass { allowedHosts: ["my-alias"] } to mount() to permit a custom alias.
402
523
  `,
403
- { status: 403, headers: { "Content-Type": "text/plain; charset=utf-8" } }
404
- );
524
+ { status: 403, headers: { "Content-Type": "text/plain; charset=utf-8" } }
525
+ );
526
+ }
527
+ const headers = {};
528
+ if (cspHeader) {
529
+ headers["Content-Security-Policy"] = cspHeader;
530
+ }
531
+ const url2 = new URL(req.url);
532
+ const path = url2.pathname;
533
+ const method = req.method;
534
+ if (method === "GET" && path === "/") {
535
+ return new Response(viewState.html, {
536
+ status: 200,
537
+ headers: { ...headers, "Content-Type": "text/html; charset=utf-8" }
538
+ });
539
+ }
540
+ if (method === "POST" && path === "/heartbeat") {
541
+ if (!checkAuth(req, token)) {
542
+ return new Response("", { status: 401, headers });
405
543
  }
406
- const headers = {};
407
- if (cspHeader) {
408
- headers["Content-Security-Policy"] = cspHeader;
544
+ lastHeartbeatAt = Date.now();
545
+ if (connectionState === "disconnected") {
546
+ connectionState = "connected";
547
+ fireEvent2("reconnected");
548
+ } else if (connectionState === "connecting") {
549
+ connectionState = "connected";
409
550
  }
410
- const url2 = new URL(req.url);
411
- const path = url2.pathname;
412
- const method = req.method;
413
- if (method === "GET" && path === "/") {
414
- return new Response(viewState.html, {
415
- status: 200,
416
- headers: { ...headers, "Content-Type": "text/html; charset=utf-8" }
551
+ return new Response("", { status: 204, headers });
552
+ }
553
+ if (method === "POST" && path === "/mutate") {
554
+ if (!checkAuth(req, token)) {
555
+ return new Response("", { status: 401, headers });
556
+ }
557
+ return handleMutate(req, mutations, headers);
558
+ }
559
+ if (method === "GET" && path === "/api/data") {
560
+ if (!dataLoader) {
561
+ return new Response(JSON.stringify({ error: "not found" }), {
562
+ status: 404,
563
+ headers: { ...headers, "Content-Type": "application/json" }
417
564
  });
418
565
  }
419
- if (method === "POST" && path === "/heartbeat") {
420
- if (!checkAuth(req, token)) {
421
- return new Response(JSON.stringify({ error: "unauthorized" }), {
422
- status: 401,
423
- headers: { ...headers, "Content-Type": "application/json" }
424
- });
425
- }
426
- lastHeartbeatAt = Date.now();
427
- return new Response("", { status: 204, headers });
566
+ if (!checkAuth(req, token)) {
567
+ return new Response("", { status: 401, headers });
428
568
  }
429
- if (method === "POST" && path === "/mutate") {
430
- if (!checkAuth(req, token)) {
431
- return new Response(JSON.stringify({ error: "unauthorized" }), {
432
- status: 401,
433
- headers: { ...headers, "Content-Type": "application/json" }
434
- });
435
- }
436
- return handleMutate(req, mutations, headers);
569
+ return new Response(JSON.stringify(viewState.data !== void 0 ? viewState.data : null), {
570
+ status: 200,
571
+ headers: { ...headers, "Content-Type": "application/json" }
572
+ });
573
+ }
574
+ if (method === "GET" && path === "/events") {
575
+ if (!checkAuth(req, token)) {
576
+ return new Response("", { status: 401, headers });
437
577
  }
438
- if (method === "GET" && path === "/api/data") {
439
- if (!dataLoader) {
440
- return new Response(JSON.stringify({ error: "not found" }), {
441
- status: 404,
442
- headers: { ...headers, "Content-Type": "application/json" }
578
+ let sseController;
579
+ const stream = new ReadableStream({
580
+ start(controller) {
581
+ sseController = controller;
582
+ sseClients.add(controller);
583
+ controller.enqueue(sseEncoder.encode(": connected\n\n"));
584
+ req.signal?.addEventListener("abort", () => {
585
+ sseClients.delete(sseController);
586
+ try {
587
+ sseController.close();
588
+ } catch {
589
+ }
443
590
  });
591
+ },
592
+ cancel() {
593
+ sseClients.delete(sseController);
444
594
  }
445
- if (!checkAuth(req, token)) {
446
- return new Response(JSON.stringify({ error: "unauthorized" }), {
447
- status: 401,
448
- headers: { ...headers, "Content-Type": "application/json" }
449
- });
595
+ });
596
+ return new Response(stream, {
597
+ status: 200,
598
+ headers: {
599
+ ...headers,
600
+ "Content-Type": "text/event-stream",
601
+ "Cache-Control": "no-cache"
450
602
  }
451
- return new Response(JSON.stringify(viewState.data !== void 0 ? viewState.data : null), {
452
- status: 200,
453
- headers: { ...headers, "Content-Type": "application/json" }
454
- });
455
- }
456
- return new Response(JSON.stringify({ error: "not found" }), {
457
- status: 404,
458
- headers: { ...headers, "Content-Type": "application/json" }
459
603
  });
460
- };
604
+ }
605
+ return new Response(JSON.stringify({ error: "not found" }), {
606
+ status: 404,
607
+ headers: { ...headers, "Content-Type": "application/json" }
608
+ });
609
+ };
610
+ let heartbeatWatcher;
611
+ let bunServer;
612
+ const cleanup = async (reason) => {
613
+ if (closeRequested) return;
614
+ closeRequested = true;
615
+ if (heartbeatWatcher) clearInterval(heartbeatWatcher);
616
+ broadcast2({ type: "closing", reason });
617
+ for (const controller of sseClients) {
618
+ try {
619
+ controller.close();
620
+ } catch {
621
+ }
622
+ }
623
+ sseClients.clear();
624
+ await bunServer.stop();
625
+ if (restoreStdout) restoreStdout();
626
+ resolveClosed(reason);
627
+ };
628
+ const serverErrorHandler = (_err) => {
629
+ void cleanup("error");
630
+ return new Response(JSON.stringify({ error: "internal server error" }), {
631
+ status: 500,
632
+ headers: { "Content-Type": "application/json" }
633
+ });
634
+ };
635
+ bunServer = (() => {
461
636
  if (bunPort === 0) {
462
- return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler });
637
+ return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler, error: serverErrorHandler, idleTimeout: 0 });
463
638
  }
464
639
  const MAX_PORT_ATTEMPTS = 10;
465
640
  for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) {
466
641
  try {
467
- return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler });
642
+ return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler, error: serverErrorHandler, idleTimeout: 0 });
468
643
  } catch (err) {
469
644
  const isAddrinuse = err instanceof Error && err.message.includes("EADDRINUSE");
470
645
  if (!isAddrinuse || i === MAX_PORT_ATTEMPTS - 1) {
@@ -479,37 +654,33 @@ async function startDevServer(opts) {
479
654
  }
480
655
  throw new Error("unreachable");
481
656
  })();
482
- actualPort = server.port ?? bunPort;
657
+ actualPort = bunServer.port ?? bunPort;
483
658
  const url = `http://127.0.0.1:${actualPort}`;
484
659
  const startedAt = Date.now();
485
- let heartbeatWatcher;
486
- const cleanup = async () => {
487
- if (closeRequested) return;
488
- closeRequested = true;
489
- if (heartbeatWatcher) clearInterval(heartbeatWatcher);
490
- await server.stop(true);
491
- if (restoreStdout) restoreStdout();
492
- resolveClosed();
493
- };
494
660
  heartbeatWatcher = setInterval(() => {
661
+ if (closeRequested) return;
495
662
  const now = Date.now();
496
663
  if (now - startedAt < startupGraceMs) return;
497
664
  if (now - lastHeartbeatAt > heartbeatTimeoutMs) {
498
- void cleanup();
665
+ if (connectionState !== "disconnected") {
666
+ connectionState = "disconnected";
667
+ fireEvent2("disconnected");
668
+ }
499
669
  }
500
- }, 1e3);
501
- const doOpen = _opener ? () => _opener(url) : async () => {
670
+ }, _heartbeatCheckIntervalMs);
671
+ const openUrl = `${url}/#token=${token}`;
672
+ const doOpen = _opener ? () => _opener(openUrl) : async () => {
502
673
  if (shell === "app") {
503
- const launched = await openInAppMode(url);
674
+ const launched = await openInAppMode(openUrl);
504
675
  if (!launched) {
505
676
  process.stderr.write(
506
677
  `ui-leaf: shell:"app" requested but no Chromium browser found; falling back to default browser tab.
507
678
  `
508
679
  );
509
- await open(url);
680
+ await open(openUrl);
510
681
  }
511
682
  } else {
512
- await open(url);
683
+ await open(openUrl);
513
684
  }
514
685
  };
515
686
  if (openBrowser) {
@@ -519,7 +690,7 @@ async function startDevServer(opts) {
519
690
  url,
520
691
  port: actualPort,
521
692
  closed,
522
- close: cleanup,
693
+ close: (reason = "caller") => cleanup(reason),
523
694
  on(event, listener) {
524
695
  listeners.get(event)?.add(listener);
525
696
  },
@@ -528,6 +699,7 @@ async function startDevServer(opts) {
528
699
  },
529
700
  update(newData) {
530
701
  viewState.data = newData;
702
+ broadcast2({ type: "data-updated", data: newData });
531
703
  fireEvent2("data-updated");
532
704
  },
533
705
  async swapView(source) {
@@ -540,6 +712,7 @@ async function startDevServer(opts) {
540
712
  });
541
713
  if (r.errors.length > 0) return r.errors;
542
714
  viewState.html = r.html;
715
+ broadcast2({ type: "view-swapped" });
543
716
  fireEvent2("view-swapped");
544
717
  return [];
545
718
  },
@@ -554,6 +727,8 @@ async function startDevServer(opts) {
554
727
  if (r.errors.length > 0) return r.errors;
555
728
  viewState.data = newData;
556
729
  viewState.html = r.html;
730
+ broadcast2({ type: "data-updated", data: newData });
731
+ broadcast2({ type: "view-swapped" });
557
732
  fireEvent2("data-updated");
558
733
  fireEvent2("view-swapped");
559
734
  return [];
@@ -568,10 +743,9 @@ async function startDevServer(opts) {
568
743
  }
569
744
  }
570
745
  function checkAuth(req, token) {
571
- const header = req.headers.get("authorization") ?? "";
572
- const match = /^Bearer (.+)$/.exec(header);
573
- if (!match) return false;
574
- return timingSafeEqual(match[1], token);
746
+ const value = req.headers.get("x-ui-leaf-token") ?? "";
747
+ if (!value) return false;
748
+ return timingSafeEqual(value, token);
575
749
  }
576
750
  async function handleMutate(req, mutations, headers) {
577
751
  const contentLength = req.headers.get("content-length");
@@ -644,11 +818,12 @@ async function mount(opts) {
644
818
  startupGraceMs: opts.startupGraceMs,
645
819
  csp: opts.csp,
646
820
  allowedHosts: opts.allowedHosts,
647
- silent: opts.silent
821
+ silent: opts.silent,
822
+ _heartbeatCheckIntervalMs: opts._heartbeatCheckIntervalMs
648
823
  });
649
824
  const onSignal = (signal) => {
650
825
  void (async () => {
651
- await server.close();
826
+ await server.close("signal");
652
827
  process.kill(process.pid, signal);
653
828
  })();
654
829
  };
@@ -664,8 +839,8 @@ async function mount(opts) {
664
839
  return {
665
840
  url: server.url,
666
841
  port: server.port,
667
- closed: Promise.resolve(),
668
- close: server.close,
842
+ closed: Promise.resolve("caller"),
843
+ close: () => server.close(),
669
844
  update: server.update.bind(server),
670
845
  swapView: (source) => server.swapView(source),
671
846
  patch: (data, source) => server.patch(data, source),
@@ -688,7 +863,7 @@ async function mount(opts) {
688
863
  url: server.url,
689
864
  port: server.port,
690
865
  closed,
691
- close: server.close,
866
+ close: () => server.close(),
692
867
  update: server.update.bind(server),
693
868
  swapView: (source) => server.swapView(source),
694
869
  patch: (data, source) => server.patch(data, source),
@@ -894,6 +1069,10 @@ async function runMount() {
894
1069
  });
895
1070
  return;
896
1071
  }
1072
+ if (msg.type === "close") {
1073
+ void mountedView.close();
1074
+ return;
1075
+ }
897
1076
  emit2({ type: "error", message: `unknown message type: ${msg.type}` });
898
1077
  });
899
1078
  rl.on("close", () => {
@@ -942,10 +1121,12 @@ async function runMount() {
942
1121
  if (stdinClosed) {
943
1122
  void view.close();
944
1123
  }
1124
+ view.on("disconnected", () => emit2({ type: "disconnected" }));
1125
+ view.on("reconnected", () => emit2({ type: "reconnected" }));
945
1126
  emit2({ type: "ready", url: view.url, port: view.port });
946
- await view.closed;
947
- emit2({ type: "closed" });
948
- process.exit(0);
1127
+ const closeReason = await view.closed;
1128
+ emit2({ type: "closed", reason: closeReason });
1129
+ process.exit(closeReason === "error" ? 1 : 0);
949
1130
  } catch (err) {
950
1131
  emit2({
951
1132
  type: "error",