@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openthink/ui-leaf",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Customizable browser views, on demand, for any CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Matt Pardini",
@@ -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 {
@@ -348,8 +453,19 @@ async function startDevServer(opts) {
348
453
  try {
349
454
  let fireEvent2 = function(event) {
350
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
+ }
351
467
  };
352
- var fireEvent = fireEvent2;
468
+ var fireEvent = fireEvent2, broadcast = broadcast2;
353
469
  if (view.includes("/") || view.includes("\\")) {
354
470
  throw new Error(
355
471
  `ui-leaf: view '${view}' must be a bare identifier with no path separators`
@@ -383,6 +499,8 @@ async function startDevServer(opts) {
383
499
  ["disconnected", /* @__PURE__ */ new Set()],
384
500
  ["reconnected", /* @__PURE__ */ new Set()]
385
501
  ]);
502
+ const sseClients = /* @__PURE__ */ new Set();
503
+ const sseEncoder = new TextEncoder();
386
504
  let lastHeartbeatAt = Date.now();
387
505
  let closeRequested = false;
388
506
  let connectionState = "connecting";
@@ -421,10 +539,7 @@ async function startDevServer(opts) {
421
539
  }
422
540
  if (method === "POST" && path === "/heartbeat") {
423
541
  if (!checkAuth(req, token)) {
424
- return new Response(JSON.stringify({ error: "unauthorized" }), {
425
- status: 401,
426
- headers: { ...headers, "Content-Type": "application/json" }
427
- });
542
+ return new Response("", { status: 401, headers });
428
543
  }
429
544
  lastHeartbeatAt = Date.now();
430
545
  if (connectionState === "disconnected") {
@@ -437,10 +552,7 @@ async function startDevServer(opts) {
437
552
  }
438
553
  if (method === "POST" && path === "/mutate") {
439
554
  if (!checkAuth(req, token)) {
440
- return new Response(JSON.stringify({ error: "unauthorized" }), {
441
- status: 401,
442
- headers: { ...headers, "Content-Type": "application/json" }
443
- });
555
+ return new Response("", { status: 401, headers });
444
556
  }
445
557
  return handleMutate(req, mutations, headers);
446
558
  }
@@ -452,16 +564,44 @@ async function startDevServer(opts) {
452
564
  });
453
565
  }
454
566
  if (!checkAuth(req, token)) {
455
- return new Response(JSON.stringify({ error: "unauthorized" }), {
456
- status: 401,
457
- headers: { ...headers, "Content-Type": "application/json" }
458
- });
567
+ return new Response("", { status: 401, headers });
459
568
  }
460
569
  return new Response(JSON.stringify(viewState.data !== void 0 ? viewState.data : null), {
461
570
  status: 200,
462
571
  headers: { ...headers, "Content-Type": "application/json" }
463
572
  });
464
573
  }
574
+ if (method === "GET" && path === "/events") {
575
+ if (!checkAuth(req, token)) {
576
+ return new Response("", { status: 401, headers });
577
+ }
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
+ }
590
+ });
591
+ },
592
+ cancel() {
593
+ sseClients.delete(sseController);
594
+ }
595
+ });
596
+ return new Response(stream, {
597
+ status: 200,
598
+ headers: {
599
+ ...headers,
600
+ "Content-Type": "text/event-stream",
601
+ "Cache-Control": "no-cache"
602
+ }
603
+ });
604
+ }
465
605
  return new Response(JSON.stringify({ error: "not found" }), {
466
606
  status: 404,
467
607
  headers: { ...headers, "Content-Type": "application/json" }
@@ -473,7 +613,15 @@ async function startDevServer(opts) {
473
613
  if (closeRequested) return;
474
614
  closeRequested = true;
475
615
  if (heartbeatWatcher) clearInterval(heartbeatWatcher);
476
- await bunServer.stop(true);
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();
477
625
  if (restoreStdout) restoreStdout();
478
626
  resolveClosed(reason);
479
627
  };
@@ -486,12 +634,12 @@ async function startDevServer(opts) {
486
634
  };
487
635
  bunServer = (() => {
488
636
  if (bunPort === 0) {
489
- return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler, error: serverErrorHandler });
637
+ return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler, error: serverErrorHandler, idleTimeout: 0 });
490
638
  }
