@openthink/ui-leaf 0.3.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/LICENSE +21 -0
- package/README.md +301 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +672 -0
- package/dist/cli.js.map +1 -0
- package/dist/dev-server-DapOoULX.d.ts +5 -0
- package/dist/index.d.ts +170 -0
- package/dist/index.js +503 -0
- package/dist/index.js.map +1 -0
- package/dist/view.d.ts +16 -0
- package/dist/view.js +1 -0
- package/dist/view.js.map +1 -0
- package/package.json +66 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
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/dev-server.ts
|
|
10
|
+
import { randomBytes, timingSafeEqual as nodeTimingSafeEqual } from "crypto";
|
|
11
|
+
import { rmSync } from "fs";
|
|
12
|
+
import { mkdtemp, readdir, rm, stat, writeFile } from "fs/promises";
|
|
13
|
+
import { createRequire } from "module";
|
|
14
|
+
import { createServer as createTcpServer } from "net";
|
|
15
|
+
import { tmpdir } from "os";
|
|
16
|
+
import { dirname, join, resolve, sep } from "path";
|
|
17
|
+
import { createRsbuild } from "@rsbuild/core";
|
|
18
|
+
import { pluginReact } from "@rsbuild/plugin-react";
|
|
19
|
+
import open, { apps } from "open";
|
|
20
|
+
var uiLeafRequire = createRequire(import.meta.url);
|
|
21
|
+
var reactPath = dirname(uiLeafRequire.resolve("react/package.json"));
|
|
22
|
+
var reactDomPath = dirname(uiLeafRequire.resolve("react-dom/package.json"));
|
|
23
|
+
var ORIGINAL_STDOUT_WRITE = process.stdout.write.bind(process.stdout);
|
|
24
|
+
var stdoutRedirectCount = 0;
|
|
25
|
+
async function findFreePort() {
|
|
26
|
+
return new Promise((resolve3, reject) => {
|
|
27
|
+
const server = createTcpServer();
|
|
28
|
+
server.unref();
|
|
29
|
+
server.on("error", reject);
|
|
30
|
+
server.listen(0, "127.0.0.1", () => {
|
|
31
|
+
const addr = server.address();
|
|
32
|
+
if (!addr || typeof addr !== "object") {
|
|
33
|
+
server.close();
|
|
34
|
+
reject(new Error("ui-leaf: failed to obtain a free port from the OS"));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const port = addr.port;
|
|
38
|
+
server.close(() => resolve3(port));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
var STALE_TEMPDIR_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
43
|
+
async function sweepStaleTempDirs() {
|
|
44
|
+
try {
|
|
45
|
+
const root = tmpdir();
|
|
46
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
47
|
+
const cutoff = Date.now() - STALE_TEMPDIR_AGE_MS;
|
|
48
|
+
await Promise.all(
|
|
49
|
+
entries.filter((e) => e.isDirectory() && e.name.startsWith("ui-leaf-")).map(async (e) => {
|
|
50
|
+
const path = join(root, e.name);
|
|
51
|
+
try {
|
|
52
|
+
const info = await stat(path);
|
|
53
|
+
if (info.mtimeMs < cutoff) {
|
|
54
|
+
await rm(path, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function redirectStdoutToStderr() {
|
|
64
|
+
stdoutRedirectCount++;
|
|
65
|
+
if (stdoutRedirectCount === 1) {
|
|
66
|
+
process.stdout.write = ((chunk, enc, cb) => process.stderr.write(chunk, enc, cb));
|
|
67
|
+
}
|
|
68
|
+
let released = false;
|
|
69
|
+
return () => {
|
|
70
|
+
if (released) return;
|
|
71
|
+
released = true;
|
|
72
|
+
stdoutRedirectCount--;
|
|
73
|
+
if (stdoutRedirectCount === 0) {
|
|
74
|
+
process.stdout.write = ORIGINAL_STDOUT_WRITE;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
async function openInAppMode(url) {
|
|
79
|
+
const candidates = [apps.chrome, apps.edge, apps.brave];
|
|
80
|
+
for (const app of candidates) {
|
|
81
|
+
try {
|
|
82
|
+
await open(url, { app: { name: app, arguments: [`--app=${url}`] } });
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
var STRICT_CSP = [
|
|
90
|
+
"default-src 'self'",
|
|
91
|
+
"connect-src 'self'",
|
|
92
|
+
"img-src 'self' data: https:",
|
|
93
|
+
"font-src 'self' https: data:",
|
|
94
|
+
"style-src 'self' 'unsafe-inline'",
|
|
95
|
+
"script-src 'self' 'unsafe-eval' 'unsafe-inline'"
|
|
96
|
+
].join("; ");
|
|
97
|
+
function resolveCsp(opt) {
|
|
98
|
+
if (!opt || opt === "off") return null;
|
|
99
|
+
if (opt === "strict") return STRICT_CSP;
|
|
100
|
+
return opt;
|
|
101
|
+
}
|
|
102
|
+
function escapeForScriptTag(json) {
|
|
103
|
+
return json.replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
104
|
+
}
|
|
105
|
+
async function readJsonBody(req) {
|
|
106
|
+
const chunks = [];
|
|
107
|
+
let total = 0;
|
|
108
|
+
for await (const chunk of req) {
|
|
109
|
+
const buf = chunk;
|
|
110
|
+
total += buf.length;
|
|
111
|
+
if (total > 1024 * 1024) {
|
|
112
|
+
throw new Error("request body exceeds 1 MiB limit");
|
|
113
|
+
}
|
|
114
|
+
chunks.push(buf);
|
|
115
|
+
}
|
|
116
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
117
|
+
return text ? JSON.parse(text) : void 0;
|
|
118
|
+
}
|
|
119
|
+
function timingSafeEqual(a, b) {
|
|
120
|
+
if (a.length !== b.length) return false;
|
|
121
|
+
return nodeTimingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
122
|
+
}
|
|
123
|
+
var DEFAULT_LOOPBACK_HOSTNAMES = ["127.0.0.1", "localhost", "::1"];
|
|
124
|
+
function parseHostHeader(value) {
|
|
125
|
+
const trimmed = value.trim();
|
|
126
|
+
if (trimmed === "") return null;
|
|
127
|
+
if (trimmed.startsWith("[")) {
|
|
128
|
+
const close = trimmed.indexOf("]");
|
|
129
|
+
if (close === -1) return null;
|
|
130
|
+
return trimmed.slice(1, close).toLowerCase();
|
|
131
|
+
}
|
|
132
|
+
const colon = trimmed.indexOf(":");
|
|
133
|
+
return (colon === -1 ? trimmed : trimmed.slice(0, colon)).toLowerCase();
|
|
134
|
+
}
|
|
135
|
+
function isAllowedHost(value, allowed) {
|
|
136
|
+
const host = value === void 0 ? null : parseHostHeader(value);
|
|
137
|
+
return host !== null && allowed.has(host);
|
|
138
|
+
}
|
|
139
|
+
function isAllowedOrigin(value, allowed) {
|
|
140
|
+
if (value === void 0 || value === "" || value === "null") return true;
|
|
141
|
+
try {
|
|
142
|
+
let hostname = new URL(value).hostname.toLowerCase();
|
|
143
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
144
|
+
hostname = hostname.slice(1, -1);
|
|
145
|
+
}
|
|
146
|
+
return allowed.has(hostname);
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function startDevServer(opts) {
|
|
152
|
+
const {
|
|
153
|
+
view,
|
|
154
|
+
data,
|
|
155
|
+
viewsRoot,
|
|
156
|
+
mutations = {},
|
|
157
|
+
title = "ui-leaf",
|
|
158
|
+
port,
|
|
159
|
+
openBrowser = true,
|
|
160
|
+
shell = "tab",
|
|
161
|
+
heartbeatTimeoutMs = 75e3,
|
|
162
|
+
startupGraceMs = 3e4,
|
|
163
|
+
csp,
|
|
164
|
+
allowedHosts,
|
|
165
|
+
silent = false
|
|
166
|
+
} = opts;
|
|
167
|
+
const cspHeader = resolveCsp(csp);
|
|
168
|
+
const allowedHostSet = new Set(DEFAULT_LOOPBACK_HOSTNAMES);
|
|
169
|
+
for (const h of allowedHosts ?? []) allowedHostSet.add(h.toLowerCase());
|
|
170
|
+
const allowedHostList = [...allowedHostSet].join(", ");
|
|
171
|
+
const resolvedPort = port === 0 ? await findFreePort() : port ?? 5810;
|
|
172
|
+
const restoreStdout = silent ? redirectStdoutToStderr() : null;
|
|
173
|
+
let tempDir = null;
|
|
174
|
+
let cleanupOnExit = null;
|
|
175
|
+
try {
|
|
176
|
+
let checkAuth2 = function(req) {
|
|
177
|
+
const header = req.headers.authorization ?? "";
|
|
178
|
+
const match = /^Bearer (.+)$/.exec(header);
|
|
179
|
+
if (!match) return false;
|
|
180
|
+
return timingSafeEqual(match[1], token);
|
|
181
|
+
}, sendJson2 = function(res, status, body) {
|
|
182
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
183
|
+
res.end(body === void 0 ? "" : JSON.stringify(body));
|
|
184
|
+
};
|
|
185
|
+
var checkAuth = checkAuth2, sendJson = sendJson2;
|
|
186
|
+
if (view.includes("/") || view.includes("\\")) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`ui-leaf: view '${view}' must be a bare identifier with no path separators`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const viewsRootAbs = resolve(viewsRoot);
|
|
192
|
+
const viewAbs = resolve(viewsRootAbs, `${view}.tsx`);
|
|
193
|
+
if (!viewAbs.startsWith(viewsRootAbs + sep)) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`ui-leaf: view '${view}' resolves outside viewsRoot`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
await stat(viewAbs);
|
|
200
|
+
} catch {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`ui-leaf: view '${view}' not found at ${viewAbs} (looked for .tsx; viewsRoot=${viewsRoot})`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const token = randomBytes(32).toString("hex");
|
|
206
|
+
tempDir = await mkdtemp(join(tmpdir(), "ui-leaf-"));
|
|
207
|
+
const dirToSweep = tempDir;
|
|
208
|
+
cleanupOnExit = () => {
|
|
209
|
+
try {
|
|
210
|
+
rmSync(dirToSweep, { recursive: true, force: true });
|
|
211
|
+
} catch {
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
process.on("exit", cleanupOnExit);
|
|
215
|
+
void sweepStaleTempDirs();
|
|
216
|
+
const entryPath = join(dirToSweep, "entry.tsx");
|
|
217
|
+
await writeFile(
|
|
218
|
+
entryPath,
|
|
219
|
+
`import { createRoot } from "react-dom/client";
|
|
220
|
+
import View from ${JSON.stringify(viewAbs)};
|
|
221
|
+
|
|
222
|
+
const ctx = (globalThis).__UI_LEAF__ || {};
|
|
223
|
+
const data = ctx.data;
|
|
224
|
+
const token = ctx.token;
|
|
225
|
+
|
|
226
|
+
async function mutate(name, args) {
|
|
227
|
+
const res = await fetch("/mutate", {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: {
|
|
230
|
+
"Content-Type": "application/json",
|
|
231
|
+
...(token ? { Authorization: "Bearer " + token } : {}),
|
|
232
|
+
},
|
|
233
|
+
body: JSON.stringify({ name, args }),
|
|
234
|
+
});
|
|
235
|
+
const text = await res.text().catch(function () { return ""; });
|
|
236
|
+
if (!res.ok) {
|
|
237
|
+
var detail = text;
|
|
238
|
+
try {
|
|
239
|
+
var parsed = text ? JSON.parse(text) : null;
|
|
240
|
+
if (parsed && typeof parsed === "object" && typeof parsed.error === "string") {
|
|
241
|
+
detail = parsed.error;
|
|
242
|
+
}
|
|
243
|
+
} catch (_) { /* keep raw text */ }
|
|
244
|
+
throw new Error("ui-leaf: mutation '" + name + "' failed (" + res.status + "): " + detail);
|
|
245
|
+
}
|
|
246
|
+
return text ? JSON.parse(text) : undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function heartbeat() {
|
|
250
|
+
try {
|
|
251
|
+
await fetch("/heartbeat", {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: token ? { Authorization: "Bearer " + token } : {},
|
|
254
|
+
});
|
|
255
|
+
} catch {
|
|
256
|
+
/* server may have shut down; ignore */
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
setInterval(heartbeat, 5000);
|
|
260
|
+
heartbeat();
|
|
261
|
+
|
|
262
|
+
const el = document.getElementById("root");
|
|
263
|
+
if (!el) throw new Error("ui-leaf: #root element missing");
|
|
264
|
+
createRoot(el).render(<View data={data} mutate={mutate} />);
|
|
265
|
+
`
|
|
266
|
+
);
|
|
267
|
+
const dataInline = escapeForScriptTag(JSON.stringify(data));
|
|
268
|
+
const tokenInline = JSON.stringify(token);
|
|
269
|
+
const titleEscaped = title.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
270
|
+
const htmlPath = join(dirToSweep, "index.html");
|
|
271
|
+
await writeFile(
|
|
272
|
+
htmlPath,
|
|
273
|
+
`<!DOCTYPE html>
|
|
274
|
+
<html lang="en">
|
|
275
|
+
<head>
|
|
276
|
+
<meta charset="utf-8" />
|
|
277
|
+
<title>${titleEscaped}</title>
|
|
278
|
+
<script>window.__UI_LEAF__ = { data: ${dataInline}, token: ${tokenInline} };</script>
|
|
279
|
+
</head>
|
|
280
|
+
<body>
|
|
281
|
+
<div id="root"></div>
|
|
282
|
+
</body>
|
|
283
|
+
</html>
|
|
284
|
+
`
|
|
285
|
+
);
|
|
286
|
+
let lastHeartbeatAt = Date.now();
|
|
287
|
+
let closeRequested = false;
|
|
288
|
+
let resolveClosed = () => {
|
|
289
|
+
};
|
|
290
|
+
const closed = new Promise((r) => {
|
|
291
|
+
resolveClosed = r;
|
|
292
|
+
});
|
|
293
|
+
const rsbuild = await createRsbuild({
|
|
294
|
+
cwd: dirToSweep,
|
|
295
|
+
rsbuildConfig: {
|
|
296
|
+
plugins: [pluginReact()],
|
|
297
|
+
...silent ? { logLevel: "silent" } : {},
|
|
298
|
+
source: { entry: { index: entryPath } },
|
|
299
|
+
// 5810 is unused by the major Node dev tools (vite=5173, parcel=1234,
|
|
300
|
+
// webpack=8080, next/CRA=3000). rsbuild auto-bumps to the next free
|
|
301
|
+
// port if 5810 is busy, so collisions are graceful.
|
|
302
|
+
server: { port: resolvedPort, host: "127.0.0.1" },
|
|
303
|
+
// Note: `dev.setupMiddlewares` is deprecated as of rsbuild 2.x in
|
|
304
|
+
// favor of `server.setup`, but the new API has a different signature
|
|
305
|
+
// and bypasses the rsbuild CSRF middleware in ways that break our
|
|
306
|
+
// POST endpoints. Sticking with the deprecated path for v1.
|
|
307
|
+
dev: {
|
|
308
|
+
setupMiddlewares: [
|
|
309
|
+
(middlewares) => {
|
|
310
|
+
middlewares.unshift(async (req, res, next) => {
|
|
311
|
+
const hostOk = isAllowedHost(req.headers.host, allowedHostSet);
|
|
312
|
+
const originOk = isAllowedOrigin(req.headers.origin, allowedHostSet);
|
|
313
|
+
if (!hostOk || !originOk) {
|
|
314
|
+
const offender = !hostOk ? `Host "${req.headers.host ?? "(absent)"}"` : `Origin "${req.headers.origin}"`;
|
|
315
|
+
res.statusCode = 403;
|
|
316
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
317
|
+
res.end(
|
|
318
|
+
`ui-leaf: refusing request with ${offender} \u2014 only the following hostnames are accepted to prevent DNS rebinding: ${allowedHostList}. Open the dev server at http://localhost:${resolvedPort}/ or http://127.0.0.1:${resolvedPort}/, or pass { allowedHosts: ["my-alias"] } to mount() to permit a custom alias.
|
|
319
|
+
`
|
|
320
|
+
);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const url2 = req.url ?? "";
|
|
324
|
+
if (req.method === "POST" && url2 === "/heartbeat") {
|
|
325
|
+
if (!checkAuth2(req)) {
|
|
326
|
+
sendJson2(res, 401, { error: "unauthorized" });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
lastHeartbeatAt = Date.now();
|
|
330
|
+
sendJson2(res, 204, void 0);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (req.method === "POST" && url2 === "/mutate") {
|
|
334
|
+
if (!checkAuth2(req)) {
|
|
335
|
+
sendJson2(res, 401, { error: "unauthorized" });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
let body;
|
|
339
|
+
try {
|
|
340
|
+
body = await readJsonBody(req);
|
|
341
|
+
} catch (err) {
|
|
342
|
+
sendJson2(res, 400, {
|
|
343
|
+
error: err instanceof Error ? err.message : "bad request"
|
|
344
|
+
});
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const name = body?.name;
|
|
348
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
349
|
+
sendJson2(res, 400, { error: "missing mutation name" });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (!Object.hasOwn(mutations, name)) {
|
|
353
|
+
sendJson2(res, 404, {
|
|
354
|
+
error: `ui-leaf: no mutation handler registered for '${name}'. Add it to the mutations: { } map passed to mount().`
|
|
355
|
+
});
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const handler = mutations[name];
|
|
359
|
+
try {
|
|
360
|
+
const result = await handler(body.args);
|
|
361
|
+
sendJson2(res, 200, result ?? null);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
sendJson2(res, 500, {
|
|
364
|
+
error: err instanceof Error ? err.message : String(err)
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
next();
|
|
370
|
+
});
|
|
371
|
+
if (cspHeader) {
|
|
372
|
+
middlewares.unshift((_req, res, next) => {
|
|
373
|
+
res.setHeader("Content-Security-Policy", cspHeader);
|
|
374
|
+
next();
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
]
|
|
379
|
+
},
|
|
380
|
+
html: { template: htmlPath },
|
|
381
|
+
tools: {
|
|
382
|
+
rspack: {
|
|
383
|
+
resolve: {
|
|
384
|
+
alias: {
|
|
385
|
+
react: reactPath,
|
|
386
|
+
"react-dom": reactDomPath
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
const devServer = await rsbuild.startDevServer();
|
|
394
|
+
const actualPort = devServer.port;
|
|
395
|
+
const url = `http://127.0.0.1:${actualPort}`;
|
|
396
|
+
const startedAt = Date.now();
|
|
397
|
+
let heartbeatWatcher;
|
|
398
|
+
const cleanup = async () => {
|
|
399
|
+
if (closeRequested) return;
|
|
400
|
+
closeRequested = true;
|
|
401
|
+
if (heartbeatWatcher) clearInterval(heartbeatWatcher);
|
|
402
|
+
await devServer.server.close();
|
|
403
|
+
await rm(dirToSweep, { recursive: true, force: true });
|
|
404
|
+
if (cleanupOnExit) process.off("exit", cleanupOnExit);
|
|
405
|
+
if (restoreStdout) restoreStdout();
|
|
406
|
+
resolveClosed();
|
|
407
|
+
};
|
|
408
|
+
heartbeatWatcher = setInterval(() => {
|
|
409
|
+
const now = Date.now();
|
|
410
|
+
if (now - startedAt < startupGraceMs) return;
|
|
411
|
+
if (now - lastHeartbeatAt > heartbeatTimeoutMs) {
|
|
412
|
+
void cleanup();
|
|
413
|
+
}
|
|
414
|
+
}, 1e3);
|
|
415
|
+
if (openBrowser) {
|
|
416
|
+
if (shell === "app") {
|
|
417
|
+
const launched = await openInAppMode(url);
|
|
418
|
+
if (!launched) {
|
|
419
|
+
process.stderr.write(
|
|
420
|
+
`ui-leaf: shell:"app" requested but no Chromium browser found; falling back to default browser tab.
|
|
421
|
+
`
|
|
422
|
+
);
|
|
423
|
+
await open(url);
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
await open(url);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
url,
|
|
431
|
+
port: actualPort,
|
|
432
|
+
closed,
|
|
433
|
+
close: cleanup
|
|
434
|
+
};
|
|
435
|
+
} catch (err) {
|
|
436
|
+
if (tempDir) {
|
|
437
|
+
try {
|
|
438
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
439
|
+
} catch {
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (cleanupOnExit) process.off("exit", cleanupOnExit);
|
|
443
|
+
restoreStdout?.();
|
|
444
|
+
throw err;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/index.ts
|
|
449
|
+
async function mount(opts) {
|
|
450
|
+
const viewsRoot = opts.viewsRoot ?? resolve2(process.cwd(), "views");
|
|
451
|
+
const server = await startDevServer({
|
|
452
|
+
view: opts.view,
|
|
453
|
+
data: opts.data,
|
|
454
|
+
viewsRoot,
|
|
455
|
+
mutations: opts.mutations,
|
|
456
|
+
title: opts.title,
|
|
457
|
+
port: opts.port,
|
|
458
|
+
openBrowser: opts.openBrowser,
|
|
459
|
+
shell: opts.shell,
|
|
460
|
+
heartbeatTimeoutMs: opts.heartbeatTimeoutMs,
|
|
461
|
+
startupGraceMs: opts.startupGraceMs,
|
|
462
|
+
csp: opts.csp,
|
|
463
|
+
allowedHosts: opts.allowedHosts,
|
|
464
|
+
silent: opts.silent
|
|
465
|
+
});
|
|
466
|
+
const onSignal = (signal) => {
|
|
467
|
+
void (async () => {
|
|
468
|
+
await server.close();
|
|
469
|
+
process.kill(process.pid, signal);
|
|
470
|
+
})();
|
|
471
|
+
};
|
|
472
|
+
const sigint = () => onSignal("SIGINT");
|
|
473
|
+
const sigterm = () => onSignal("SIGTERM");
|
|
474
|
+
process.once("SIGINT", sigint);
|
|
475
|
+
process.once("SIGTERM", sigterm);
|
|
476
|
+
if (opts.signal) {
|
|
477
|
+
if (opts.signal.aborted) {
|
|
478
|
+
process.off("SIGINT", sigint);
|
|
479
|
+
process.off("SIGTERM", sigterm);
|
|
480
|
+
await server.close();
|
|
481
|
+
return {
|
|
482
|
+
url: server.url,
|
|
483
|
+
port: server.port,
|
|
484
|
+
closed: Promise.resolve(),
|
|
485
|
+
close: server.close
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
opts.signal.addEventListener(
|
|
489
|
+
"abort",
|
|
490
|
+
() => void server.close(),
|
|
491
|
+
{ once: true }
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
const closed = server.closed.finally(() => {
|
|
495
|
+
process.off("SIGINT", sigint);
|
|
496
|
+
process.off("SIGTERM", sigterm);
|
|
497
|
+
});
|
|
498
|
+
return {
|
|
499
|
+
url: server.url,
|
|
500
|
+
port: server.port,
|
|
501
|
+
closed,
|
|
502
|
+
close: server.close
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/cli.ts
|
|
507
|
+
var realStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
508
|
+
var args = process.argv.slice(2);
|
|
509
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
510
|
+
process.stdout.write(
|
|
511
|
+
[
|
|
512
|
+
"ui-leaf \u2014 Customizable browser views, on demand, for any CLI.",
|
|
513
|
+
"",
|
|
514
|
+
"Usage:",
|
|
515
|
+
" ui-leaf mount Read a JSON config from stdin and mount a view.",
|
|
516
|
+
" See https://github.com/OpenThinkAi/ui-leaf for",
|
|
517
|
+
" the full stdio protocol spec.",
|
|
518
|
+
"",
|
|
519
|
+
" ui-leaf --version Print version.",
|
|
520
|
+
" ui-leaf --help Print this message.",
|
|
521
|
+
""
|
|
522
|
+
].join("\n")
|
|
523
|
+
);
|
|
524
|
+
process.exit(0);
|
|
525
|
+
}
|
|
526
|
+
if (args[0] === "--version" || args[0] === "-v") {
|
|
527
|
+
const { createRequire: createRequire2 } = await import("module");
|
|
528
|
+
const require2 = createRequire2(import.meta.url);
|
|
529
|
+
const pkg = require2("../package.json");
|
|
530
|
+
process.stdout.write(`${pkg.version}
|
|
531
|
+
`);
|
|
532
|
+
process.exit(0);
|
|
533
|
+
}
|
|
534
|
+
if (args[0] !== "mount") {
|
|
535
|
+
process.stderr.write(`ui-leaf: unknown command "${args[0]}"
|
|
536
|
+
`);
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
if (args[1] === "--help" || args[1] === "-h" || process.stdin.isTTY) {
|
|
540
|
+
process.stdout.write(
|
|
541
|
+
[
|
|
542
|
+
"ui-leaf mount \u2014 read a JSON config from stdin and mount a view.",
|
|
543
|
+
"",
|
|
544
|
+
"Protocol: line-delimited JSON over stdio.",
|
|
545
|
+
" stdin line 1 = config object",
|
|
546
|
+
" lines 2+ = mutation responses {type:result|error,id,...}",
|
|
547
|
+
" stdout {type:ready,url,port}, {type:mutate,id,name,args},",
|
|
548
|
+
" {type:closed}, {type:error,message}",
|
|
549
|
+
"",
|
|
550
|
+
"Full spec: https://github.com/OpenThinkAi/ui-leaf#driving-ui-leaf-from-a-non-node-cli",
|
|
551
|
+
"",
|
|
552
|
+
"Example:",
|
|
553
|
+
` echo '{"view":"spec","viewsRoot":"/abs/path","data":{}}' | ui-leaf mount`,
|
|
554
|
+
""
|
|
555
|
+
].join("\n")
|
|
556
|
+
);
|
|
557
|
+
process.exit(0);
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
await runMount();
|
|
561
|
+
} catch (err) {
|
|
562
|
+
emit({
|
|
563
|
+
type: "error",
|
|
564
|
+
message: err instanceof Error ? err.message : String(err)
|
|
565
|
+
});
|
|
566
|
+
process.exit(1);
|
|
567
|
+
}
|
|
568
|
+
function emit(event) {
|
|
569
|
+
realStdoutWrite(`${JSON.stringify(event)}
|
|
570
|
+
`);
|
|
571
|
+
}
|
|
572
|
+
async function runMount() {
|
|
573
|
+
const rl = createInterface({ input: process.stdin });
|
|
574
|
+
let nextId = 0;
|
|
575
|
+
const pending = /* @__PURE__ */ new Map();
|
|
576
|
+
let configReceived = false;
|
|
577
|
+
let configResolve;
|
|
578
|
+
let configReject;
|
|
579
|
+
const configPromise = new Promise((res, rej) => {
|
|
580
|
+
configResolve = res;
|
|
581
|
+
configReject = rej;
|
|
582
|
+
});
|
|
583
|
+
let mountedView = null;
|
|
584
|
+
let stdinClosed = false;
|
|
585
|
+
rl.on("line", (line) => {
|
|
586
|
+
const trimmed = line.trim();
|
|
587
|
+
if (!trimmed) return;
|
|
588
|
+
if (!configReceived) {
|
|
589
|
+
configReceived = true;
|
|
590
|
+
try {
|
|
591
|
+
const config2 = JSON.parse(trimmed);
|
|
592
|
+
configResolve(config2);
|
|
593
|
+
} catch (err) {
|
|
594
|
+
emit({
|
|
595
|
+
type: "error",
|
|
596
|
+
message: `failed to parse config JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
597
|
+
});
|
|
598
|
+
process.exit(1);
|
|
599
|
+
}
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
let msg;
|
|
603
|
+
try {
|
|
604
|
+
msg = JSON.parse(trimmed);
|
|
605
|
+
} catch {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const p = pending.get(msg.id);
|
|
609
|
+
if (!p) return;
|
|
610
|
+
pending.delete(msg.id);
|
|
611
|
+
if (msg.type === "result") p.resolve(msg.value);
|
|
612
|
+
else if (msg.type === "error") p.reject(new Error(msg.message));
|
|
613
|
+
});
|
|
614
|
+
rl.on("close", () => {
|
|
615
|
+
stdinClosed = true;
|
|
616
|
+
for (const { reject } of pending.values()) {
|
|
617
|
+
reject(new Error("ui-leaf: stdin closed by parent before mutation responded"));
|
|
618
|
+
}
|
|
619
|
+
pending.clear();
|
|
620
|
+
if (!configReceived) {
|
|
621
|
+
configReject(new Error("ui-leaf: stdin closed before config received"));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (mountedView) {
|
|
625
|
+
void mountedView.close();
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
const config = await configPromise;
|
|
629
|
+
const mutations = {};
|
|
630
|
+
for (const name of config.mutations ?? []) {
|
|
631
|
+
mutations[name] = (mutationArgs) => {
|
|
632
|
+
const id = ++nextId;
|
|
633
|
+
return new Promise((resolve3, reject) => {
|
|
634
|
+
pending.set(id, { resolve: resolve3, reject });
|
|
635
|
+
emit({ type: "mutate", id, name, args: mutationArgs });
|
|
636
|
+
});
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
const mountOpts = {
|
|
640
|
+
view: config.view,
|
|
641
|
+
viewsRoot: config.viewsRoot,
|
|
642
|
+
data: config.data,
|
|
643
|
+
mutations,
|
|
644
|
+
title: config.title,
|
|
645
|
+
port: config.port,
|
|
646
|
+
openBrowser: config.openBrowser,
|
|
647
|
+
shell: config.shell,
|
|
648
|
+
csp: config.csp,
|
|
649
|
+
heartbeatTimeoutMs: config.heartbeatTimeoutMs,
|
|
650
|
+
startupGraceMs: config.startupGraceMs,
|
|
651
|
+
silent: true
|
|
652
|
+
// bridge owns stdout; rsbuild output must stay silent
|
|
653
|
+
};
|
|
654
|
+
try {
|
|
655
|
+
const view = await mount(mountOpts);
|
|
656
|
+
mountedView = view;
|
|
657
|
+
if (stdinClosed) {
|
|
658
|
+
void view.close();
|
|
659
|
+
}
|
|
660
|
+
emit({ type: "ready", url: view.url, port: view.port });
|
|
661
|
+
await view.closed;
|
|
662
|
+
emit({ type: "closed" });
|
|
663
|
+
process.exit(0);
|
|
664
|
+
} catch (err) {
|
|
665
|
+
emit({
|
|
666
|
+
type: "error",
|
|
667
|
+
message: err instanceof Error ? err.message : String(err)
|
|
668
|
+
});
|
|
669
|
+
process.exit(1);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
//# sourceMappingURL=cli.js.map
|