@openthink/ui-leaf 0.4.0 → 0.6.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/README.md +43 -19
- package/package.json +14 -17
- package/packages/cli/dist/cli.js +986 -0
- package/packages/cli/dist/cli.js.map +1 -0
- package/{dist → packages/cli/dist}/index.d.ts +88 -57
- package/packages/cli/dist/index.js +721 -0
- package/packages/cli/dist/index.js.map +1 -0
- package/packages/cli/dist/server-3vbR-tuu.d.ts +7 -0
- package/{dist → packages/cli/dist}/view.d.ts +1 -1
- package/packages/cli/package.json +22 -0
- package/dist/cli.js +0 -725
- package/dist/cli.js.map +0 -1
- package/dist/dev-server-DapOoULX.d.ts +0 -5
- package/dist/index.js +0 -556
- package/dist/index.js.map +0 -1
- /package/{dist → packages/cli/dist}/cli.d.ts +0 -0
- /package/{dist → packages/cli/dist}/view.js +0 -0
- /package/{dist → packages/cli/dist}/view.js.map +0 -0
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { createInterface } from "readline";
|
|
5
|
+
|
|
6
|
+
// src/index.ts
|
|
7
|
+
import { resolve as resolve2 } from "path";
|
|
8
|
+
|
|
9
|
+
// src/server.ts
|
|
10
|
+
import { randomBytes, timingSafeEqual as nodeTimingSafeEqual } from "crypto";
|
|
11
|
+
import open, { apps } from "open";
|
|
12
|
+
|
|
13
|
+
// src/compile.ts
|
|
14
|
+
import { createRequire } from "module";
|
|
15
|
+
import { tmpdir } from "os";
|
|
16
|
+
import { join, resolve, sep } from "path";
|
|
17
|
+
import { mkdtemp, rm, stat, writeFile } from "fs/promises";
|
|
18
|
+
|
|
19
|
+
// src/internal/html.ts
|
|
20
|
+
function escapeForScriptTag(json) {
|
|
21
|
+
return json.replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/compile.ts
|
|
25
|
+
var requireFromHere = createRequire(import.meta.url);
|
|
26
|
+
var reactAliasPlugin = {
|
|
27
|
+
name: "ui-leaf-react-alias",
|
|
28
|
+
setup(build) {
|
|
29
|
+
build.onResolve({ filter: /^react($|\/|-dom($|\/))/ }, (args2) => {
|
|
30
|
+
try {
|
|
31
|
+
return { path: requireFromHere.resolve(args2.path) };
|
|
32
|
+
} catch {
|
|
33
|
+
return {
|
|
34
|
+
path: args2.path,
|
|
35
|
+
errors: [{ text: `ui-leaf: failed to resolve ${args2.path}` }]
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var SHARED_BRIDGE = `
|
|
42
|
+
async function mutate(name: string, args?: unknown): Promise<unknown> {
|
|
43
|
+
const res = await fetch("/mutate", {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: {
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
...(token ? { Authorization: "Bearer " + token } : {}),
|
|
48
|
+
},
|
|
49
|
+
body: JSON.stringify({ name, args }),
|
|
50
|
+
});
|
|
51
|
+
const text = await res.text().catch(() => "");
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
let detail = text;
|
|
54
|
+
try {
|
|
55
|
+
const parsed: unknown = text ? JSON.parse(text) : null;
|
|
56
|
+
if (parsed !== null && typeof parsed === "object" && "error" in parsed && typeof (parsed as { error: unknown }).error === "string") {
|
|
57
|
+
detail = (parsed as { error: string }).error;
|
|
58
|
+
}
|
|
59
|
+
} catch { /* keep raw text */ }
|
|
60
|
+
throw new Error("ui-leaf: mutation '" + name + "' failed (" + res.status + "): " + detail);
|
|
61
|
+
}
|
|
62
|
+
return text ? JSON.parse(text) : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function heartbeat(): Promise<void> {
|
|
66
|
+
try {
|
|
67
|
+
await fetch("/heartbeat", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: token ? { Authorization: "Bearer " + token } : {},
|
|
70
|
+
});
|
|
71
|
+
} catch { /* server may have shut down; ignore */ }
|
|
72
|
+
}
|
|
73
|
+
setInterval(heartbeat, 5000);
|
|
74
|
+
heartbeat();`;
|
|
75
|
+
async function runBunBuild(entryPath) {
|
|
76
|
+
let buildOutput;
|
|
77
|
+
try {
|
|
78
|
+
buildOutput = await Bun.build({
|
|
79
|
+
entrypoints: [entryPath],
|
|
80
|
+
target: "browser",
|
|
81
|
+
format: "esm",
|
|
82
|
+
minify: false,
|
|
83
|
+
sourcemap: "none",
|
|
84
|
+
plugins: [reactAliasPlugin]
|
|
85
|
+
});
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (err instanceof AggregateError) {
|
|
88
|
+
const errors = err.errors.map((e) => ({
|
|
89
|
+
file: e.position?.file ?? "<unknown>",
|
|
90
|
+
line: e.position?.line ?? 0,
|
|
91
|
+
column: e.position?.column ?? 0,
|
|
92
|
+
message: e.message
|
|
93
|
+
}));
|
|
94
|
+
return { errors };
|
|
95
|
+
}
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
const output = buildOutput.outputs[0];
|
|
99
|
+
if (!output) {
|
|
100
|
+
return {
|
|
101
|
+
errors: [{ file: "<unknown>", line: 0, column: 0, message: "ui-leaf: Bun.build produced no output" }]
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { js: await output.text() };
|
|
105
|
+
}
|
|
106
|
+
function assembleHtml(opts) {
|
|
107
|
+
const { js, title, csp, data, token, dataLoader } = opts;
|
|
108
|
+
const safeJs = js.replace(/<\/script>/gi, "<\\/script>");
|
|
109
|
+
const titleEscaped = title.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
110
|
+
const cspMeta = csp ? ` <meta http-equiv="Content-Security-Policy" content="${csp.replace(/&/g, "&").replace(/"/g, """)}" />
|
|
111
|
+
` : "";
|
|
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} }`;
|
|
115
|
+
return `<!doctype html>
|
|
116
|
+
<html lang="en">
|
|
117
|
+
<head>
|
|
118
|
+
<meta charset="utf-8" />
|
|
119
|
+
<title>${titleEscaped}</title>
|
|
120
|
+
${cspMeta} <!-- ui-leaf bootstrap -->
|
|
121
|
+
<script>window.__UI_LEAF__ = ${bootstrapValue};</script>
|
|
122
|
+
</head>
|
|
123
|
+
<body>
|
|
124
|
+
<div id="root"></div>
|
|
125
|
+
<script type="module">${safeJs}</script>
|
|
126
|
+
</body>
|
|
127
|
+
</html>`;
|
|
128
|
+
}
|
|
129
|
+
async function compileView(opts) {
|
|
130
|
+
const {
|
|
131
|
+
entry,
|
|
132
|
+
viewsRoot,
|
|
133
|
+
data,
|
|
134
|
+
title = "ui-leaf",
|
|
135
|
+
csp,
|
|
136
|
+
// allowedHosts has no compile-time effect; accepted for API symmetry.
|
|
137
|
+
allowedHosts: _allowedHosts,
|
|
138
|
+
token,
|
|
139
|
+
dataLoader = false
|
|
140
|
+
} = opts;
|
|
141
|
+
const viewsRootAbs = resolve(viewsRoot);
|
|
142
|
+
const hasExt = /\.[a-z]+$/i.test(entry);
|
|
143
|
+
const viewAbs = resolve(viewsRootAbs, hasExt ? entry : `${entry}.tsx`);
|
|
144
|
+
if (!viewAbs.startsWith(viewsRootAbs + sep)) {
|
|
145
|
+
return {
|
|
146
|
+
html: "",
|
|
147
|
+
errors: [
|
|
148
|
+
{
|
|
149
|
+
file: "<unknown>",
|
|
150
|
+
line: 0,
|
|
151
|
+
column: 0,
|
|
152
|
+
message: `ui-leaf: view '${entry}' resolves outside viewsRoot`
|
|
153
|
+
}
|
|
154
|
+
]
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
await stat(viewAbs);
|
|
159
|
+
} catch {
|
|
160
|
+
return {
|
|
161
|
+
html: "",
|
|
162
|
+
errors: [
|
|
163
|
+
{
|
|
164
|
+
file: viewAbs,
|
|
165
|
+
line: 0,
|
|
166
|
+
column: 0,
|
|
167
|
+
message: `ui-leaf: view '${entry}' not found at ${viewAbs}`
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const tempDir = await mkdtemp(join(tmpdir(), "ui-leaf-compile-"));
|
|
173
|
+
try {
|
|
174
|
+
const entryPath = join(tempDir, "entry.tsx");
|
|
175
|
+
const entryContent = dataLoader ? `import { createRoot } from "react-dom/client";
|
|
176
|
+
import View from ${JSON.stringify(viewAbs)};
|
|
177
|
+
|
|
178
|
+
const ctx = (globalThis as { __UI_LEAF__?: { token?: string } }).__UI_LEAF__ ?? {};
|
|
179
|
+
const token = ctx.token;
|
|
180
|
+
${SHARED_BRIDGE}
|
|
181
|
+
|
|
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);
|
|
189
|
+
}
|
|
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} />);
|
|
194
|
+
}
|
|
195
|
+
bootstrap();
|
|
196
|
+
` : `import { createRoot } from "react-dom/client";
|
|
197
|
+
import View from ${JSON.stringify(viewAbs)};
|
|
198
|
+
|
|
199
|
+
const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string } }).__UI_LEAF__ ?? {};
|
|
200
|
+
const data = ctx.data;
|
|
201
|
+
const token = ctx.token;
|
|
202
|
+
${SHARED_BRIDGE}
|
|
203
|
+
|
|
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} />);
|
|
207
|
+
`;
|
|
208
|
+
await writeFile(entryPath, entryContent);
|
|
209
|
+
const buildResult = await runBunBuild(entryPath);
|
|
210
|
+
if ("errors" in buildResult) return { html: "", errors: buildResult.errors };
|
|
211
|
+
return {
|
|
212
|
+
html: assembleHtml({ js: buildResult.js, title, csp, data, token, dataLoader }),
|
|
213
|
+
errors: []
|
|
214
|
+
};
|
|
215
|
+
} finally {
|
|
216
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function compileSource(opts) {
|
|
220
|
+
const { source, data, title = "ui-leaf", csp, token } = opts;
|
|
221
|
+
const tempDir = await mkdtemp(join(tmpdir(), "ui-leaf-src-"));
|
|
222
|
+
try {
|
|
223
|
+
const viewPath = join(tempDir, "view.tsx");
|
|
224
|
+
const entryPath = join(tempDir, "entry.tsx");
|
|
225
|
+
await writeFile(viewPath, source);
|
|
226
|
+
const entryContent = `import { createRoot } from "react-dom/client";
|
|
227
|
+
import View from ${JSON.stringify(viewPath)};
|
|
228
|
+
|
|
229
|
+
const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string } }).__UI_LEAF__ ?? {};
|
|
230
|
+
const data = ctx.data;
|
|
231
|
+
const token = ctx.token;
|
|
232
|
+
${SHARED_BRIDGE}
|
|
233
|
+
|
|
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} />);
|
|
237
|
+
`;
|
|
238
|
+
await writeFile(entryPath, entryContent);
|
|
239
|
+
const buildResult = await runBunBuild(entryPath);
|
|
240
|
+
if ("errors" in buildResult) return { html: "", errors: buildResult.errors };
|
|
241
|
+
return {
|
|
242
|
+
html: assembleHtml({ js: buildResult.js, title, csp, data, token, dataLoader: false }),
|
|
243
|
+
errors: []
|
|
244
|
+
};
|
|
245
|
+
} finally {
|
|
246
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/server.ts
|
|
251
|
+
var ORIGINAL_STDOUT_WRITE = process.stdout.write.bind(process.stdout);
|
|
252
|
+
var stdoutRedirectCount = 0;
|
|
253
|
+
function redirectStdoutToStderr() {
|
|
254
|
+
stdoutRedirectCount++;
|
|
255
|
+
if (stdoutRedirectCount === 1) {
|
|
256
|
+
process.stdout.write = ((chunk, enc, cb) => process.stderr.write(chunk, enc, cb));
|
|
257
|
+
}
|
|
258
|
+
let released = false;
|
|
259
|
+
return () => {
|
|
260
|
+
if (released) return;
|
|
261
|
+
released = true;
|
|
262
|
+
stdoutRedirectCount--;
|
|
263
|
+
if (stdoutRedirectCount === 0) {
|
|
264
|
+
process.stdout.write = ORIGINAL_STDOUT_WRITE;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
async function openInAppMode(url) {
|
|
269
|
+
const candidates = [apps.chrome, apps.edge, apps.brave];
|
|
270
|
+
for (const app of candidates) {
|
|
271
|
+
try {
|
|
272
|
+
await open(url, { app: { name: app, arguments: [`--app=${url}`] } });
|
|
273
|
+
return true;
|
|
274
|
+
} catch {
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
var STRICT_CSP = [
|
|
280
|
+
"default-src 'self'",
|
|
281
|
+
"connect-src 'self'",
|
|
282
|
+
"img-src 'self' data: https:",
|
|
283
|
+
"font-src 'self' https: data:",
|
|
284
|
+
"style-src 'self' 'unsafe-inline'",
|
|
285
|
+
"script-src 'self' 'unsafe-inline'"
|
|
286
|
+
].join("; ");
|
|
287
|
+
function resolveCsp(opt) {
|
|
288
|
+
if (!opt || opt === "off") return null;
|
|
289
|
+
if (opt === "strict") return STRICT_CSP;
|
|
290
|
+
return opt;
|
|
291
|
+
}
|
|
292
|
+
function timingSafeEqual(a, b) {
|
|
293
|
+
if (a.length !== b.length) return false;
|
|
294
|
+
return nodeTimingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
295
|
+
}
|
|
296
|
+
var DEFAULT_LOOPBACK_HOSTNAMES = ["127.0.0.1", "localhost", "::1"];
|
|
297
|
+
function parseHostHeader(value) {
|
|
298
|
+
const trimmed = value.trim();
|
|
299
|
+
if (trimmed === "") return null;
|
|
300
|
+
if (trimmed.startsWith("[")) {
|
|
301
|
+
const close = trimmed.indexOf("]");
|
|
302
|
+
if (close === -1) return null;
|
|
303
|
+
return trimmed.slice(1, close).toLowerCase();
|
|
304
|
+
}
|
|
305
|
+
const colon = trimmed.indexOf(":");
|
|
306
|
+
return (colon === -1 ? trimmed : trimmed.slice(0, colon)).toLowerCase();
|
|
307
|
+
}
|
|
308
|
+
function isAllowedHost(value, allowed) {
|
|
309
|
+
const host = value === void 0 ? null : parseHostHeader(value);
|
|
310
|
+
return host !== null && allowed.has(host);
|
|
311
|
+
}
|
|
312
|
+
function isAllowedOrigin(value, allowed) {
|
|
313
|
+
if (value === void 0 || value === "" || value === "null") return true;
|
|
314
|
+
try {
|
|
315
|
+
let hostname = new URL(value).hostname.toLowerCase();
|
|
316
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
317
|
+
hostname = hostname.slice(1, -1);
|
|
318
|
+
}
|
|
319
|
+
return allowed.has(hostname);
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async function startDevServer(opts) {
|
|
325
|
+
const {
|
|
326
|
+
view,
|
|
327
|
+
data,
|
|
328
|
+
dataLoader,
|
|
329
|
+
viewsRoot,
|
|
330
|
+
mutations = {},
|
|
331
|
+
title = "ui-leaf",
|
|
332
|
+
port,
|
|
333
|
+
openBrowser = true,
|
|
334
|
+
shell = "tab",
|
|
335
|
+
heartbeatTimeoutMs = 75e3,
|
|
336
|
+
startupGraceMs = 3e4,
|
|
337
|
+
csp,
|
|
338
|
+
allowedHosts,
|
|
339
|
+
silent = false,
|
|
340
|
+
_opener,
|
|
341
|
+
_heartbeatCheckIntervalMs = 1e3
|
|
342
|
+
} = opts;
|
|
343
|
+
const cspHeader = resolveCsp(csp);
|
|
344
|
+
const allowedHostSet = new Set(DEFAULT_LOOPBACK_HOSTNAMES);
|
|
345
|
+
for (const h of allowedHosts ?? []) allowedHostSet.add(h.toLowerCase());
|
|
346
|
+
const allowedHostList = [...allowedHostSet].join(", ");
|
|
347
|
+
const restoreStdout = silent ? redirectStdoutToStderr() : null;
|
|
348
|
+
try {
|
|
349
|
+
let fireEvent2 = function(event) {
|
|
350
|
+
for (const fn of listeners.get(event)) fn();
|
|
351
|
+
};
|
|
352
|
+
var fireEvent = fireEvent2;
|
|
353
|
+
if (view.includes("/") || view.includes("\\")) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`ui-leaf: view '${view}' must be a bare identifier with no path separators`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (data !== void 0 && dataLoader) {
|
|
359
|
+
throw new Error("ui-leaf: pass data or dataLoader, not both");
|
|
360
|
+
}
|
|
361
|
+
const token = randomBytes(32).toString("hex");
|
|
362
|
+
let loadedData;
|
|
363
|
+
if (dataLoader) {
|
|
364
|
+
loadedData = await dataLoader();
|
|
365
|
+
}
|
|
366
|
+
const result = await compileView({
|
|
367
|
+
entry: view,
|
|
368
|
+
viewsRoot,
|
|
369
|
+
data: dataLoader ? null : data,
|
|
370
|
+
title,
|
|
371
|
+
csp: cspHeader ?? void 0,
|
|
372
|
+
token,
|
|
373
|
+
dataLoader: !!dataLoader
|
|
374
|
+
});
|
|
375
|
+
if (result.errors.length > 0) {
|
|
376
|
+
const msg = result.errors.map((e) => e.message).join("; ");
|
|
377
|
+
throw new Error(`ui-leaf: view compilation failed: ${msg}`);
|
|
378
|
+
}
|
|
379
|
+
const viewState = { html: result.html, data: dataLoader ? loadedData : data };
|
|
380
|
+
const listeners = /* @__PURE__ */ new Map([
|
|
381
|
+
["data-updated", /* @__PURE__ */ new Set()],
|
|
382
|
+
["view-swapped", /* @__PURE__ */ new Set()],
|
|
383
|
+
["disconnected", /* @__PURE__ */ new Set()],
|
|
384
|
+
["reconnected", /* @__PURE__ */ new Set()]
|
|
385
|
+
]);
|
|
386
|
+
let lastHeartbeatAt = Date.now();
|
|
387
|
+
let closeRequested = false;
|
|
388
|
+
let connectionState = "connecting";
|
|
389
|
+
let resolveClosed = () => {
|
|
390
|
+
};
|
|
391
|
+
const closed = new Promise((r) => {
|
|
392
|
+
resolveClosed = r;
|
|
393
|
+
});
|
|
394
|
+
const bunPort = port === void 0 ? 5810 : port;
|
|
395
|
+
let actualPort = bunPort;
|
|
396
|
+
const handler = (req) => {
|
|
397
|
+
const host = req.headers.get("host") ?? void 0;
|
|
398
|
+
const origin = req.headers.get("origin") ?? void 0;
|
|
399
|
+
const hostOk = isAllowedHost(host, allowedHostSet);
|
|
400
|
+
const originOk = isAllowedOrigin(origin, allowedHostSet);
|
|
401
|
+
if (!hostOk || !originOk) {
|
|
402
|
+
const offender = !hostOk ? `Host "${host ?? "(absent)"}"` : `Origin "${origin}"`;
|
|
403
|
+
return new Response(
|
|
404
|
+
`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.
|
|
405
|
+
`,
|
|
406
|
+
{ status: 403, headers: { "Content-Type": "text/plain; charset=utf-8" } }
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
const headers = {};
|
|
410
|
+
if (cspHeader) {
|
|
411
|
+
headers["Content-Security-Policy"] = cspHeader;
|
|
412
|
+
}
|
|
413
|
+
const url2 = new URL(req.url);
|
|
414
|
+
const path = url2.pathname;
|
|
415
|
+
const method = req.method;
|
|
416
|
+
if (method === "GET" && path === "/") {
|
|
417
|
+
return new Response(viewState.html, {
|
|
418
|
+
status: 200,
|
|
419
|
+
headers: { ...headers, "Content-Type": "text/html; charset=utf-8" }
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
if (method === "POST" && path === "/heartbeat") {
|
|
423
|
+
if (!checkAuth(req, token)) {
|
|
424
|
+
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
425
|
+
status: 401,
|
|
426
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
lastHeartbeatAt = Date.now();
|
|
430
|
+
if (connectionState === "disconnected") {
|
|
431
|
+
connectionState = "connected";
|
|
432
|
+
fireEvent2("reconnected");
|
|
433
|
+
} else if (connectionState === "connecting") {
|
|
434
|
+
connectionState = "connected";
|
|
435
|
+
}
|
|
436
|
+
return new Response("", { status: 204, headers });
|
|
437
|
+
}
|
|
438
|
+
if (method === "POST" && path === "/mutate") {
|
|
439
|
+
if (!checkAuth(req, token)) {
|
|
440
|
+
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
441
|
+
status: 401,
|
|
442
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
return handleMutate(req, mutations, headers);
|
|
446
|
+
}
|
|
447
|
+
if (method === "GET" && path === "/api/data") {
|
|
448
|
+
if (!dataLoader) {
|
|
449
|
+
return new Response(JSON.stringify({ error: "not found" }), {
|
|
450
|
+
status: 404,
|
|
451
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
if (!checkAuth(req, token)) {
|
|
455
|
+
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
456
|
+
status: 401,
|
|
457
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return new Response(JSON.stringify(viewState.data !== void 0 ? viewState.data : null), {
|
|
461
|
+
status: 200,
|
|
462
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
return new Response(JSON.stringify({ error: "not found" }), {
|
|
466
|
+
status: 404,
|
|
467
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
468
|
+
});
|
|
469
|
+
};
|
|
470
|
+
let heartbeatWatcher;
|
|
471
|
+
let bunServer;
|
|
472
|
+
const cleanup = async (reason) => {
|
|
473
|
+
if (closeRequested) return;
|
|
474
|
+
closeRequested = true;
|
|
475
|
+
if (heartbeatWatcher) clearInterval(heartbeatWatcher);
|
|
476
|
+
await bunServer.stop(true);
|
|
477
|
+
if (restoreStdout) restoreStdout();
|
|
478
|
+
resolveClosed(reason);
|
|
479
|
+
};
|
|
480
|
+
const serverErrorHandler = (_err) => {
|
|
481
|
+
void cleanup("error");
|
|
482
|
+
return new Response(JSON.stringify({ error: "internal server error" }), {
|
|
483
|
+
status: 500,
|
|
484
|
+
headers: { "Content-Type": "application/json" }
|
|
485
|
+
});
|
|
486
|
+
};
|
|
487
|
+
bunServer = (() => {
|
|
488
|
+
if (bunPort === 0) {
|
|
489
|
+
return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler, error: serverErrorHandler });
|
|
490
|
+
}
|
|
491
|
+
const MAX_PORT_ATTEMPTS = 10;
|
|
492
|
+
for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) {
|
|
493
|
+
try {
|
|
494
|
+
return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler, error: serverErrorHandler });
|
|
495
|
+
} catch (err) {
|
|
496
|
+
const isAddrinuse = err instanceof Error && err.message.includes("EADDRINUSE");
|
|
497
|
+
if (!isAddrinuse || i === MAX_PORT_ATTEMPTS - 1) {
|
|
498
|
+
if (isAddrinuse) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`ui-leaf: ports ${bunPort}\u2013${bunPort + MAX_PORT_ATTEMPTS - 1} are all in use. Pass { port: 0 } to mount() for an OS-assigned port.`
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
throw err;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
throw new Error("unreachable");
|
|
508
|
+
})();
|
|
509
|
+
actualPort = bunServer.port ?? bunPort;
|
|
510
|
+
const url = `http://127.0.0.1:${actualPort}`;
|
|
511
|
+
const startedAt = Date.now();
|
|
512
|
+
heartbeatWatcher = setInterval(() => {
|
|
513
|
+
if (closeRequested) return;
|
|
514
|
+
const now = Date.now();
|
|
515
|
+
if (now - startedAt < startupGraceMs) return;
|
|
516
|
+
if (now - lastHeartbeatAt > heartbeatTimeoutMs) {
|
|
517
|
+
if (connectionState !== "disconnected") {
|
|
518
|
+
connectionState = "disconnected";
|
|
519
|
+
fireEvent2("disconnected");
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}, _heartbeatCheckIntervalMs);
|
|
523
|
+
const doOpen = _opener ? () => _opener(url) : async () => {
|
|
524
|
+
if (shell === "app") {
|
|
525
|
+
const launched = await openInAppMode(url);
|
|
526
|
+
if (!launched) {
|
|
527
|
+
process.stderr.write(
|
|
528
|
+
`ui-leaf: shell:"app" requested but no Chromium browser found; falling back to default browser tab.
|
|
529
|
+
`
|
|
530
|
+
);
|
|
531
|
+
await open(url);
|
|
532
|
+
}
|
|
533
|
+
} else {
|
|
534
|
+
await open(url);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
if (openBrowser) {
|
|
538
|
+
await doOpen();
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
url,
|
|
542
|
+
port: actualPort,
|
|
543
|
+
closed,
|
|
544
|
+
close: (reason = "caller") => cleanup(reason),
|
|
545
|
+
on(event, listener) {
|
|
546
|
+
listeners.get(event)?.add(listener);
|
|
547
|
+
},
|
|
548
|
+
off(event, listener) {
|
|
549
|
+
listeners.get(event)?.delete(listener);
|
|
550
|
+
},
|
|
551
|
+
update(newData) {
|
|
552
|
+
viewState.data = newData;
|
|
553
|
+
fireEvent2("data-updated");
|
|
554
|
+
},
|
|
555
|
+
async swapView(source) {
|
|
556
|
+
const r = await compileSource({
|
|
557
|
+
source,
|
|
558
|
+
data: viewState.data,
|
|
559
|
+
title,
|
|
560
|
+
csp: cspHeader ?? void 0,
|
|
561
|
+
token
|
|
562
|
+
});
|
|
563
|
+
if (r.errors.length > 0) return r.errors;
|
|
564
|
+
viewState.html = r.html;
|
|
565
|
+
fireEvent2("view-swapped");
|
|
566
|
+
return [];
|
|
567
|
+
},
|
|
568
|
+
async patch(newData, source) {
|
|
569
|
+
const r = await compileSource({
|
|
570
|
+
source,
|
|
571
|
+
data: newData,
|
|
572
|
+
title,
|
|
573
|
+
csp: cspHeader ?? void 0,
|
|
574
|
+
token
|
|
575
|
+
});
|
|
576
|
+
if (r.errors.length > 0) return r.errors;
|
|
577
|
+
viewState.data = newData;
|
|
578
|
+
viewState.html = r.html;
|
|
579
|
+
fireEvent2("data-updated");
|
|
580
|
+
fireEvent2("view-swapped");
|
|
581
|
+
return [];
|
|
582
|
+
},
|
|
583
|
+
async reopen() {
|
|
584
|
+
await doOpen();
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
} catch (err) {
|
|
588
|
+
restoreStdout?.();
|
|
589
|
+
throw err;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
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);
|
|
597
|
+
}
|
|
598
|
+
async function handleMutate(req, mutations, headers) {
|
|
599
|
+
const contentLength = req.headers.get("content-length");
|
|
600
|
+
if (contentLength && Number.parseInt(contentLength, 10) > 1024 * 1024) {
|
|
601
|
+
return new Response(JSON.stringify({ error: "request body exceeds 1 MiB limit" }), {
|
|
602
|
+
status: 400,
|
|
603
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
let body;
|
|
607
|
+
try {
|
|
608
|
+
const text = await req.text();
|
|
609
|
+
if (text.length > 1024 * 1024) {
|
|
610
|
+
return new Response(JSON.stringify({ error: "request body exceeds 1 MiB limit" }), {
|
|
611
|
+
status: 400,
|
|
612
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
body = text ? JSON.parse(text) : void 0;
|
|
616
|
+
} catch (err) {
|
|
617
|
+
return new Response(
|
|
618
|
+
JSON.stringify({ error: err instanceof Error ? err.message : "bad request" }),
|
|
619
|
+
{ status: 400, headers: { ...headers, "Content-Type": "application/json" } }
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
const name = body?.name;
|
|
623
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
624
|
+
return new Response(JSON.stringify({ error: "missing mutation name" }), {
|
|
625
|
+
status: 400,
|
|
626
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
if (!Object.hasOwn(mutations, name)) {
|
|
630
|
+
return new Response(
|
|
631
|
+
JSON.stringify({
|
|
632
|
+
error: `ui-leaf: no mutation handler registered for '${name}'. Add it to the mutations: { } map passed to mount().`
|
|
633
|
+
}),
|
|
634
|
+
{ status: 404, headers: { ...headers, "Content-Type": "application/json" } }
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
const handler = mutations[name];
|
|
638
|
+
try {
|
|
639
|
+
const result = await handler(body.args);
|
|
640
|
+
return new Response(JSON.stringify(result ?? null), {
|
|
641
|
+
status: 200,
|
|
642
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
643
|
+
});
|
|
644
|
+
} catch (err) {
|
|
645
|
+
return new Response(
|
|
646
|
+
JSON.stringify({ error: err instanceof Error ? err.message : String(err) }),
|
|
647
|
+
{ status: 500, headers: { ...headers, "Content-Type": "application/json" } }
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// src/index.ts
|
|
653
|
+
async function mount(opts) {
|
|
654
|
+
const viewsRoot = opts.viewsRoot ?? resolve2(process.cwd(), "views");
|
|
655
|
+
const server = await startDevServer({
|
|
656
|
+
view: opts.view,
|
|
657
|
+
data: opts.data,
|
|
658
|
+
dataLoader: opts.dataLoader,
|
|
659
|
+
viewsRoot,
|
|
660
|
+
mutations: opts.mutations,
|
|
661
|
+
title: opts.title,
|
|
662
|
+
port: opts.port,
|
|
663
|
+
openBrowser: opts.openBrowser,
|
|
664
|
+
shell: opts.shell,
|
|
665
|
+
heartbeatTimeoutMs: opts.heartbeatTimeoutMs,
|
|
666
|
+
startupGraceMs: opts.startupGraceMs,
|
|
667
|
+
csp: opts.csp,
|
|
668
|
+
allowedHosts: opts.allowedHosts,
|
|
669
|
+
silent: opts.silent,
|
|
670
|
+
_heartbeatCheckIntervalMs: opts._heartbeatCheckIntervalMs
|
|
671
|
+
});
|
|
672
|
+
const onSignal = (signal) => {
|
|
673
|
+
void (async () => {
|
|
674
|
+
await server.close("signal");
|
|
675
|
+
process.kill(process.pid, signal);
|
|
676
|
+
})();
|
|
677
|
+
};
|
|
678
|
+
const sigint = () => onSignal("SIGINT");
|
|
679
|
+
const sigterm = () => onSignal("SIGTERM");
|
|
680
|
+
process.once("SIGINT", sigint);
|
|
681
|
+
process.once("SIGTERM", sigterm);
|
|
682
|
+
if (opts.signal) {
|
|
683
|
+
if (opts.signal.aborted) {
|
|
684
|
+
process.off("SIGINT", sigint);
|
|
685
|
+
process.off("SIGTERM", sigterm);
|
|
686
|
+
await server.close();
|
|
687
|
+
return {
|
|
688
|
+
url: server.url,
|
|
689
|
+
port: server.port,
|
|
690
|
+
closed: Promise.resolve("caller"),
|
|
691
|
+
close: () => server.close(),
|
|
692
|
+
update: server.update.bind(server),
|
|
693
|
+
swapView: (source) => server.swapView(source),
|
|
694
|
+
patch: (data, source) => server.patch(data, source),
|
|
695
|
+
reopen: server.reopen.bind(server),
|
|
696
|
+
on: server.on.bind(server),
|
|
697
|
+
off: server.off.bind(server)
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
opts.signal.addEventListener(
|
|
701
|
+
"abort",
|
|
702
|
+
() => void server.close(),
|
|
703
|
+
{ once: true }
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
const closed = server.closed.finally(() => {
|
|
707
|
+
process.off("SIGINT", sigint);
|
|
708
|
+
process.off("SIGTERM", sigterm);
|
|
709
|
+
});
|
|
710
|
+
return {
|
|
711
|
+
url: server.url,
|
|
712
|
+
port: server.port,
|
|
713
|
+
closed,
|
|
714
|
+
close: () => server.close(),
|
|
715
|
+
update: server.update.bind(server),
|
|
716
|
+
swapView: (source) => server.swapView(source),
|
|
717
|
+
patch: (data, source) => server.patch(data, source),
|
|
718
|
+
reopen: server.reopen.bind(server),
|
|
719
|
+
on: server.on.bind(server),
|
|
720
|
+
off: server.off.bind(server)
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/ipc.ts
|
|
725
|
+
var PROTOCOL_VERSION = "1";
|
|
726
|
+
function emit(event) {
|
|
727
|
+
const stamped = { version: PROTOCOL_VERSION, ...event };
|
|
728
|
+
return `${JSON.stringify(stamped)}
|
|
729
|
+
`;
|
|
730
|
+
}
|
|
731
|
+
function parseInbound(line) {
|
|
732
|
+
let parsed;
|
|
733
|
+
try {
|
|
734
|
+
parsed = JSON.parse(line);
|
|
735
|
+
} catch (err) {
|
|
736
|
+
return {
|
|
737
|
+
ok: false,
|
|
738
|
+
kind: "json",
|
|
739
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
743
|
+
return { ok: false, kind: "missing-version" };
|
|
744
|
+
}
|
|
745
|
+
if (!Object.hasOwn(parsed, "version")) {
|
|
746
|
+
return { ok: false, kind: "missing-version" };
|
|
747
|
+
}
|
|
748
|
+
const version = parsed.version;
|
|
749
|
+
if (version !== PROTOCOL_VERSION) {
|
|
750
|
+
return { ok: false, kind: "unsupported-version", got: version };
|
|
751
|
+
}
|
|
752
|
+
return { ok: true, msg: parsed };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// src/cli.ts
|
|
756
|
+
var realStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
757
|
+
var args = process.argv.slice(2);
|
|
758
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
759
|
+
process.stdout.write(
|
|
760
|
+
[
|
|
761
|
+
"ui-leaf \u2014 Customizable browser views, on demand, for any CLI.",
|
|
762
|
+
"",
|
|
763
|
+
"Usage:",
|
|
764
|
+
" ui-leaf mount Read a JSON config from stdin and mount a view.",
|
|
765
|
+
" See https://github.com/OpenThinkAi/ui-leaf for",
|
|
766
|
+
" the full stdio protocol spec.",
|
|
767
|
+
"",
|
|
768
|
+
" ui-leaf --version Print version.",
|
|
769
|
+
" ui-leaf --help Print this message.",
|
|
770
|
+
""
|
|
771
|
+
].join("\n")
|
|
772
|
+
);
|
|
773
|
+
process.exit(0);
|
|
774
|
+
}
|
|
775
|
+
if (args[0] === "--version" || args[0] === "-v") {
|
|
776
|
+
const { createRequire: createRequire2 } = await import("module");
|
|
777
|
+
const require2 = createRequire2(import.meta.url);
|
|
778
|
+
const pkg = require2("../package.json");
|
|
779
|
+
process.stdout.write(`${pkg.version}
|
|
780
|
+
`);
|
|
781
|
+
process.exit(0);
|
|
782
|
+
}
|
|
783
|
+
if (args[0] !== "mount") {
|
|
784
|
+
process.stderr.write(`ui-leaf: unknown command "${args[0]}"
|
|
785
|
+
`);
|
|
786
|
+
process.exit(1);
|
|
787
|
+
}
|
|
788
|
+
if (args[1] === "--help" || args[1] === "-h" || process.stdin.isTTY) {
|
|
789
|
+
process.stdout.write(
|
|
790
|
+
[
|
|
791
|
+
"ui-leaf mount \u2014 read a JSON config from stdin and mount a view.",
|
|
792
|
+
"",
|
|
793
|
+
"Protocol: line-delimited JSON over stdio.",
|
|
794
|
+
" stdin line 1 = config object",
|
|
795
|
+
" lines 2+ = mutation responses {type:result|error,id,...}",
|
|
796
|
+
" stdout {type:ready,url,port}, {type:mutate,id,name,args},",
|
|
797
|
+
" {type:closed}, {type:error,message}",
|
|
798
|
+
"",
|
|
799
|
+
"Full spec: https://github.com/OpenThinkAi/ui-leaf#driving-ui-leaf-from-a-non-node-cli",
|
|
800
|
+
"",
|
|
801
|
+
"Example:",
|
|
802
|
+
` echo '{"view":"spec","viewsRoot":"/abs/path","data":{}}' | ui-leaf mount`,
|
|
803
|
+
""
|
|
804
|
+
].join("\n")
|
|
805
|
+
);
|
|
806
|
+
process.exit(0);
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
await runMount();
|
|
810
|
+
} catch (err) {
|
|
811
|
+
emit2({
|
|
812
|
+
type: "error",
|
|
813
|
+
message: err instanceof Error ? err.message : String(err)
|
|
814
|
+
});
|
|
815
|
+
process.exit(1);
|
|
816
|
+
}
|
|
817
|
+
function emit2(event) {
|
|
818
|
+
realStdoutWrite(emit(event));
|
|
819
|
+
}
|
|
820
|
+
function stringifyVersion(got) {
|
|
821
|
+
if (typeof got === "string") return got;
|
|
822
|
+
return JSON.stringify(got);
|
|
823
|
+
}
|
|
824
|
+
async function runMount() {
|
|
825
|
+
const rl = createInterface({ input: process.stdin });
|
|
826
|
+
let nextId = 0;
|
|
827
|
+
const pending = /* @__PURE__ */ new Map();
|
|
828
|
+
let configReceived = false;
|
|
829
|
+
let configResolve;
|
|
830
|
+
let configReject;
|
|
831
|
+
const configPromise = new Promise((res, rej) => {
|
|
832
|
+
configResolve = res;
|
|
833
|
+
configReject = rej;
|
|
834
|
+
});
|
|
835
|
+
let mountedView = null;
|
|
836
|
+
let stdinClosed = false;
|
|
837
|
+
rl.on("line", (line) => {
|
|
838
|
+
const trimmed = line.trim();
|
|
839
|
+
if (!trimmed) return;
|
|
840
|
+
if (!configReceived) {
|
|
841
|
+
configReceived = true;
|
|
842
|
+
const outcome2 = parseInbound(trimmed);
|
|
843
|
+
if (!outcome2.ok) {
|
|
844
|
+
if (outcome2.kind === "json") {
|
|
845
|
+
emit2({
|
|
846
|
+
type: "error",
|
|
847
|
+
message: `failed to parse config JSON: ${outcome2.reason}`
|
|
848
|
+
});
|
|
849
|
+
} else if (outcome2.kind === "missing-version") {
|
|
850
|
+
emit2({ type: "error", message: "missing version field" });
|
|
851
|
+
} else {
|
|
852
|
+
emit2({
|
|
853
|
+
type: "error",
|
|
854
|
+
message: `unsupported protocol version: ${stringifyVersion(outcome2.got)}`
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
process.exit(1);
|
|
858
|
+
}
|
|
859
|
+
configResolve(outcome2.msg);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const outcome = parseInbound(trimmed);
|
|
863
|
+
if (!outcome.ok) {
|
|
864
|
+
if (outcome.kind === "missing-version") {
|
|
865
|
+
emit2({ type: "error", message: "missing version field" });
|
|
866
|
+
} else if (outcome.kind === "unsupported-version") {
|
|
867
|
+
emit2({
|
|
868
|
+
type: "error",
|
|
869
|
+
message: `unsupported protocol version: ${stringifyVersion(outcome.got)}`
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const msg = outcome.msg;
|
|
875
|
+
if ("id" in msg && typeof msg.id === "number") {
|
|
876
|
+
const p = pending.get(msg.id);
|
|
877
|
+
if (!p) return;
|
|
878
|
+
pending.delete(msg.id);
|
|
879
|
+
if (msg.type === "result") p.resolve(msg.value);
|
|
880
|
+
else if (msg.type === "error") p.reject(new Error(msg.message));
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if (!mountedView) return;
|
|
884
|
+
if (msg.type === "update") {
|
|
885
|
+
mountedView.update(msg.data);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (msg.type === "view") {
|
|
889
|
+
void (async () => {
|
|
890
|
+
const errors = await mountedView.swapView(msg.source);
|
|
891
|
+
if (errors.length > 0) {
|
|
892
|
+
emit2({
|
|
893
|
+
type: "error",
|
|
894
|
+
phase: "build",
|
|
895
|
+
message: errors.map((e) => e.message).join("; ")
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
})();
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (msg.type === "patch") {
|
|
902
|
+
void (async () => {
|
|
903
|
+
const errors = await mountedView.patch(msg.data, msg.view.source);
|
|
904
|
+
if (errors.length > 0) {
|
|
905
|
+
emit2({
|
|
906
|
+
type: "error",
|
|
907
|
+
phase: "build",
|
|
908
|
+
message: errors.map((e) => e.message).join("; ")
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
})();
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
if (msg.type === "reopen") {
|
|
915
|
+
void mountedView.reopen().catch((err) => {
|
|
916
|
+
emit2({ type: "error", message: err instanceof Error ? err.message : String(err) });
|
|
917
|
+
});
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
if (msg.type === "close") {
|
|
921
|
+
void mountedView.close();
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
emit2({ type: "error", message: `unknown message type: ${msg.type}` });
|
|
925
|
+
});
|
|
926
|
+
rl.on("close", () => {
|
|
927
|
+
stdinClosed = true;
|
|
928
|
+
for (const { reject } of pending.values()) {
|
|
929
|
+
reject(new Error("ui-leaf: stdin closed by parent before mutation responded"));
|
|
930
|
+
}
|
|
931
|
+
pending.clear();
|
|
932
|
+
if (!configReceived) {
|
|
933
|
+
configReject(new Error("ui-leaf: stdin closed before config received"));
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (mountedView) {
|
|
937
|
+
void mountedView.close();
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
const config = await configPromise;
|
|
941
|
+
const mutations = {};
|
|
942
|
+
for (const name of config.mutations ?? []) {
|
|
943
|
+
mutations[name] = (mutationArgs) => {
|
|
944
|
+
const id = ++nextId;
|
|
945
|
+
return new Promise((resolve3, reject) => {
|
|
946
|
+
pending.set(id, { resolve: resolve3, reject });
|
|
947
|
+
emit2({ type: "mutate", id, name, args: mutationArgs });
|
|
948
|
+
});
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
const mountOpts = {
|
|
952
|
+
view: config.view,
|
|
953
|
+
viewsRoot: config.viewsRoot,
|
|
954
|
+
data: config.data,
|
|
955
|
+
mutations,
|
|
956
|
+
title: config.title,
|
|
957
|
+
port: config.port,
|
|
958
|
+
openBrowser: config.openBrowser,
|
|
959
|
+
shell: config.shell,
|
|
960
|
+
csp: config.csp,
|
|
961
|
+
heartbeatTimeoutMs: config.heartbeatTimeoutMs,
|
|
962
|
+
startupGraceMs: config.startupGraceMs,
|
|
963
|
+
silent: true
|
|
964
|
+
// bridge owns stdout; bundler / dev-server output must stay silent
|
|
965
|
+
};
|
|
966
|
+
try {
|
|
967
|
+
const view = await mount(mountOpts);
|
|
968
|
+
mountedView = view;
|
|
969
|
+
if (stdinClosed) {
|
|
970
|
+
void view.close();
|
|
971
|
+
}
|
|
972
|
+
view.on("disconnected", () => emit2({ type: "disconnected" }));
|
|
973
|
+
view.on("reconnected", () => emit2({ type: "reconnected" }));
|
|
974
|
+
emit2({ type: "ready", url: view.url, port: view.port });
|
|
975
|
+
const closeReason = await view.closed;
|
|
976
|
+
emit2({ type: "closed", reason: closeReason });
|
|
977
|
+
process.exit(closeReason === "error" ? 1 : 0);
|
|
978
|
+
} catch (err) {
|
|
979
|
+
emit2({
|
|
980
|
+
type: "error",
|
|
981
|
+
message: err instanceof Error ? err.message : String(err)
|
|
982
|
+
});
|
|
983
|
+
process.exit(1);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
//# sourceMappingURL=cli.js.map
|