@openthink/ui-leaf 0.5.0 → 0.6.1
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 +33 -9
- package/package.json +1 -1
- package/packages/cli/dist/cli.js +309 -128
- package/packages/cli/dist/cli.js.map +1 -1
- package/packages/cli/dist/index.d.ts +28 -19
- package/packages/cli/dist/index.js +300 -125
- package/packages/cli/dist/index.js.map +1 -1
- package/packages/cli/dist/{server-Bp6cms3O.d.ts → server-3vbR-tuu.d.ts} +1 -1
- package/packages/cli/dist/view.d.ts +1 -1
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 {
|
|
@@ -337,7 +442,8 @@ async function startDevServer(opts) {
|
|
|
337
442
|
csp,
|
|
338
443
|
allowedHosts,
|
|
339
444
|
silent = false,
|
|
340
|
-
_opener
|
|
445
|
+
_opener,
|
|
446
|
+
_heartbeatCheckIntervalMs = 1e3
|
|
341
447
|
} = opts;
|
|
342
448
|
const cspHeader = resolveCsp(csp);
|
|
343
449
|
const allowedHostSet = new Set(DEFAULT_LOOPBACK_HOSTNAMES);
|
|
@@ -347,8 +453,19 @@ async function startDevServer(opts) {
|
|
|
347
453
|
try {
|
|
348
454
|
let fireEvent2 = function(event) {
|
|
349
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
|
+
}
|
|
350
467
|
};
|
|
351
|
-
var fireEvent = fireEvent2;
|
|
468
|
+
var fireEvent = fireEvent2, broadcast = broadcast2;
|
|
352
469
|
if (view.includes("/") || view.includes("\\")) {
|
|
353
470
|
throw new Error(
|
|
354
471
|
`ui-leaf: view '${view}' must be a bare identifier with no path separators`
|
|
@@ -378,10 +495,15 @@ async function startDevServer(opts) {
|
|
|
378
495
|
const viewState = { html: result.html, data: dataLoader ? loadedData : data };
|
|
379
496
|
const listeners = /* @__PURE__ */ new Map([
|
|
380
497
|
["data-updated", /* @__PURE__ */ new Set()],
|
|
381
|
-
["view-swapped", /* @__PURE__ */ new Set()]
|
|
498
|
+
["view-swapped", /* @__PURE__ */ new Set()],
|
|
499
|
+
["disconnected", /* @__PURE__ */ new Set()],
|
|
500
|
+
["reconnected", /* @__PURE__ */ new Set()]
|
|
382
501
|
]);
|
|
502
|
+
const sseClients = /* @__PURE__ */ new Set();
|
|
503
|
+
const sseEncoder = new TextEncoder();
|
|
383
504
|
let lastHeartbeatAt = Date.now();
|
|
384
505
|
let closeRequested = false;
|
|
506
|
+
let connectionState = "connecting";
|
|
385
507
|
let resolveClosed = () => {
|
|
386
508
|
};
|
|
387
509
|
const closed = new Promise((r) => {
|
|
@@ -389,82 +511,135 @@ async function startDevServer(opts) {
|
|
|
389
511
|
});
|
|
390
512
|
const bunPort = port === void 0 ? 5810 : port;
|
|
391
513
|
let actualPort = bunPort;
|
|
392
|
-
const
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
`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.
|
|
514
|
+
const handler = (req) => {
|
|
515
|
+
const host = req.headers.get("host") ?? void 0;
|
|
516
|
+
const origin = req.headers.get("origin") ?? void 0;
|
|
517
|
+
const hostOk = isAllowedHost(host, allowedHostSet);
|
|
518
|
+
const originOk = isAllowedOrigin(origin, allowedHostSet);
|
|
519
|
+
if (!hostOk || !originOk) {
|
|
520
|
+
const offender = !hostOk ? `Host "${host ?? "(absent)"}"` : `Origin "${origin}"`;
|
|
521
|
+
return new Response(
|
|
522
|
+
`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.
|
|
402
523
|
`,
|
|
403
|
-
|
|
404
|
-
|
|
524
|
+
{ status: 403, headers: { "Content-Type": "text/plain; charset=utf-8" } }
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const headers = {};
|
|
528
|
+
if (cspHeader) {
|
|
529
|
+
headers["Content-Security-Policy"] = cspHeader;
|
|
530
|
+
}
|
|
531
|
+
const url2 = new URL(req.url);
|
|
532
|
+
const path = url2.pathname;
|
|
533
|
+
const method = req.method;
|
|
534
|
+
if (method === "GET" && path === "/") {
|
|
535
|
+
return new Response(viewState.html, {
|
|
536
|
+
status: 200,
|
|
537
|
+
headers: { ...headers, "Content-Type": "text/html; charset=utf-8" }
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
if (method === "POST" && path === "/heartbeat") {
|
|
541
|
+
if (!checkAuth(req, token)) {
|
|
542
|
+
return new Response("", { status: 401, headers });
|
|
405
543
|
}
|
|
406
|
-
|
|
407
|
-
if (
|
|
408
|
-
|
|
544
|
+
lastHeartbeatAt = Date.now();
|
|
545
|
+
if (connectionState === "disconnected") {
|
|
546
|
+
connectionState = "connected";
|
|
547
|
+
fireEvent2("reconnected");
|
|
548
|
+
} else if (connectionState === "connecting") {
|
|
549
|
+
connectionState = "connected";
|
|
409
550
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if (
|
|
414
|
-
return new Response(
|
|
415
|
-
|
|
416
|
-
|
|
551
|
+
return new Response("", { status: 204, headers });
|
|
552
|
+
}
|
|
553
|
+
if (method === "POST" && path === "/mutate") {
|
|
554
|
+
if (!checkAuth(req, token)) {
|
|
555
|
+
return new Response("", { status: 401, headers });
|
|
556
|
+
}
|
|
557
|
+
return handleMutate(req, mutations, headers);
|
|
558
|
+
}
|
|
559
|
+
if (method === "GET" && path === "/api/data") {
|
|
560
|
+
if (!dataLoader) {
|
|
561
|
+
return new Response(JSON.stringify({ error: "not found" }), {
|
|
562
|
+
status: 404,
|
|
563
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
417
564
|
});
|
|
418
565
|
}
|
|
419
|
-
if (
|
|
420
|
-
|
|
421
|
-
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
422
|
-
status: 401,
|
|
423
|
-
headers: { ...headers, "Content-Type": "application/json" }
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
lastHeartbeatAt = Date.now();
|
|
427
|
-
return new Response("", { status: 204, headers });
|
|
566
|
+
if (!checkAuth(req, token)) {
|
|
567
|
+
return new Response("", { status: 401, headers });
|
|
428
568
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
return
|
|
569
|
+
return new Response(JSON.stringify(viewState.data !== void 0 ? viewState.data : null), {
|
|
570
|
+
status: 200,
|
|
571
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
if (method === "GET" && path === "/events") {
|
|
575
|
+
if (!checkAuth(req, token)) {
|
|
576
|
+
return new Response("", { status: 401, headers });
|
|
437
577
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
+
}
|
|
443
590
|
});
|
|
591
|
+
},
|
|
592
|
+
cancel() {
|
|
593
|
+
sseClients.delete(sseController);
|
|
444
594
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
595
|
+
});
|
|
596
|
+
return new Response(stream, {
|
|
597
|
+
status: 200,
|
|
598
|
+
headers: {
|
|
599
|
+
...headers,
|
|
600
|
+
"Content-Type": "text/event-stream",
|
|
601
|
+
"Cache-Control": "no-cache"
|
|
450
602
|
}
|
|
451
|
-
return new Response(JSON.stringify(viewState.data !== void 0 ? viewState.data : null), {
|
|
452
|
-
status: 200,
|
|
453
|
-
headers: { ...headers, "Content-Type": "application/json" }
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
return new Response(JSON.stringify({ error: "not found" }), {
|
|
457
|
-
status: 404,
|
|
458
|
-
headers: { ...headers, "Content-Type": "application/json" }
|
|
459
603
|
});
|
|
460
|
-
}
|
|
604
|
+
}
|
|
605
|
+
return new Response(JSON.stringify({ error: "not found" }), {
|
|
606
|
+
status: 404,
|
|
607
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
608
|
+
});
|
|
609
|
+
};
|
|
610
|
+
let heartbeatWatcher;
|
|
611
|
+
let bunServer;
|
|
612
|
+
const cleanup = async (reason) => {
|
|
613
|
+
if (closeRequested) return;
|
|
614
|
+
closeRequested = true;
|
|
615
|
+
if (heartbeatWatcher) clearInterval(heartbeatWatcher);
|
|
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();
|
|
625
|
+
if (restoreStdout) restoreStdout();
|
|
626
|
+
resolveClosed(reason);
|
|
627
|
+
};
|
|
628
|
+
const serverErrorHandler = (_err) => {
|
|
629
|
+
void cleanup("error");
|
|
630
|
+
return new Response(JSON.stringify({ error: "internal server error" }), {
|
|
631
|
+
status: 500,
|
|
632
|
+
headers: { "Content-Type": "application/json" }
|
|
633
|
+
});
|
|
634
|
+
};
|
|
635
|
+
bunServer = (() => {
|
|
461
636
|
if (bunPort === 0) {
|
|
462
|
-
return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler });
|
|
637
|
+
return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler, error: serverErrorHandler, idleTimeout: 0 });
|
|
463
638
|
}
|
|
464
639
|
const MAX_PORT_ATTEMPTS = 10;
|
|
465
640
|
for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) {
|
|
466
641
|
try {
|
|
467
|
-
return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler });
|
|
642
|
+
return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler, error: serverErrorHandler, idleTimeout: 0 });
|
|
468
643
|
} catch (err) {
|
|
469
644
|
const isAddrinuse = err instanceof Error && err.message.includes("EADDRINUSE");
|
|
470
645
|
if (!isAddrinuse || i === MAX_PORT_ATTEMPTS - 1) {
|
|
@@ -479,37 +654,33 @@ async function startDevServer(opts) {
|
|
|
479
654
|
}
|
|
480
655
|
throw new Error("unreachable");
|
|
481
656
|
})();
|
|
482
|
-
actualPort =
|
|
657
|
+
actualPort = bunServer.port ?? bunPort;
|
|
483
658
|
const url = `http://127.0.0.1:${actualPort}`;
|
|
484
659
|
const startedAt = Date.now();
|
|
485
|
-
let heartbeatWatcher;
|
|
486
|
-
const cleanup = async () => {
|
|
487
|
-
if (closeRequested) return;
|
|
488
|
-
closeRequested = true;
|
|
489
|
-
if (heartbeatWatcher) clearInterval(heartbeatWatcher);
|
|
490
|
-
await server.stop(true);
|
|
491
|
-
if (restoreStdout) restoreStdout();
|
|
492
|
-
resolveClosed();
|
|
493
|
-
};
|
|
494
660
|
heartbeatWatcher = setInterval(() => {
|
|
661
|
+
if (closeRequested) return;
|
|
495
662
|
const now = Date.now();
|
|
496
663
|
if (now - startedAt < startupGraceMs) return;
|
|
497
664
|
if (now - lastHeartbeatAt > heartbeatTimeoutMs) {
|
|
498
|
-
|
|
665
|
+
if (connectionState !== "disconnected") {
|
|
666
|
+
connectionState = "disconnected";
|
|
667
|
+
fireEvent2("disconnected");
|
|
668
|
+
}
|
|
499
669
|
}
|
|
500
|
-
},
|
|
501
|
-
const
|
|
670
|
+
}, _heartbeatCheckIntervalMs);
|
|
671
|
+
const openUrl = `${url}/#token=${token}`;
|
|
672
|
+
const doOpen = _opener ? () => _opener(openUrl) : async () => {
|
|
502
673
|
if (shell === "app") {
|
|
503
|
-
const launched = await openInAppMode(
|
|
674
|
+
const launched = await openInAppMode(openUrl);
|
|
504
675
|
if (!launched) {
|
|
505
676
|
process.stderr.write(
|
|
506
677
|
`ui-leaf: shell:"app" requested but no Chromium browser found; falling back to default browser tab.
|
|
507
678
|
`
|
|
508
679
|
);
|
|
509
|
-
await open(
|
|
680
|
+
await open(openUrl);
|
|
510
681
|
}
|
|
511
682
|
} else {
|
|
512
|
-
await open(
|
|
683
|
+
await open(openUrl);
|
|
513
684
|
}
|
|
514
685
|
};
|
|
515
686
|
if (openBrowser) {
|
|
@@ -519,7 +690,7 @@ async function startDevServer(opts) {
|
|
|
519
690
|
url,
|
|
520
691
|
port: actualPort,
|
|
521
692
|
closed,
|
|
522
|
-
close: cleanup,
|
|
693
|
+
close: (reason = "caller") => cleanup(reason),
|
|
523
694
|
on(event, listener) {
|
|
524
695
|
listeners.get(event)?.add(listener);
|
|
525
696
|
},
|
|
@@ -528,6 +699,7 @@ async function startDevServer(opts) {
|
|
|
528
699
|
},
|
|
529
700
|
update(newData) {
|
|
530
701
|
viewState.data = newData;
|
|
702
|
+
broadcast2({ type: "data-updated", data: newData });
|
|
531
703
|
fireEvent2("data-updated");
|
|
532
704
|
},
|
|
533
705
|
async swapView(source) {
|
|
@@ -540,6 +712,7 @@ async function startDevServer(opts) {
|
|
|
540
712
|
});
|
|
541
713
|
if (r.errors.length > 0) return r.errors;
|
|
542
714
|
viewState.html = r.html;
|
|
715
|
+
broadcast2({ type: "view-swapped" });
|
|
543
716
|
fireEvent2("view-swapped");
|
|
544
717
|
return [];
|
|
545
718
|
},
|
|
@@ -554,6 +727,8 @@ async function startDevServer(opts) {
|
|
|
554
727
|
if (r.errors.length > 0) return r.errors;
|
|
555
728
|
viewState.data = newData;
|
|
556
729
|
viewState.html = r.html;
|
|
730
|
+
broadcast2({ type: "data-updated", data: newData });
|
|
731
|
+
broadcast2({ type: "view-swapped" });
|
|
557
732
|
fireEvent2("data-updated");
|
|
558
733
|
fireEvent2("view-swapped");
|
|
559
734
|
return [];
|
|
@@ -568,10 +743,9 @@ async function startDevServer(opts) {
|
|
|
568
743
|
}
|
|
569
744
|
}
|
|
570
745
|
function checkAuth(req, token) {
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
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);
|
|
575
749
|
}
|
|
576
750
|
async function handleMutate(req, mutations, headers) {
|
|
577
751
|
const contentLength = req.headers.get("content-length");
|
|
@@ -644,11 +818,12 @@ async function mount(opts) {
|
|
|
644
818
|
startupGraceMs: opts.startupGraceMs,
|
|
645
819
|
csp: opts.csp,
|
|
646
820
|
allowedHosts: opts.allowedHosts,
|
|
647
|
-
silent: opts.silent
|
|
821
|
+
silent: opts.silent,
|
|
822
|
+
_heartbeatCheckIntervalMs: opts._heartbeatCheckIntervalMs
|
|
648
823
|
});
|
|
649
824
|
const onSignal = (signal) => {
|
|
650
825
|
void (async () => {
|
|
651
|
-
await server.close();
|
|
826
|
+
await server.close("signal");
|
|
652
827
|
process.kill(process.pid, signal);
|
|
653
828
|
})();
|
|
654
829
|
};
|
|
@@ -664,8 +839,8 @@ async function mount(opts) {
|
|
|
664
839
|
return {
|
|
665
840
|
url: server.url,
|
|
666
841
|
port: server.port,
|
|
667
|
-
closed: Promise.resolve(),
|
|
668
|
-
close: server.close,
|
|
842
|
+
closed: Promise.resolve("caller"),
|
|
843
|
+
close: () => server.close(),
|
|
669
844
|
update: server.update.bind(server),
|
|
670
845
|
swapView: (source) => server.swapView(source),
|
|
671
846
|
patch: (data, source) => server.patch(data, source),
|
|
@@ -688,7 +863,7 @@ async function mount(opts) {
|
|
|
688
863
|
url: server.url,
|
|
689
864
|
port: server.port,
|
|
690
865
|
closed,
|
|
691
|
-
close: server.close,
|
|
866
|
+
close: () => server.close(),
|
|
692
867
|
update: server.update.bind(server),
|
|
693
868
|
swapView: (source) => server.swapView(source),
|
|
694
869
|
patch: (data, source) => server.patch(data, source),
|
|
@@ -894,6 +1069,10 @@ async function runMount() {
|
|
|
894
1069
|
});
|
|
895
1070
|
return;
|
|
896
1071
|
}
|
|
1072
|
+
if (msg.type === "close") {
|
|
1073
|
+
void mountedView.close();
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
897
1076
|
emit2({ type: "error", message: `unknown message type: ${msg.type}` });
|
|
898
1077
|
});
|
|
899
1078
|
rl.on("close", () => {
|
|
@@ -942,10 +1121,12 @@ async function runMount() {
|
|
|
942
1121
|
if (stdinClosed) {
|
|
943
1122
|
void view.close();
|
|
944
1123
|
}
|
|
1124
|
+
view.on("disconnected", () => emit2({ type: "disconnected" }));
|
|
1125
|
+
view.on("reconnected", () => emit2({ type: "reconnected" }));
|
|
945
1126
|
emit2({ type: "ready", url: view.url, port: view.port });
|
|
946
|
-
await view.closed;
|
|
947
|
-
emit2({ type: "closed" });
|
|
948
|
-
process.exit(0);
|
|
1127
|
+
const closeReason = await view.closed;
|
|
1128
|
+
emit2({ type: "closed", reason: closeReason });
|
|
1129
|
+
process.exit(closeReason === "error" ? 1 : 0);
|
|
949
1130
|
} catch (err) {
|
|
950
1131
|
emit2({
|
|
951
1132
|
type: "error",
|