491
639
  const MAX_PORT_ATTEMPTS = 10;
492
640
  for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) {
493
641
  try {
494
- return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler, error: serverErrorHandler });
642
+ return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler, error: serverErrorHandler, idleTimeout: 0 });
495
643
  } catch (err) {
496
644
  const isAddrinuse = err instanceof Error && err.message.includes("EADDRINUSE");
497
645
  if (!isAddrinuse || i === MAX_PORT_ATTEMPTS - 1) {
@@ -520,18 +668,19 @@ async function startDevServer(opts) {
520
668
  }
521
669
  }
522
670
  }, _heartbeatCheckIntervalMs);
523
- const doOpen = _opener ? () => _opener(url) : async () => {
671
+ const openUrl = `${url}/#token=${token}`;
672
+ const doOpen = _opener ? () => _opener(openUrl) : async () => {
524
673
  if (shell === "app") {
525
- const launched = await openInAppMode(url);
674
+ const launched = await openInAppMode(openUrl);
526
675
  if (!launched) {
527
676
  process.stderr.write(
528
677
  `ui-leaf: shell:"app" requested but no Chromium browser found; falling back to default browser tab.
529
678
  `
530
679
  );
531
- await open(url);
680
+ await open(openUrl);
532
681
  }
533
682
  } else {
534
- await open(url);
683
+ await open(openUrl);
535
684
  }
536
685
  };
537
686
  if (openBrowser) {
@@ -550,6 +699,7 @@ async function startDevServer(opts) {
550
699
  },
551
700
  update(newData) {
552
701
  viewState.data = newData;
702
+ broadcast2({ type: "data-updated", data: newData });
553
703
  fireEvent2("data-updated");
554
704
  },
555
705
  async swapView(source) {
@@ -562,6 +712,7 @@ async function startDevServer(opts) {
562
712
  });
563
713
  if (r.errors.length > 0) return r.errors;
564
714
  viewState.html = r.html;
715
+ broadcast2({ type: "view-swapped" });
565
716
  fireEvent2("view-swapped");
566
717
  return [];
567
718
  },
@@ -576,6 +727,8 @@ async function startDevServer(opts) {
576
727
  if (r.errors.length > 0) return r.errors;
577
728
  viewState.data = newData;
578
729
  viewState.html = r.html;
730
+ broadcast2({ type: "data-updated", data: newData });
731
+ broadcast2({ type: "view-swapped" });
579
732
  fireEvent2("data-updated");
580
733
  fireEvent2("view-swapped");
581
734
  return [];
@@ -590,10 +743,9 @@ async function startDevServer(opts) {
590
743
  }
591
744
  }
592
745
  function checkAuth(req, token) {
593
- const header = req.headers.get("authorization") ?? "";
594
- const match = /^Bearer (.+)$/.exec(header);
595
- if (!match) return false;
596
- 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);
597
749
  }
