@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 +1 -1
- package/packages/cli/dist/cli.js +299 -61
- package/packages/cli/dist/cli.js.map +1 -1
- package/packages/cli/dist/index.js +212 -60
- package/packages/cli/dist/index.js.map +1 -1
package/package.json
CHANGED
package/packages/cli/dist/cli.js
CHANGED
|
@@ -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 ? {
|
|
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 ? {
|
|
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,
|
|
161
|
+
const { js, title, csp, data, dataLoader } = opts;
|
|
108
162
|
const safeJs = js.replace(/<\/script>/gi, "<\\/script>");
|
|
109
163
|
const titleEscaped = title.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
110
164
|
const cspMeta = csp ? ` <meta http-equiv="Content-Security-Policy" content="${csp.replace(/&/g, "&").replace(/"/g, """)}" />
|
|
111
165
|
` : "";
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
671
|
+
const openUrl = `${url}/#token=${token}`;
|
|
672
|
+
const doOpen = _opener ? () => _opener(openUrl) : async () => {
|
|
524
673
|
if (shell === "app") {
|
|
525
|
-
const launched = await openInAppMode(
|
|
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(
|
|
680
|
+
await open(openUrl);
|
|
532
681
|
}
|
|
533
682
|
} else {
|
|
534
|
-
await open(
|
|
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
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
1160
|
+
if (msg.type === "ping") {
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
925
1163
|
});
|
|
926
1164
|
rl.on("close", () => {
|
|
927
1165
|
stdinClosed = true;
|