@openthink/ui-leaf 0.4.0 → 0.5.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.
@@ -0,0 +1,698 @@
1
+ // src/index.ts
2
+ import { resolve as resolve2 } from "path";
3
+
4
+ // src/server.ts
5
+ import { randomBytes, timingSafeEqual as nodeTimingSafeEqual } from "crypto";
6
+ import open, { apps } from "open";
7
+
8
+ // src/compile.ts
9
+ import { createRequire } from "module";
10
+ import { tmpdir } from "os";
11
+ import { join, resolve, sep } from "path";
12
+ import { mkdtemp, rm, stat, writeFile } from "fs/promises";
13
+
14
+ // src/internal/html.ts
15
+ function escapeForScriptTag(json) {
16
+ return json.replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
17
+ }
18
+
19
+ // src/compile.ts
20
+ var requireFromHere = createRequire(import.meta.url);
21
+ var reactAliasPlugin = {
22
+ name: "ui-leaf-react-alias",
23
+ setup(build) {
24
+ build.onResolve({ filter: /^react($|\/|-dom($|\/))/ }, (args) => {
25
+ try {
26
+ return { path: requireFromHere.resolve(args.path) };
27
+ } catch {
28
+ return {
29
+ path: args.path,
30
+ errors: [{ text: `ui-leaf: failed to resolve ${args.path}` }]
31
+ };
32
+ }
33
+ });
34
+ }
35
+ };
36
+ var SHARED_BRIDGE = `
37
+ async function mutate(name: string, args?: unknown): Promise<unknown> {
38
+ const res = await fetch("/mutate", {
39
+ method: "POST",
40
+ headers: {
41
+ "Content-Type": "application/json",
42
+ ...(token ? { Authorization: "Bearer " + token } : {}),
43
+ },
44
+ body: JSON.stringify({ name, args }),
45
+ });
46
+ const text = await res.text().catch(() => "");
47
+ if (!res.ok) {
48
+ let detail = text;
49
+ try {
50
+ const parsed: unknown = text ? JSON.parse(text) : null;
51
+ if (parsed !== null && typeof parsed === "object" && "error" in parsed && typeof (parsed as { error: unknown }).error === "string") {
52
+ detail = (parsed as { error: string }).error;
53
+ }
54
+ } catch { /* keep raw text */ }
55
+ throw new Error("ui-leaf: mutation '" + name + "' failed (" + res.status + "): " + detail);
56
+ }
57
+ return text ? JSON.parse(text) : undefined;
58
+ }
59
+
60
+ async function heartbeat(): Promise<void> {
61
+ try {
62
+ await fetch("/heartbeat", {
63
+ method: "POST",
64
+ headers: token ? { Authorization: "Bearer " + token } : {},
65
+ });
66
+ } catch { /* server may have shut down; ignore */ }
67
+ }
68
+ setInterval(heartbeat, 5000);
69
+ heartbeat();`;
70
+ async function runBunBuild(entryPath) {
71
+ let buildOutput;
72
+ try {
73
+ buildOutput = await Bun.build({
74
+ entrypoints: [entryPath],
75
+ target: "browser",
76
+ format: "esm",
77
+ minify: false,
78
+ sourcemap: "none",
79
+ plugins: [reactAliasPlugin]
80
+ });
81
+ } catch (err) {
82
+ if (err instanceof AggregateError) {
83
+ const errors = err.errors.map((e) => ({
84
+ file: e.position?.file ?? "<unknown>",
85
+ line: e.position?.line ?? 0,
86
+ column: e.position?.column ?? 0,
87
+ message: e.message
88
+ }));
89
+ return { errors };
90
+ }
91
+ throw err;
92
+ }
93
+ const output = buildOutput.outputs[0];
94
+ if (!output) {
95
+ return {
96
+ errors: [{ file: "<unknown>", line: 0, column: 0, message: "ui-leaf: Bun.build produced no output" }]
97
+ };
98
+ }
99
+ return { js: await output.text() };
100
+ }
101
+ function assembleHtml(opts) {
102
+ const { js, title, csp, data, token, dataLoader } = opts;
103
+ const safeJs = js.replace(/<\/script>/gi, "<\\/script>");
104
+ const titleEscaped = title.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
105
+ const cspMeta = csp ? ` <meta http-equiv="Content-Security-Policy" content="${csp.replace(/&/g, "&amp;").replace(/"/g, "&quot;")}" />
106
+ ` : "";
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} }`;
110
+ return `<!doctype html>
111
+ <html lang="en">
112
+ <head>
113
+ <meta charset="utf-8" />
114
+ <title>${titleEscaped}</title>
115
+ ${cspMeta} <!-- ui-leaf bootstrap -->
116
+ <script>window.__UI_LEAF__ = ${bootstrapValue};</script>
117
+ </head>
118
+ <body>
119
+ <div id="root"></div>
120
+ <script type="module">${safeJs}</script>
121
+ </body>
122
+ </html>`;
123
+ }
124
+ async function compileView(opts) {
125
+ const {
126
+ entry,
127
+ viewsRoot,
128
+ data,
129
+ title = "ui-leaf",
130
+ csp,
131
+ // allowedHosts has no compile-time effect; accepted for API symmetry.
132
+ allowedHosts: _allowedHosts,
133
+ token,
134
+ dataLoader = false
135
+ } = opts;
136
+ const viewsRootAbs = resolve(viewsRoot);
137
+ const hasExt = /\.[a-z]+$/i.test(entry);
138
+ const viewAbs = resolve(viewsRootAbs, hasExt ? entry : `${entry}.tsx`);
139
+ if (!viewAbs.startsWith(viewsRootAbs + sep)) {
140
+ return {
141
+ html: "",
142
+ errors: [
143
+ {
144
+ file: "<unknown>",
145
+ line: 0,
146
+ column: 0,
147
+ message: `ui-leaf: view '${entry}' resolves outside viewsRoot`
148
+ }
149
+ ]
150
+ };
151
+ }
152
+ try {
153
+ await stat(viewAbs);
154
+ } catch {
155
+ return {
156
+ html: "",
157
+ errors: [
158
+ {
159
+ file: viewAbs,
160
+ line: 0,
161
+ column: 0,
162
+ message: `ui-leaf: view '${entry}' not found at ${viewAbs}`
163
+ }
164
+ ]
165
+ };
166
+ }
167
+ const tempDir = await mkdtemp(join(tmpdir(), "ui-leaf-compile-"));
168
+ try {
169
+ const entryPath = join(tempDir, "entry.tsx");
170
+ const entryContent = dataLoader ? `import { createRoot } from "react-dom/client";
171
+ import View from ${JSON.stringify(viewAbs)};
172
+
173
+ const ctx = (globalThis as { __UI_LEAF__?: { token?: string } }).__UI_LEAF__ ?? {};
174
+ const token = ctx.token;
175
+ ${SHARED_BRIDGE}
176
+
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);
184
+ }
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} />);
189
+ }
190
+ bootstrap();
191
+ ` : `import { createRoot } from "react-dom/client";
192
+ import View from ${JSON.stringify(viewAbs)};
193
+
194
+ const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string } }).__UI_LEAF__ ?? {};
195
+ const data = ctx.data;
196
+ const token = ctx.token;
197
+ ${SHARED_BRIDGE}
198
+
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} />);
202
+ `;
203
+ await writeFile(entryPath, entryContent);
204
+ const buildResult = await runBunBuild(entryPath);
205
+ if ("errors" in buildResult) return { html: "", errors: buildResult.errors };
206
+ return {
207
+ html: assembleHtml({ js: buildResult.js, title, csp, data, token, dataLoader }),
208
+ errors: []
209
+ };
210
+ } finally {
211
+ await rm(tempDir, { recursive: true, force: true });
212
+ }
213
+ }
214
+ async function compileSource(opts) {
215
+ const { source, data, title = "ui-leaf", csp, token } = opts;
216
+ const tempDir = await mkdtemp(join(tmpdir(), "ui-leaf-src-"));
217
+ try {
218
+ const viewPath = join(tempDir, "view.tsx");
219
+ const entryPath = join(tempDir, "entry.tsx");
220
+ await writeFile(viewPath, source);
221
+ const entryContent = `import { createRoot } from "react-dom/client";
222
+ import View from ${JSON.stringify(viewPath)};
223
+
224
+ const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string } }).__UI_LEAF__ ?? {};
225
+ const data = ctx.data;
226
+ const token = ctx.token;
227
+ ${SHARED_BRIDGE}
228
+
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} />);
232
+ `;
233
+ await writeFile(entryPath, entryContent);
234
+ const buildResult = await runBunBuild(entryPath);
235
+ if ("errors" in buildResult) return { html: "", errors: buildResult.errors };
236
+ return {
237
+ html: assembleHtml({ js: buildResult.js, title, csp, data, token, dataLoader: false }),
238
+ errors: []
239
+ };
240
+ } finally {
241
+ await rm(tempDir, { recursive: true, force: true });
242
+ }
243
+ }
244
+
245
+ // src/server.ts
246
+ var ORIGINAL_STDOUT_WRITE = process.stdout.write.bind(process.stdout);
247
+ var stdoutRedirectCount = 0;
248
+ function redirectStdoutToStderr() {
249
+ stdoutRedirectCount++;
250
+ if (stdoutRedirectCount === 1) {
251
+ process.stdout.write = ((chunk, enc, cb) => process.stderr.write(chunk, enc, cb));
252
+ }
253
+ let released = false;
254
+ return () => {
255
+ if (released) return;
256
+ released = true;
257
+ stdoutRedirectCount--;
258
+ if (stdoutRedirectCount === 0) {
259
+ process.stdout.write = ORIGINAL_STDOUT_WRITE;
260
+ }
261
+ };
262
+ }
263
+ async function openInAppMode(url) {
264
+ const candidates = [apps.chrome, apps.edge, apps.brave];
265
+ for (const app of candidates) {
266
+ try {
267
+ await open(url, { app: { name: app, arguments: [`--app=${url}`] } });
268
+ return true;
269
+ } catch {
270
+ }
271
+ }
272
+ return false;
273
+ }
274
+ var STRICT_CSP = [
275
+ "default-src 'self'",
276
+ "connect-src 'self'",
277
+ "img-src 'self' data: https:",
278
+ "font-src 'self' https: data:",
279
+ "style-src 'self' 'unsafe-inline'",
280
+ "script-src 'self' 'unsafe-inline'"
281
+ ].join("; ");
282
+ function resolveCsp(opt) {
283
+ if (!opt || opt === "off") return null;
284
+ if (opt === "strict") return STRICT_CSP;
285
+ return opt;
286
+ }
287
+ function timingSafeEqual(a, b) {
288
+ if (a.length !== b.length) return false;
289
+ return nodeTimingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
290
+ }
291
+ var DEFAULT_LOOPBACK_HOSTNAMES = ["127.0.0.1", "localhost", "::1"];
292
+ function parseHostHeader(value) {
293
+ const trimmed = value.trim();
294
+ if (trimmed === "") return null;
295
+ if (trimmed.startsWith("[")) {
296
+ const close = trimmed.indexOf("]");
297
+ if (close === -1) return null;
298
+ return trimmed.slice(1, close).toLowerCase();
299
+ }
300
+ const colon = trimmed.indexOf(":");
301
+ return (colon === -1 ? trimmed : trimmed.slice(0, colon)).toLowerCase();
302
+ }
303
+ function isAllowedHost(value, allowed) {
304
+ const host = value === void 0 ? null : parseHostHeader(value);
305
+ return host !== null && allowed.has(host);
306
+ }
307
+ function isAllowedOrigin(value, allowed) {
308
+ if (value === void 0 || value === "" || value === "null") return true;
309
+ try {
310
+ let hostname = new URL(value).hostname.toLowerCase();
311
+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
312
+ hostname = hostname.slice(1, -1);
313
+ }
314
+ return allowed.has(hostname);
315
+ } catch {
316
+ return false;
317
+ }
318
+ }
319
+ async function startDevServer(opts) {
320
+ const {
321
+ view,
322
+ data,
323
+ dataLoader,
324
+ viewsRoot,
325
+ mutations = {},
326
+ title = "ui-leaf",
327
+ port,
328
+ openBrowser = true,
329
+ shell = "tab",
330
+ heartbeatTimeoutMs = 75e3,
331
+ startupGraceMs = 3e4,
332
+ csp,
333
+ allowedHosts,
334
+ silent = false,
335
+ _opener
336
+ } = opts;
337
+ const cspHeader = resolveCsp(csp);
338
+ const allowedHostSet = new Set(DEFAULT_LOOPBACK_HOSTNAMES);
339
+ for (const h of allowedHosts ?? []) allowedHostSet.add(h.toLowerCase());
340
+ const allowedHostList = [...allowedHostSet].join(", ");
341
+ const restoreStdout = silent ? redirectStdoutToStderr() : null;
342
+ try {
343
+ let fireEvent2 = function(event) {
344
+ for (const fn of listeners.get(event)) fn();
345
+ };
346
+ var fireEvent = fireEvent2;
347
+ if (view.includes("/") || view.includes("\\")) {
348
+ throw new Error(
349
+ `ui-leaf: view '${view}' must be a bare identifier with no path separators`
350
+ );
351
+ }
352
+ if (data !== void 0 && dataLoader) {
353
+ throw new Error("ui-leaf: pass data or dataLoader, not both");
354
+ }
355
+ const token = randomBytes(32).toString("hex");
356
+ let loadedData;
357
+ if (dataLoader) {
358
+ loadedData = await dataLoader();
359
+ }
360
+ const result = await compileView({
361
+ entry: view,
362
+ viewsRoot,
363
+ data: dataLoader ? null : data,
364
+ title,
365
+ csp: cspHeader ?? void 0,
366
+ token,
367
+ dataLoader: !!dataLoader
368
+ });
369
+ if (result.errors.length > 0) {
370
+ const msg = result.errors.map((e) => e.message).join("; ");
371
+ throw new Error(`ui-leaf: view compilation failed: ${msg}`);
372
+ }
373
+ const viewState = { html: result.html, data: dataLoader ? loadedData : data };
374
+ const listeners = /* @__PURE__ */ new Map([
375
+ ["data-updated", /* @__PURE__ */ new Set()],
376
+ ["view-swapped", /* @__PURE__ */ new Set()]
377
+ ]);
378
+ let lastHeartbeatAt = Date.now();
379
+ let closeRequested = false;
380
+ let resolveClosed = () => {
381
+ };
382
+ const closed = new Promise((r) => {
383
+ resolveClosed = r;
384
+ });
385
+ const bunPort = port === void 0 ? 5810 : port;
386
+ 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.
397
+ `,
398
+ { status: 403, headers: { "Content-Type": "text/plain; charset=utf-8" } }
399
+ );
400
+ }
401
+ const headers = {};
402
+ if (cspHeader) {
403
+ headers["Content-Security-Policy"] = cspHeader;
404
+ }
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" }
412
+ });
413
+ }
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 });
423
+ }
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);
432
+ }
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" }
438
+ });
439
+ }
440
+ if (!checkAuth(req, token)) {
441
+ return new Response(JSON.stringify({ error: "unauthorized" }), {
442
+ status: 401,
443
+ headers: { ...headers, "Content-Type": "application/json" }
444
+ });
445
+ }
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
+ });
455
+ };
456
+ if (bunPort === 0) {
457
+ return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler });
458
+ }
459
+ const MAX_PORT_ATTEMPTS = 10;
460
+ for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) {
461
+ try {
462
+ return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler });
463
+ } catch (err) {
464
+ const isAddrinuse = err instanceof Error && err.message.includes("EADDRINUSE");
465
+ if (!isAddrinuse || i === MAX_PORT_ATTEMPTS - 1) {
466
+ if (isAddrinuse) {
467
+ throw new Error(
468
+ `ui-leaf: ports ${bunPort}\u2013${bunPort + MAX_PORT_ATTEMPTS - 1} are all in use. Pass { port: 0 } to mount() for an OS-assigned port.`
469
+ );
470
+ }
471
+ throw err;
472
+ }
473
+ }
474
+ }
475
+ throw new Error("unreachable");
476
+ })();
477
+ actualPort = server.port ?? bunPort;
478
+ const url = `http://127.0.0.1:${actualPort}`;
479
+ 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
+ heartbeatWatcher = setInterval(() => {
490
+ const now = Date.now();
491
+ if (now - startedAt < startupGraceMs) return;
492
+ if (now - lastHeartbeatAt > heartbeatTimeoutMs) {
493
+ void cleanup();
494
+ }
495
+ }, 1e3);
496
+ const doOpen = _opener ? () => _opener(url) : async () => {
497
+ if (shell === "app") {
498
+ const launched = await openInAppMode(url);
499
+ if (!launched) {
500
+ process.stderr.write(
501
+ `ui-leaf: shell:"app" requested but no Chromium browser found; falling back to default browser tab.
502
+ `
503
+ );
504
+ await open(url);
505
+ }
506
+ } else {
507
+ await open(url);
508
+ }
509
+ };
510
+ if (openBrowser) {
511
+ await doOpen();
512
+ }
513
+ return {
514
+ url,
515
+ port: actualPort,
516
+ closed,
517
+ close: cleanup,
518
+ on(event, listener) {
519
+ listeners.get(event)?.add(listener);
520
+ },
521
+ off(event, listener) {
522
+ listeners.get(event)?.delete(listener);
523
+ },
524
+ update(newData) {
525
+ viewState.data = newData;
526
+ fireEvent2("data-updated");
527
+ },
528
+ async swapView(source) {
529
+ const r = await compileSource({
530
+ source,
531
+ data: viewState.data,
532
+ title,
533
+ csp: cspHeader ?? void 0,
534
+ token
535
+ });
536
+ if (r.errors.length > 0) return r.errors;
537
+ viewState.html = r.html;
538
+ fireEvent2("view-swapped");
539
+ return [];
540
+ },
541
+ async patch(newData, source) {
542
+ const r = await compileSource({
543
+ source,
544
+ data: newData,
545
+ title,
546
+ csp: cspHeader ?? void 0,
547
+ token
548
+ });
549
+ if (r.errors.length > 0) return r.errors;
550
+ viewState.data = newData;
551
+ viewState.html = r.html;
552
+ fireEvent2("data-updated");
553
+ fireEvent2("view-swapped");
554
+ return [];
555
+ },
556
+ async reopen() {
557
+ await doOpen();
558
+ }
559
+ };
560
+ } catch (err) {
561
+ restoreStdout?.();
562
+ throw err;
563
+ }
564
+ }
565
+ 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);
570
+ }
571
+ async function handleMutate(req, mutations, headers) {
572
+ const contentLength = req.headers.get("content-length");
573
+ if (contentLength && Number.parseInt(contentLength, 10) > 1024 * 1024) {
574
+ return new Response(JSON.stringify({ error: "request body exceeds 1 MiB limit" }), {
575
+ status: 400,
576
+ headers: { ...headers, "Content-Type": "application/json" }
577
+ });
578
+ }
579
+ let body;
580
+ try {
581
+ const text = await req.text();
582
+ if (text.length > 1024 * 1024) {
583
+ return new Response(JSON.stringify({ error: "request body exceeds 1 MiB limit" }), {
584
+ status: 400,
585
+ headers: { ...headers, "Content-Type": "application/json" }
586
+ });
587
+ }
588
+ body = text ? JSON.parse(text) : void 0;
589
+ } catch (err) {
590
+ return new Response(
591
+ JSON.stringify({ error: err instanceof Error ? err.message : "bad request" }),
592
+ { status: 400, headers: { ...headers, "Content-Type": "application/json" } }
593
+ );
594
+ }
595
+ const name = body?.name;
596
+ if (typeof name !== "string" || name.length === 0) {
597
+ return new Response(JSON.stringify({ error: "missing mutation name" }), {
598
+ status: 400,
599
+ headers: { ...headers, "Content-Type": "application/json" }
600
+ });
601
+ }
602
+ if (!Object.hasOwn(mutations, name)) {
603
+ return new Response(
604
+ JSON.stringify({
605
+ error: `ui-leaf: no mutation handler registered for '${name}'. Add it to the mutations: { } map passed to mount().`
606
+ }),
607
+ { status: 404, headers: { ...headers, "Content-Type": "application/json" } }
608
+ );
609
+ }
610
+ const handler = mutations[name];
611
+ try {
612
+ const result = await handler(body.args);
613
+ return new Response(JSON.stringify(result ?? null), {
614
+ status: 200,
615
+ headers: { ...headers, "Content-Type": "application/json" }
616
+ });
617
+ } catch (err) {
618
+ return new Response(
619
+ JSON.stringify({ error: err instanceof Error ? err.message : String(err) }),
620
+ { status: 500, headers: { ...headers, "Content-Type": "application/json" } }
621
+ );
622
+ }
623
+ }
624
+
625
+ // src/index.ts
626
+ async function mount(opts) {
627
+ const viewsRoot = opts.viewsRoot ?? resolve2(process.cwd(), "views");
628
+ const server = await startDevServer({
629
+ view: opts.view,
630
+ data: opts.data,
631
+ dataLoader: opts.dataLoader,
632
+ viewsRoot,
633
+ mutations: opts.mutations,
634
+ title: opts.title,
635
+ port: opts.port,
636
+ openBrowser: opts.openBrowser,
637
+ shell: opts.shell,
638
+ heartbeatTimeoutMs: opts.heartbeatTimeoutMs,
639
+ startupGraceMs: opts.startupGraceMs,
640
+ csp: opts.csp,
641
+ allowedHosts: opts.allowedHosts,
642
+ silent: opts.silent
643
+ });
644
+ const onSignal = (signal) => {
645
+ void (async () => {
646
+ await server.close();
647
+ process.kill(process.pid, signal);
648
+ })();
649
+ };
650
+ const sigint = () => onSignal("SIGINT");
651
+ const sigterm = () => onSignal("SIGTERM");
652
+ process.once("SIGINT", sigint);
653
+ process.once("SIGTERM", sigterm);
654
+ if (opts.signal) {
655
+ if (opts.signal.aborted) {
656
+ process.off("SIGINT", sigint);
657
+ process.off("SIGTERM", sigterm);
658
+ await server.close();
659
+ return {
660
+ url: server.url,
661
+ port: server.port,
662
+ closed: Promise.resolve(),
663
+ close: server.close,
664
+ update: server.update.bind(server),
665
+ swapView: (source) => server.swapView(source),
666
+ patch: (data, source) => server.patch(data, source),
667
+ reopen: server.reopen.bind(server),
668
+ on: server.on.bind(server),
669
+ off: server.off.bind(server)
670
+ };
671
+ }
672
+ opts.signal.addEventListener(
673
+ "abort",
674
+ () => void server.close(),
675
+ { once: true }
676
+ );
677
+ }
678
+ const closed = server.closed.finally(() => {
679
+ process.off("SIGINT", sigint);
680
+ process.off("SIGTERM", sigterm);
681
+ });
682
+ return {
683
+ url: server.url,
684
+ port: server.port,
685
+ closed,
686
+ close: server.close,
687
+ update: server.update.bind(server),
688
+ swapView: (source) => server.swapView(source),
689
+ patch: (data, source) => server.patch(data, source),
690
+ reopen: server.reopen.bind(server),
691
+ on: server.on.bind(server),
692
+ off: server.off.bind(server)
693
+ };
694
+ }
695
+ export {
696
+ mount
697
+ };
698
+ //# sourceMappingURL=index.js.map