598
750
  async function handleMutate(req, mutations, headers) {
599
751
  const contentLength = req.headers.get("content-length");
@@ -728,6 +880,80 @@ function emit(event) {
728
880
  return `${JSON.stringify(stamped)}
729
881
  `;
730
882
  }
883
+ function validateInboundShape(msg, kind) {
884
+ if (typeof msg !== "object" || msg === null) {
885
+ return { ok: false, reason: "message is not an object" };
886
+ }
887
+ const m = msg;
888
+ if (kind === "config") {
889
+ if (typeof m.view !== "string" || m.view === "") {
890
+ return { ok: false, reason: 'config requires a non-empty string "view"' };
891
+ }
892
+ if (typeof m.viewsRoot !== "string" || m.viewsRoot === "") {
893
+ return { ok: false, reason: 'config requires a non-empty string "viewsRoot"' };
894
+ }
895
+ if ("mutations" in m && m.mutations !== void 0) {
896
+ if (!Array.isArray(m.mutations) || !m.mutations.every((x) => typeof x === "string")) {
897
+ return { ok: false, reason: "config.mutations must be an array of strings" };
898
+ }
899
+ }
900
+ if ("port" in m && m.port !== void 0 && typeof m.port !== "number") {
901
+ return { ok: false, reason: "config.port must be a number" };
902
+ }
903
+ if ("openBrowser" in m && m.openBrowser !== void 0 && typeof m.openBrowser !== "boolean") {
904
+ return { ok: false, reason: "config.openBrowser must be a boolean" };
905
+ }
906
+ if ("shell" in m && m.shell !== void 0 && m.shell !== "tab" && m.shell !== "app") {
907
+ return { ok: false, reason: 'config.shell must be "tab" or "app"' };
908
+ }
909
+ if ("heartbeatTimeoutMs" in m && m.heartbeatTimeoutMs !== void 0 && typeof m.heartbeatTimeoutMs !== "number") {
910
+ return { ok: false, reason: "config.heartbeatTimeoutMs must be a number" };
911
+ }
912
+ if ("startupGraceMs" in m && m.startupGraceMs !== void 0 && typeof m.startupGraceMs !== "number") {
913
+ return { ok: false, reason: "config.startupGraceMs must be a number" };
914
+ }
915
+ return { ok: true };
916
+ }
917
+ const type = m.type;
918
+ if (typeof type !== "string") {
919
+ return { ok: false, reason: '"type" field must be a string' };
920
+ }
921
+ if (type === "result" || type === "error") {
922
+ if (typeof m.id !== "number") {
923
+ return { ok: false, reason: `"${type}" requires a numeric "id" field` };
924
+ }
925
+ if (type === "error" && typeof m.message !== "string") {
926
+ return { ok: false, reason: '"error" requires a string "message" field' };
927
+ }
928
+ return { ok: true };
929
+ }
930
+ switch (type) {
931
+ case "update":
932
+ if (!Object.hasOwn(m, "data")) {
933
+ return { ok: false, reason: '"update" requires a "data" field' };
934
+ }
935
+ return { ok: true };
936
+ case "view":
937
+ if (typeof m.source !== "string") {
938
+ return { ok: false, reason: '"view" requires a string "source" field' };
939
+ }
940
+ return { ok: true };
941
+ case "patch":
942
+ if (!Object.hasOwn(m, "data")) {
943
+ return { ok: false, reason: '"patch" requires a "data" field' };
944
+ }
945
+ if (typeof m.view !== "object" || m.view === null || typeof m.view.source !== "string") {
946
+ return { ok: false, reason: '"patch" requires a string "view.source" field' };
947
+ }
948
+ return { ok: true };
949
+ case "reopen":
950
+ case "close":
951
+ case "ping":
952
+ return { ok: true };
953
+ default:
954
+ return { ok: false, reason: `unknown message type: "${type}"` };
955
+ }
956
+ }
731
957
  function parseInbound(line) {
732
958
  let parsed;
733
959
  try {
@@ -856,6 +1082,11 @@ async function runMount() {
856
1082
  }
857
1083
  process.exit(1);
858
1084
  }
1085
+ const configValidation = validateInboundShape(outcome2.msg, "config");
1086
+ if (!configValidation.ok) {
1087
+ emit2({ type: "error", message: configValidation.reason });
1088
+ process.exit(1);
1089
+ }
859
1090
  configResolve(outcome2.msg);
860
1091
  return;
861
1092
  }
@@ -872,6 +1103,11 @@ async function runMount() {
872
1103
  return;
873
1104
  }
874
1105
  const msg = outcome.msg;
1106
+ const msgValidation = validateInboundShape(msg, "post-config");
1107
+ if (!msgValidation.ok) {
1108
+ emit2({ type: "error", message: msgValidation.reason });
1109
+ return;
1110
+ }
875
1111
  if ("id" in msg && typeof msg.id === "number") {
876
1112
  const p = pending.get(msg.id);
877
1113
  if (!p) return;
@@ -921,7 +1157,9 @@ async function runMount() {
921
1157
  void mountedView.close();
922
1158
  return;
923
1159
  }
924
- emit2({ type: "error", message: `unknown message type: ${msg.type}` });
1160
+ if (msg.type === "ping") {
1161
+ return;
1162
+ }
925
1163
  });
926
1164
  rl.on("close", () => {
927
1165
  stdinClosed = true;