@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.
@@ -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 {
@@ -332,7 +437,8 @@ async function startDevServer(opts) {
332
437
  csp,
333
438
  allowedHosts,
334
439
  silent = false,
335
- _opener
440
+ _opener,
441
+ _heartbeatCheckIntervalMs = 1e3
336
442
  } = opts;
337
443
  const cspHeader = resolveCsp(csp);
338
444
  const allowedHostSet = new Set(DEFAULT_LOOPBACK_HOSTNAMES);
@@ -342,8 +448,19 @@ async function startDevServer(opts) {
342
448
  try {
343
449
  let fireEvent2 = function(event) {
344
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
+ }
345
462
  };
346
- var fireEvent = fireEvent2;
463
+ var fireEvent = fireEvent2, broadcast = broadcast2;
347
464
  if (view.includes("/") || view.includes("\\")) {
348
465
  throw new Error(
349
466
  `ui-leaf: view '${view}' must be a bare identifier with no path separators`
@@ -373,10 +490,15 @@ async function startDevServer(opts) {
373
490
  const viewState = { html: result.html, data: dataLoader ? loadedData : data };
374
491
  const listeners = /* @__PURE__ */ new Map([
375
492
  ["data-updated", /* @__PURE__ */ new Set()],
376
- ["view-swapped", /* @__PURE__ */ new Set()]
493
+ ["view-swapped", /* @__PURE__ */ new Set()],
494
+ ["disconnected", /* @__PURE__ */ new Set()],
495
+ ["reconnected", /* @__PURE__ */ new Set()]
377
496
  ]);
497
+ const sseClients = /* @__PURE__ */ new Set();
498
+ const sseEncoder = new TextEncoder();
378
499
  let lastHeartbeatAt = Date.now();
379
500
  let closeRequested = false;
501
+ let connectionState = "connecting";
380
502
  let resolveClosed = () => {
381
503
  };
382
504
  const closed = new Promise((r) => {
@@ -384,82 +506,135 @@ async function startDevServer(opts) {
384
506
  });
385
507
  const bunPort = port === void 0 ? 5810 : port;
386
508
  let actualPort = bunPort;
387
- const server = (() => {
388
- const handler = (req) => {
389
- const host = req.headers.get("host") ?? void 0;
390
- const origin = req.headers.get("origin") ?? void 0;
391
- const hostOk = isAllowedHost(host, allowedHostSet);
392
- const originOk = isAllowedOrigin(origin, allowedHostSet);
393
- if (!hostOk || !originOk) {
394
- const offender = !hostOk ? `Host "${host ?? "(absent)"}"` : `Origin "${origin}"`;
395
- return new Response(
396
- `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.
509
+ const handler = (req) => {
510
+ const host = req.headers.get("host") ?? void 0;
511
+ const origin = req.headers.get("origin") ?? void 0;
512
+ const hostOk = isAllowedHost(host, allowedHostSet);
513
+ const originOk = isAllowedOrigin(origin, allowedHostSet);
514
+ if (!hostOk || !originOk) {
515
+ const offender = !hostOk ? `Host "${host ?? "(absent)"}"` : `Origin "${origin}"`;
516
+ return new Response(
517
+ `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.
397
518
  `,
398
- { status: 403, headers: { "Content-Type": "text/plain; charset=utf-8" } }
399
- );
519
+ { status: 403, headers: { "Content-Type": "text/plain; charset=utf-8" } }
520
+ );
521
+ }
522
+ const headers = {};
523
+ if (cspHeader) {
524
+ headers["Content-Security-Policy"] = cspHeader;
525
+ }
526
+ const url2 = new URL(req.url);
527
+ const path = url2.pathname;
528
+ const method = req.method;
529
+ if (method === "GET" && path === "/") {
530
+ return new Response(viewState.html, {
531
+ status: 200,
532
+ headers: { ...headers, "Content-Type": "text/html; charset=utf-8" }
533
+ });
534
+ }
535
+ if (method === "POST" && path === "/heartbeat") {
536
+ if (!checkAuth(req, token)) {
537
+ return new Response("", { status: 401, headers });
400
538
  }
401
- const headers = {};
402
- if (cspHeader) {
403
- headers["Content-Security-Policy"] = cspHeader;
539
+ lastHeartbeatAt = Date.now();
540
+ if (connectionState === "disconnected") {
541
+ connectionState = "connected";
542
+ fireEvent2("reconnected");
543
+ } else if (connectionState === "connecting") {
544
+ connectionState = "connected";
404
545
  }
405
- const url2 = new URL(req.url);
406
- const path = url2.pathname;
407
- const method = req.method;
408
- if (method === "GET" && path === "/") {
409
- return new Response(viewState.html, {
410
- status: 200,
411
- headers: { ...headers, "Content-Type": "text/html; charset=utf-8" }
546
+ return new Response("", { status: 204, headers });
547
+ }
548
+ if (method === "POST" && path === "/mutate") {
549
+ if (!checkAuth(req, token)) {
550
+ return new Response("", { status: 401, headers });
551
+ }
552
+ return handleMutate(req, mutations, headers);
553
+ }
554
+ if (method === "GET" && path === "/api/data") {
555
+ if (!dataLoader) {
556
+ return new Response(JSON.stringify({ error: "not found" }), {
557
+ status: 404,
558
+ headers: { ...headers, "Content-Type": "application/json" }
412
559
  });
413
560
  }
414
- if (method === "POST" && path === "/heartbeat") {
415
- if (!checkAuth(req, token)) {
416
- return new Response(JSON.stringify({ error: "unauthorized" }), {
417
- status: 401,
418
- headers: { ...headers, "Content-Type": "application/json" }
419
- });
420
- }
421
- lastHeartbeatAt = Date.now();
422
- return new Response("", { status: 204, headers });
561
+ if (!checkAuth(req, token)) {
562
+ return new Response("", { status: 401, headers });
423
563
  }
424
- if (method === "POST" && path === "/mutate") {
425
- if (!checkAuth(req, token)) {
426
- return new Response(JSON.stringify({ error: "unauthorized" }), {
427
- status: 401,
428
- headers: { ...headers, "Content-Type": "application/json" }
429
- });
430
- }
431
- return handleMutate(req, mutations, headers);
564
+ return new Response(JSON.stringify(viewState.data !== void 0 ? viewState.data : null), {
565
+ status: 200,
566
+ headers: { ...headers, "Content-Type": "application/json" }
567
+ });
568
+ }
569
+ if (method === "GET" && path === "/events") {
570
+ if (!checkAuth(req, token)) {
571
+ return new Response("", { status: 401, headers });
432
572
  }
433
- if (method === "GET" && path === "/api/data") {
434
- if (!dataLoader) {
435
- return new Response(JSON.stringify({ error: "not found" }), {
436
- status: 404,
437
- headers: { ...headers, "Content-Type": "application/json" }
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
+ }
438
585
  });
586
+ },
587
+ cancel() {
588
+ sseClients.delete(sseController);
439
589
  }
440
- if (!checkAuth(req, token)) {
441
- return new Response(JSON.stringify({ error: "unauthorized" }), {
442
- status: 401,
443
- headers: { ...headers, "Content-Type": "application/json" }
444
- });
590
+ });
591
+ return new Response(stream, {
592
+ status: 200,
593
+ headers: {
594
+ ...headers,
595
+ "Content-Type": "text/event-stream",
596
+ "Cache-Control": "no-cache"
445
597
  }
446
- return new Response(JSON.stringify(viewState.data !== void 0 ? viewState.data : null), {
447
- status: 200,
448
- headers: { ...headers, "Content-Type": "application/json" }
449
- });
450
- }
451
- return new Response(JSON.stringify({ error: "not found" }), {
452
- status: 404,
453
- headers: { ...headers, "Content-Type": "application/json" }
454
598
  });
455
- };
599
+ }
600
+ return new Response(JSON.stringify({ error: "not found" }), {
601
+ status: 404,
602
+ headers: { ...headers, "Content-Type": "application/json" }
603
+ });
604
+ };
605
+ let heartbeatWatcher;
606
+ let bunServer;
607
+ const cleanup = async (reason) => {
608
+ if (closeRequested) return;
609
+ closeRequested = true;
610
+ if (heartbeatWatcher) clearInterval(heartbeatWatcher);
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();
620
+ if (restoreStdout) restoreStdout();
621
+ resolveClosed(reason);
622
+ };
623
+ const serverErrorHandler = (_err) => {
624
+ void cleanup("error");
625
+ return new Response(JSON.stringify({ error: "internal server error" }), {
626
+ status: 500,
627
+ headers: { "Content-Type": "application/json" }
628
+ });
629
+ };
630
+ bunServer = (() => {
456
631
  if (bunPort === 0) {
457
- return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler });
632
+ return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler, error: serverErrorHandler, idleTimeout: 0 });
458
633
  }
459
634
  const MAX_PORT_ATTEMPTS = 10;
460
635
  for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) {
461
636
  try {
462
- return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler });
637
+ return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler, error: serverErrorHandler, idleTimeout: 0 });
463
638
  } catch (err) {
464
639
  const isAddrinuse = err instanceof Error && err.message.includes("EADDRINUSE");
465
640
  if (!isAddrinuse || i === MAX_PORT_ATTEMPTS - 1) {
@@ -474,37 +649,33 @@ async function startDevServer(opts) {
474
649
  }
475
650
  throw new Error("unreachable");
476
651
  })();
477
- actualPort = server.port ?? bunPort;
652
+ actualPort = bunServer.port ?? bunPort;
478
653
  const url = `http://127.0.0.1:${actualPort}`;
479
654
  const startedAt = Date.now();
480
- let heartbeatWatcher;
481
- const cleanup = async () => {
482
- if (closeRequested) return;
483
- closeRequested = true;
484
- if (heartbeatWatcher) clearInterval(heartbeatWatcher);
485
- await server.stop(true);
486
- if (restoreStdout) restoreStdout();
487
- resolveClosed();
488
- };
489
655
  heartbeatWatcher = setInterval(() => {
656
+ if (closeRequested) return;
490
657
  const now = Date.now();
491
658
  if (now - startedAt < startupGraceMs) return;
492
659
  if (now - lastHeartbeatAt > heartbeatTimeoutMs) {
493
- void cleanup();
660
+ if (connectionState !== "disconnected") {
661
+ connectionState = "disconnected";
662
+ fireEvent2("disconnected");
663
+ }
494
664
  }
495
- }, 1e3);
496
- const doOpen = _opener ? () => _opener(url) : async () => {
665
+ }, _heartbeatCheckIntervalMs);
666
+ const openUrl = `${url}/#token=${token}`;
667
+ const doOpen = _opener ? () => _opener(openUrl) : async () => {
497
668
  if (shell === "app") {
498
- const launched = await openInAppMode(url);
669
+ const launched = await openInAppMode(openUrl);
499
670
  if (!launched) {
500
671
  process.stderr.write(
501
672
  `ui-leaf: shell:"app" requested but no Chromium browser found; falling back to default browser tab.
502
673
  `
503
674
  );
504
- await open(url);
675
+ await open(openUrl);
505
676
  }
506
677
  } else {
507
- await open(url);
678
+ await open(openUrl);
508
679
  }
509
680
  };
510
681
  if (openBrowser) {
@@ -514,7 +685,7 @@ async function startDevServer(opts) {
514
685
  url,
515
686
  port: actualPort,
516
687
  closed,
517
- close: cleanup,
688
+ close: (reason = "caller") => cleanup(reason),
518
689
  on(event, listener) {
519
690
  listeners.get(event)?.add(listener);
520
691
  },
@@ -523,6 +694,7 @@ async function startDevServer(opts) {
523
694
  },
524
695
  update(newData) {
525
696
  viewState.data = newData;
697
+ broadcast2({ type: "data-updated", data: newData });
526
698
  fireEvent2("data-updated");
527
699
  },
528
700
  async swapView(source) {
@@ -535,6 +707,7 @@ async function startDevServer(opts) {
535
707
  });
536
708
  if (r.errors.length > 0) return r.errors;
537
709
  viewState.html = r.html;
710
+ broadcast2({ type: "view-swapped" });
538
711
  fireEvent2("view-swapped");
539
712
  return [];
540
713
  },
@@ -549,6 +722,8 @@ async function startDevServer(opts) {
549
722
  if (r.errors.length > 0) return r.errors;
550
723
  viewState.data = newData;
551
724
  viewState.html = r.html;
725
+ broadcast2({ type: "data-updated", data: newData });
726
+ broadcast2({ type: "view-swapped" });
552
727
  fireEvent2("data-updated");
553
728
  fireEvent2("view-swapped");
554
729
  return [];
@@ -563,10 +738,9 @@ async function startDevServer(opts) {
563
738
  }
564
739
  }
565
740
  function checkAuth(req, token) {
566
- const header = req.headers.get("authorization") ?? "";
567
- const match = /^Bearer (.+)$/.exec(header);
568
- if (!match) return false;
569
- 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);
570
744
  }
571
745
  async function handleMutate(req, mutations, headers) {
572
746
  const contentLength = req.headers.get("content-length");
@@ -639,11 +813,12 @@ async function mount(opts) {
639
813
  startupGraceMs: opts.startupGraceMs,
640
814
  csp: opts.csp,
641
815
  allowedHosts: opts.allowedHosts,
642
- silent: opts.silent
816
+ silent: opts.silent,
817
+ _heartbeatCheckIntervalMs: opts._heartbeatCheckIntervalMs
643
818
  });
644
819
  const onSignal = (signal) => {
645
820
  void (async () => {
646
- await server.close();
821
+ await server.close("signal");
647
822
  process.kill(process.pid, signal);
648
823
  })();
649
824
  };
@@ -659,8 +834,8 @@ async function mount(opts) {
659
834
  return {
660
835
  url: server.url,
661
836
  port: server.port,
662
- closed: Promise.resolve(),
663
- close: server.close,
837
+ closed: Promise.resolve("caller"),
838
+ close: () => server.close(),
664
839
  update: server.update.bind(server),
665
840
  swapView: (source) => server.swapView(source),
666
841
  patch: (data, source) => server.patch(data, source),
@@ -683,7 +858,7 @@ async function mount(opts) {
683
858
  url: server.url,
684
859
  port: server.port,
685
860
  closed,
686
- close: server.close,
861
+ close: () => server.close(),
687
862
  update: server.update.bind(server),
688
863
  swapView: (source) => server.swapView(source),
689
864
  patch: (data, source) => server.patch(data, source),