@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
|
@@ -33,13 +33,15 @@ var reactAliasPlugin = {
|
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
35
|
};
|
|
36
|
+
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>';
|
|
37
|
+
var CLOSED_OVERLAY_HTML = '<div style="font-family:sans-serif;padding:2em;color:#555"><p>This view has closed.</p></div>';
|
|
36
38
|
var SHARED_BRIDGE = `
|
|
37
39
|
async function mutate(name: string, args?: unknown): Promise<unknown> {
|
|
38
40
|
const res = await fetch("/mutate", {
|
|
39
41
|
method: "POST",
|
|
40
42
|
headers: {
|
|
41
43
|
"Content-Type": "application/json",
|
|
42
|
-
...(token ? {
|
|
44
|
+
...(token ? { "X-UI-Leaf-Token": token } : {}),
|
|
43
45
|
},
|
|
44
46
|
body: JSON.stringify({ name, args }),
|
|
45
47
|
});
|
|
@@ -61,12 +63,64 @@ async function heartbeat(): Promise<void> {
|
|
|
61
63
|
try {
|
|
62
64
|
await fetch("/heartbeat", {
|
|
63
65
|
method: "POST",
|
|
64
|
-
headers: token ? {
|
|
66
|
+
headers: token ? { "X-UI-Leaf-Token": token } : {},
|
|
65
67
|
});
|
|
66
68
|
} catch { /* server may have shut down; ignore */ }
|
|
67
69
|
}
|
|
68
70
|
setInterval(heartbeat, 5000);
|
|
69
|
-
heartbeat()
|
|
71
|
+
heartbeat();
|
|
72
|
+
|
|
73
|
+
function subscribeEvents(onEvent: (ev: { type: string; [k: string]: unknown }) => void): void {
|
|
74
|
+
let delay = 250;
|
|
75
|
+
const budget = 30_000;
|
|
76
|
+
const started = Date.now();
|
|
77
|
+
let done = false;
|
|
78
|
+
|
|
79
|
+
async function connect(): Promise<void> {
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch("/events", {
|
|
82
|
+
headers: token ? { "X-UI-Leaf-Token": token } : {},
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok || !res.body) throw new Error("bad status " + res.status);
|
|
85
|
+
delay = 250;
|
|
86
|
+
const reader = res.body.getReader();
|
|
87
|
+
const dec = new TextDecoder("utf-8");
|
|
88
|
+
let buf = "";
|
|
89
|
+
while (true) {
|
|
90
|
+
const { done: streamDone, value } = await reader.read();
|
|
91
|
+
if (streamDone) break;
|
|
92
|
+
buf += dec.decode(value, { stream: true });
|
|
93
|
+
let idx: number;
|
|
94
|
+
while ((idx = buf.indexOf("\\n\\n")) !== -1) {
|
|
95
|
+
const chunk = buf.slice(0, idx);
|
|
96
|
+
buf = buf.slice(idx + 2);
|
|
97
|
+
for (const line of chunk.split("\\n")) {
|
|
98
|
+
if (line.startsWith("data:")) {
|
|
99
|
+
try {
|
|
100
|
+
const ev = JSON.parse(line.slice(5).trimStart()) as { type: string; [k: string]: unknown };
|
|
101
|
+
if (ev.type === "closing") done = true;
|
|
102
|
+
onEvent(ev);
|
|
103
|
+
} catch { /* skip malformed event */ }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (done) return;
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
if (done) return;
|
|
111
|
+
}
|
|
112
|
+
if (done) return;
|
|
113
|
+
if (Date.now() - started > budget) {
|
|
114
|
+
onEvent({ type: "closing", reason: "error" });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await new Promise<void>((r) => setTimeout(r, delay));
|
|
118
|
+
delay = Math.min(delay * 2, 5_000);
|
|
119
|
+
void connect();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
void connect();
|
|
123
|
+
}`;
|
|
70
124
|
async function runBunBuild(entryPath) {
|
|
71
125
|
let buildOutput;
|
|
72
126
|
try {
|
|
@@ -99,21 +153,21 @@ async function runBunBuild(entryPath) {
|
|
|
99
153
|
return { js: await output.text() };
|
|
100
154
|
}
|
|
101
155
|
function assembleHtml(opts) {
|
|
102
|
-
const { js, title, csp, data,
|
|
156
|
+
const { js, title, csp, data, dataLoader } = opts;
|
|
103
157
|
const safeJs = js.replace(/<\/script>/gi, "<\\/script>");
|
|
104
158
|
const titleEscaped = title.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
105
159
|
const cspMeta = csp ? ` <meta http-equiv="Content-Security-Policy" content="${csp.replace(/&/g, "&").replace(/"/g, """)}" />
|
|
106
160
|
` : "";
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
161
|
+
const dataInit = dataLoader ? "window.__UI_LEAF__ = {};" : `window.__UI_LEAF__ = { data: JSON.parse(${escapeForScriptTag(JSON.stringify(JSON.stringify(data ?? null)))}) };`;
|
|
162
|
+
const bootstrapScript = `${dataInit}
|
|
163
|
+
(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;}})();`;
|
|
110
164
|
return `<!doctype html>
|
|
111
165
|
<html lang="en">
|
|
112
166
|
<head>
|
|
113
167
|
<meta charset="utf-8" />
|
|
114
168
|
<title>${titleEscaped}</title>
|
|
115
169
|
${cspMeta} <!-- ui-leaf bootstrap -->
|
|
116
|
-
<script
|
|
170
|
+
<script>${bootstrapScript}</script>
|
|
117
171
|
</head>
|
|
118
172
|
<body>
|
|
119
173
|
<div id="root"></div>
|
|
@@ -128,9 +182,9 @@ async function compileView(opts) {
|
|
|
128
182
|
data,
|
|
129
183
|
title = "ui-leaf",
|
|
130
184
|
csp,
|
|
131
|
-
// allowedHosts
|
|
185
|
+
// allowedHosts and token have no compile-time effect; accepted for API symmetry.
|
|
132
186
|
allowedHosts: _allowedHosts,
|
|
133
|
-
token,
|
|
187
|
+
token: _token,
|
|
134
188
|
dataLoader = false
|
|
135
189
|
} = opts;
|
|
136
190
|
const viewsRootAbs = resolve(viewsRoot);
|
|
@@ -170,41 +224,75 @@ async function compileView(opts) {
|
|
|
170
224
|
const entryContent = dataLoader ? `import { createRoot } from "react-dom/client";
|
|
171
225
|
import View from ${JSON.stringify(viewAbs)};
|
|
172
226
|
|
|
173
|
-
const ctx = (globalThis as { __UI_LEAF__?: { token?: string } }).__UI_LEAF__ ?? {};
|
|
227
|
+
const ctx = (globalThis as { __UI_LEAF__?: { token?: string; sessionEnded?: boolean } }).__UI_LEAF__ ?? {};
|
|
174
228
|
const token = ctx.token;
|
|
229
|
+
|
|
230
|
+
if (ctx.sessionEnded) {
|
|
231
|
+
const root = document.getElementById("root");
|
|
232
|
+
if (root) root.innerHTML = ${JSON.stringify(SESSION_ENDED_HTML)};
|
|
233
|
+
} else {
|
|
175
234
|
${SHARED_BRIDGE}
|
|
176
235
|
|
|
177
|
-
async function bootstrap(): Promise<void> {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
236
|
+
async function bootstrap(): Promise<void> {
|
|
237
|
+
const res = await fetch("/api/data", {
|
|
238
|
+
headers: token ? { "X-UI-Leaf-Token": token } : {},
|
|
239
|
+
});
|
|
240
|
+
if (!res.ok) {
|
|
241
|
+
const text = await res.text().catch(() => "");
|
|
242
|
+
throw new Error("ui-leaf: /api/data fetch failed (" + res.status + "): " + text);
|
|
243
|
+
}
|
|
244
|
+
let currentData: unknown = await res.json();
|
|
245
|
+
const el = document.getElementById("root");
|
|
246
|
+
if (!el) throw new Error("ui-leaf: #root element missing");
|
|
247
|
+
const root = createRoot(el);
|
|
248
|
+
root.render(<View data={currentData} mutate={mutate} />);
|
|
249
|
+
subscribeEvents((ev) => {
|
|
250
|
+
if (ev.type === "data-updated") {
|
|
251
|
+
currentData = ev.data;
|
|
252
|
+
root.render(<View data={currentData} mutate={mutate} />);
|
|
253
|
+
} else if (ev.type === "view-swapped") {
|
|
254
|
+
window.location.reload();
|
|
255
|
+
} else if (ev.type === "closing") {
|
|
256
|
+
el.innerHTML = ${JSON.stringify(CLOSED_OVERLAY_HTML)};
|
|
257
|
+
}
|
|
258
|
+
});
|
|
184
259
|
}
|
|
185
|
-
|
|
186
|
-
const el = document.getElementById("root");
|
|
187
|
-
if (!el) throw new Error("ui-leaf: #root element missing");
|
|
188
|
-
createRoot(el).render(<View data={data} mutate={mutate} />);
|
|
260
|
+
bootstrap();
|
|
189
261
|
}
|
|
190
|
-
bootstrap();
|
|
191
262
|
` : `import { createRoot } from "react-dom/client";
|
|
192
263
|
import View from ${JSON.stringify(viewAbs)};
|
|
193
264
|
|
|
194
|
-
const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string } }).__UI_LEAF__ ?? {};
|
|
195
|
-
const data = ctx.data;
|
|
265
|
+
const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string; sessionEnded?: boolean } }).__UI_LEAF__ ?? {};
|
|
196
266
|
const token = ctx.token;
|
|
267
|
+
|
|
268
|
+
if (ctx.sessionEnded) {
|
|
269
|
+
const root = document.getElementById("root");
|
|
270
|
+
if (root) root.innerHTML = ${JSON.stringify(SESSION_ENDED_HTML)};
|
|
271
|
+
} else {
|
|
272
|
+
let currentData: unknown = ctx.data;
|
|
197
273
|
${SHARED_BRIDGE}
|
|
198
274
|
|
|
199
|
-
const el = document.getElementById("root");
|
|
200
|
-
if (!el) throw new Error("ui-leaf: #root element missing");
|
|
201
|
-
|
|
275
|
+
const el = document.getElementById("root");
|
|
276
|
+
if (!el) throw new Error("ui-leaf: #root element missing");
|
|
277
|
+
const root = createRoot(el);
|
|
278
|
+
root.render(<View data={currentData} mutate={mutate} />);
|
|
279
|
+
subscribeEvents((ev) => {
|
|
280
|
+
if (ev.type === "data-updated") {
|
|
281
|
+
currentData = ev.data;
|
|
282
|
+
root.render(<View data={currentData} mutate={mutate} />);
|
|
283
|
+
} else if (ev.type === "view-swapped") {
|
|
284
|
+
window.location.reload();
|
|
285
|
+
} else if (ev.type === "closing") {
|
|
286
|
+
el.innerHTML = ${JSON.stringify(CLOSED_OVERLAY_HTML)};
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
202
290
|
`;
|
|
203
291
|
await writeFile(entryPath, entryContent);
|
|
204
292
|
const buildResult = await runBunBuild(entryPath);
|
|
205
293
|
if ("errors" in buildResult) return { html: "", errors: buildResult.errors };
|
|
206
294
|
return {
|
|
207
|
-
html: assembleHtml({ js: buildResult.js, title, csp, data,
|
|
295
|
+
html: assembleHtml({ js: buildResult.js, title, csp, data, dataLoader }),
|
|
208
296
|
errors: []
|
|
209
297
|
};
|
|
210
298
|
} finally {
|
|
@@ -212,7 +300,7 @@ createRoot(el).render(<View data={data} mutate={mutate} />);
|
|
|
212
300
|
}
|
|
213
301
|
}
|
|
214
302
|
async function compileSource(opts) {
|
|
215
|
-
const { source, data, title = "ui-leaf", csp, token } = opts;
|
|
303
|
+
const { source, data, title = "ui-leaf", csp, token: _token } = opts;
|
|
216
304
|
const tempDir = await mkdtemp(join(tmpdir(), "ui-leaf-src-"));
|
|
217
305
|
try {
|
|
218
306
|
const viewPath = join(tempDir, "view.tsx");
|
|
@@ -221,20 +309,37 @@ async function compileSource(opts) {
|
|
|
221
309
|
const entryContent = `import { createRoot } from "react-dom/client";
|
|
222
310
|
import View from ${JSON.stringify(viewPath)};
|
|
223
311
|
|
|
224
|
-
const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string } }).__UI_LEAF__ ?? {};
|
|
225
|
-
const data = ctx.data;
|
|
312
|
+
const ctx = (globalThis as { __UI_LEAF__?: { data?: unknown; token?: string; sessionEnded?: boolean } }).__UI_LEAF__ ?? {};
|
|
226
313
|
const token = ctx.token;
|
|
314
|
+
|
|
315
|
+
if (ctx.sessionEnded) {
|
|
316
|
+
const root = document.getElementById("root");
|
|
317
|
+
if (root) root.innerHTML = ${JSON.stringify(SESSION_ENDED_HTML)};
|
|
318
|
+
} else {
|
|
319
|
+
let currentData: unknown = ctx.data;
|
|
227
320
|
${SHARED_BRIDGE}
|
|
228
321
|
|
|
229
|
-
const el = document.getElementById("root");
|
|
230
|
-
if (!el) throw new Error("ui-leaf: #root element missing");
|
|
231
|
-
|
|
322
|
+
const el = document.getElementById("root");
|
|
323
|
+
if (!el) throw new Error("ui-leaf: #root element missing");
|
|
324
|
+
const root = createRoot(el);
|
|
325
|
+
root.render(<View data={currentData} mutate={mutate} />);
|
|
326
|
+
subscribeEvents((ev) => {
|
|
327
|
+
if (ev.type === "data-updated") {
|
|
328
|
+
currentData = ev.data;
|
|
329
|
+
root.render(<View data={currentData} mutate={mutate} />);
|
|
330
|
+
} else if (ev.type === "view-swapped") {
|
|
331
|
+
window.location.reload();
|
|
332
|
+
} else if (ev.type === "closing") {
|
|
333
|
+
el.innerHTML = ${JSON.stringify(CLOSED_OVERLAY_HTML)};
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
232
337
|
`;
|
|
233
338
|
await writeFile(entryPath, entryContent);
|
|
234
339
|
const buildResult = await runBunBuild(entryPath);
|
|
235
340
|
if ("errors" in buildResult) return { html: "", errors: buildResult.errors };
|
|
236
341
|
return {
|
|
237
|
-
html: assembleHtml({ js: buildResult.js, title, csp, data,
|
|
342
|
+
html: assembleHtml({ js: buildResult.js, title, csp, data, dataLoader: false }),
|
|
238
343
|
errors: []
|
|
239
344
|
};
|
|
240
345
|
} finally {
|
|
@@ -332,7 +437,8 @@ async function startDevServer(opts) {
|
|
|
332
437
|
csp,
|
|
333
438
|
allowedHosts,
|
|
334
439
|
silent = false,
|
|
335
|
-
_opener
|
|
440
|
+
_opener,
|
|
441
|
+
_heartbeatCheckIntervalMs = 1e3
|
|
336
442
|
} = opts;
|
|
337
443
|
const cspHeader = resolveCsp(csp);
|
|
338
444
|
const allowedHostSet = new Set(DEFAULT_LOOPBACK_HOSTNAMES);
|
|
@@ -342,8 +448,19 @@ async function startDevServer(opts) {
|
|
|
342
448
|
try {
|
|
343
449
|
let fireEvent2 = function(event) {
|
|
344
450
|
for (const fn of listeners.get(event)) fn();
|
|
451
|
+
}, broadcast2 = function(event) {
|
|
452
|
+
const frame = sseEncoder.encode(`data: ${JSON.stringify(event)}
|
|
453
|
+
|
|
454
|
+
`);
|
|
455
|
+
for (const controller of sseClients) {
|
|
456
|
+
try {
|
|
457
|
+
controller.enqueue(frame);
|
|
458
|
+
} catch {
|
|
459
|
+
sseClients.delete(controller);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
345
462
|
};
|
|
346
|
-
var fireEvent = fireEvent2;
|
|
463
|
+
var fireEvent = fireEvent2, broadcast = broadcast2;
|
|
347
464
|
if (view.includes("/") || view.includes("\\")) {
|
|
348
465
|
throw new Error(
|
|
349
466
|
`ui-leaf: view '${view}' must be a bare identifier with no path separators`
|
|
@@ -373,10 +490,15 @@ async function startDevServer(opts) {
|
|
|
373
490
|
const viewState = { html: result.html, data: dataLoader ? loadedData : data };
|
|
374
491
|
const listeners = /* @__PURE__ */ new Map([
|
|
375
492
|
["data-updated", /* @__PURE__ */ new Set()],
|
|
376
|
-
["view-swapped", /* @__PURE__ */ new Set()]
|
|
493
|
+
["view-swapped", /* @__PURE__ */ new Set()],
|
|
494
|
+
["disconnected", /* @__PURE__ */ new Set()],
|
|
495
|
+
["reconnected", /* @__PURE__ */ new Set()]
|
|
377
496
|
]);
|
|
497
|
+
const sseClients = /* @__PURE__ */ new Set();
|
|
498
|
+
const sseEncoder = new TextEncoder();
|
|
378
499
|
let lastHeartbeatAt = Date.now();
|
|
379
500
|
let closeRequested = false;
|
|
501
|
+
let connectionState = "connecting";
|
|
380
502
|
let resolveClosed = () => {
|
|
381
503
|
};
|
|
382
504
|
const closed = new Promise((r) => {
|
|
@@ -384,82 +506,135 @@ async function startDevServer(opts) {
|
|
|
384
506
|
});
|
|
385
507
|
const bunPort = port === void 0 ? 5810 : port;
|
|
386
508
|
let actualPort = bunPort;
|
|
387
|
-
const
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
`ui-leaf: refusing request with ${offender} \u2014 only the following hostnames are accepted to prevent DNS rebinding: ${allowedHostList}. Open the server at http://localhost:${actualPort}/ or http://127.0.0.1:${actualPort}/, or pass { allowedHosts: ["my-alias"] } to mount() to permit a custom alias.
|
|
509
|
+
const handler = (req) => {
|
|
510
|
+
const host = req.headers.get("host") ?? void 0;
|
|
511
|
+
const origin = req.headers.get("origin") ?? void 0;
|
|
512
|
+
const hostOk = isAllowedHost(host, allowedHostSet);
|
|
513
|
+
const originOk = isAllowedOrigin(origin, allowedHostSet);
|
|
514
|
+
if (!hostOk || !originOk) {
|
|
515
|
+
const offender = !hostOk ? `Host "${host ?? "(absent)"}"` : `Origin "${origin}"`;
|
|
516
|
+
return new Response(
|
|
517
|
+
`ui-leaf: refusing request with ${offender} \u2014 only the following hostnames are accepted to prevent DNS rebinding: ${allowedHostList}. Open the server at http://localhost:${actualPort}/ or http://127.0.0.1:${actualPort}/, or pass { allowedHosts: ["my-alias"] } to mount() to permit a custom alias.
|
|
397
518
|
`,
|
|
398
|
-
|
|
399
|
-
|
|
519
|
+
{ status: 403, headers: { "Content-Type": "text/plain; charset=utf-8" } }
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
const headers = {};
|
|
523
|
+
if (cspHeader) {
|
|
524
|
+
headers["Content-Security-Policy"] = cspHeader;
|
|
525
|
+
}
|
|
526
|
+
const url2 = new URL(req.url);
|
|
527
|
+
const path = url2.pathname;
|
|
528
|
+
const method = req.method;
|
|
529
|
+
if (method === "GET" && path === "/") {
|
|
530
|
+
return new Response(viewState.html, {
|
|
531
|
+
status: 200,
|
|
532
|
+
headers: { ...headers, "Content-Type": "text/html; charset=utf-8" }
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
if (method === "POST" && path === "/heartbeat") {
|
|
536
|
+
if (!checkAuth(req, token)) {
|
|
537
|
+
return new Response("", { status: 401, headers });
|
|
400
538
|
}
|
|
401
|
-
|
|
402
|
-
if (
|
|
403
|
-
|
|
539
|
+
lastHeartbeatAt = Date.now();
|
|
540
|
+
if (connectionState === "disconnected") {
|
|
541
|
+
connectionState = "connected";
|
|
542
|
+
fireEvent2("reconnected");
|
|
543
|
+
} else if (connectionState === "connecting") {
|
|
544
|
+
connectionState = "connected";
|
|
404
545
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (
|
|
409
|
-
return new Response(
|
|
410
|
-
|
|
411
|
-
|
|
546
|
+
return new Response("", { status: 204, headers });
|
|
547
|
+
}
|
|
548
|
+
if (method === "POST" && path === "/mutate") {
|
|
549
|
+
if (!checkAuth(req, token)) {
|
|
550
|
+
return new Response("", { status: 401, headers });
|
|
551
|
+
}
|
|
552
|
+
return handleMutate(req, mutations, headers);
|
|
553
|
+
}
|
|
554
|
+
if (method === "GET" && path === "/api/data") {
|
|
555
|
+
if (!dataLoader) {
|
|
556
|
+
return new Response(JSON.stringify({ error: "not found" }), {
|
|
557
|
+
status: 404,
|
|
558
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
412
559
|
});
|
|
413
560
|
}
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
417
|
-
status: 401,
|
|
418
|
-
headers: { ...headers, "Content-Type": "application/json" }
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
lastHeartbeatAt = Date.now();
|
|
422
|
-
return new Response("", { status: 204, headers });
|
|
561
|
+
if (!checkAuth(req, token)) {
|
|
562
|
+
return new Response("", { status: 401, headers });
|
|
423
563
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
return
|
|
564
|
+
return new Response(JSON.stringify(viewState.data !== void 0 ? viewState.data : null), {
|
|
565
|
+
status: 200,
|
|
566
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
if (method === "GET" && path === "/events") {
|
|
570
|
+
if (!checkAuth(req, token)) {
|
|
571
|
+
return new Response("", { status: 401, headers });
|
|
432
572
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
573
|
+
let sseController;
|
|
574
|
+
const stream = new ReadableStream({
|
|
575
|
+
start(controller) {
|
|
576
|
+
sseController = controller;
|
|
577
|
+
sseClients.add(controller);
|
|
578
|
+
controller.enqueue(sseEncoder.encode(": connected\n\n"));
|
|
579
|
+
req.signal?.addEventListener("abort", () => {
|
|
580
|
+
sseClients.delete(sseController);
|
|
581
|
+
try {
|
|
582
|
+
sseController.close();
|
|
583
|
+
} catch {
|
|
584
|
+
}
|
|
438
585
|
});
|
|
586
|
+
},
|
|
587
|
+
cancel() {
|
|
588
|
+
sseClients.delete(sseController);
|
|
439
589
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
590
|
+
});
|
|
591
|
+
return new Response(stream, {
|
|
592
|
+
status: 200,
|
|
593
|
+
headers: {
|
|
594
|
+
...headers,
|
|
595
|
+
"Content-Type": "text/event-stream",
|
|
596
|
+
"Cache-Control": "no-cache"
|
|
445
597
|
}
|
|
446
|
-
return new Response(JSON.stringify(viewState.data !== void 0 ? viewState.data : null), {
|
|
447
|
-
status: 200,
|
|
448
|
-
headers: { ...headers, "Content-Type": "application/json" }
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
|
-
return new Response(JSON.stringify({ error: "not found" }), {
|
|
452
|
-
status: 404,
|
|
453
|
-
headers: { ...headers, "Content-Type": "application/json" }
|
|
454
598
|
});
|
|
455
|
-
}
|
|
599
|
+
}
|
|
600
|
+
return new Response(JSON.stringify({ error: "not found" }), {
|
|
601
|
+
status: 404,
|
|
602
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
603
|
+
});
|
|
604
|
+
};
|
|
605
|
+
let heartbeatWatcher;
|
|
606
|
+
let bunServer;
|
|
607
|
+
const cleanup = async (reason) => {
|
|
608
|
+
if (closeRequested) return;
|
|
609
|
+
closeRequested = true;
|
|
610
|
+
if (heartbeatWatcher) clearInterval(heartbeatWatcher);
|
|
611
|
+
broadcast2({ type: "closing", reason });
|
|
612
|
+
for (const controller of sseClients) {
|
|
613
|
+
try {
|
|
614
|
+
controller.close();
|
|
615
|
+
} catch {
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
sseClients.clear();
|
|
619
|
+
await bunServer.stop();
|
|
620
|
+
if (restoreStdout) restoreStdout();
|
|
621
|
+
resolveClosed(reason);
|
|
622
|
+
};
|
|
623
|
+
const serverErrorHandler = (_err) => {
|
|
624
|
+
void cleanup("error");
|
|
625
|
+
return new Response(JSON.stringify({ error: "internal server error" }), {
|
|
626
|
+
status: 500,
|
|
627
|
+
headers: { "Content-Type": "application/json" }
|
|
628
|
+
});
|
|
629
|
+
};
|
|
630
|
+
bunServer = (() => {
|
|
456
631
|
if (bunPort === 0) {
|
|
457
|
-
return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler });
|
|
632
|
+
return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler, error: serverErrorHandler, idleTimeout: 0 });
|
|
458
633
|
}
|
|
459
634
|
const MAX_PORT_ATTEMPTS = 10;
|
|
460
635
|
for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) {
|
|
461
636
|
try {
|
|
462
|
-
return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler });
|
|
637
|
+
return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler, error: serverErrorHandler, idleTimeout: 0 });
|
|
463
638
|
} catch (err) {
|
|
464
639
|
const isAddrinuse = err instanceof Error && err.message.includes("EADDRINUSE");
|
|
465
640
|
if (!isAddrinuse || i === MAX_PORT_ATTEMPTS - 1) {
|
|
@@ -474,37 +649,33 @@ async function startDevServer(opts) {
|
|
|
474
649
|
}
|
|
475
650
|
throw new Error("unreachable");
|
|
476
651
|
})();
|
|
477
|
-
actualPort =
|
|
652
|
+
actualPort = bunServer.port ?? bunPort;
|
|
478
653
|
const url = `http://127.0.0.1:${actualPort}`;
|
|
479
654
|
const startedAt = Date.now();
|
|
480
|
-
let heartbeatWatcher;
|
|
481
|
-
const cleanup = async () => {
|
|
482
|
-
if (closeRequested) return;
|
|
483
|
-
closeRequested = true;
|
|
484
|
-
if (heartbeatWatcher) clearInterval(heartbeatWatcher);
|
|
485
|
-
await server.stop(true);
|
|
486
|
-
if (restoreStdout) restoreStdout();
|
|
487
|
-
resolveClosed();
|
|
488
|
-
};
|
|
489
655
|
heartbeatWatcher = setInterval(() => {
|
|
656
|
+
if (closeRequested) return;
|
|
490
657
|
const now = Date.now();
|
|
491
658
|
if (now - startedAt < startupGraceMs) return;
|
|
492
659
|
if (now - lastHeartbeatAt > heartbeatTimeoutMs) {
|
|
493
|
-
|
|
660
|
+
if (connectionState !== "disconnected") {
|
|
661
|
+
connectionState = "disconnected";
|
|
662
|
+
fireEvent2("disconnected");
|
|
663
|
+
}
|
|
494
664
|
}
|
|
495
|
-
},
|
|
496
|
-
const
|
|
665
|
+
}, _heartbeatCheckIntervalMs);
|
|
666
|
+
const openUrl = `${url}/#token=${token}`;
|
|
667
|
+
const doOpen = _opener ? () => _opener(openUrl) : async () => {
|
|
497
668
|
if (shell === "app") {
|
|
498
|
-
const launched = await openInAppMode(
|
|
669
|
+
const launched = await openInAppMode(openUrl);
|
|
499
670
|
if (!launched) {
|
|
500
671
|
process.stderr.write(
|
|
501
672
|
`ui-leaf: shell:"app" requested but no Chromium browser found; falling back to default browser tab.
|
|
502
673
|
`
|
|
503
674
|
);
|
|
504
|
-
await open(
|
|
675
|
+
await open(openUrl);
|
|
505
676
|
}
|
|
506
677
|
} else {
|
|
507
|
-
await open(
|
|
678
|
+
await open(openUrl);
|
|
508
679
|
}
|
|
509
680
|
};
|
|
510
681
|
if (openBrowser) {
|
|
@@ -514,7 +685,7 @@ async function startDevServer(opts) {
|
|
|
514
685
|
url,
|
|
515
686
|
port: actualPort,
|
|
516
687
|
closed,
|
|
517
|
-
close: cleanup,
|
|
688
|
+
close: (reason = "caller") => cleanup(reason),
|
|
518
689
|
on(event, listener) {
|
|
519
690
|
listeners.get(event)?.add(listener);
|
|
520
691
|
},
|
|
@@ -523,6 +694,7 @@ async function startDevServer(opts) {
|
|
|
523
694
|
},
|
|
524
695
|
update(newData) {
|
|
525
696
|
viewState.data = newData;
|
|
697
|
+
broadcast2({ type: "data-updated", data: newData });
|
|
526
698
|
fireEvent2("data-updated");
|
|
527
699
|
},
|
|
528
700
|
async swapView(source) {
|
|
@@ -535,6 +707,7 @@ async function startDevServer(opts) {
|
|
|
535
707
|
});
|
|
536
708
|
if (r.errors.length > 0) return r.errors;
|
|
537
709
|
viewState.html = r.html;
|
|
710
|
+
broadcast2({ type: "view-swapped" });
|
|
538
711
|
fireEvent2("view-swapped");
|
|
539
712
|
return [];
|
|
540
713
|
},
|
|
@@ -549,6 +722,8 @@ async function startDevServer(opts) {
|
|
|
549
722
|
if (r.errors.length > 0) return r.errors;
|
|
550
723
|
viewState.data = newData;
|
|
551
724
|
viewState.html = r.html;
|
|
725
|
+
broadcast2({ type: "data-updated", data: newData });
|
|
726
|
+
broadcast2({ type: "view-swapped" });
|
|
552
727
|
fireEvent2("data-updated");
|
|
553
728
|
fireEvent2("view-swapped");
|
|
554
729
|
return [];
|
|
@@ -563,10 +738,9 @@ async function startDevServer(opts) {
|
|
|
563
738
|
}
|
|
564
739
|
}
|
|
565
740
|
function checkAuth(req, token) {
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
return timingSafeEqual(match[1], token);
|
|
741
|
+
const value = req.headers.get("x-ui-leaf-token") ?? "";
|
|
742
|
+
if (!value) return false;
|
|
743
|
+
return timingSafeEqual(value, token);
|
|
570
744
|
}
|
|
571
745
|
async function handleMutate(req, mutations, headers) {
|
|
572
746
|
const contentLength = req.headers.get("content-length");
|
|
@@ -639,11 +813,12 @@ async function mount(opts) {
|
|
|
639
813
|
startupGraceMs: opts.startupGraceMs,
|
|
640
814
|
csp: opts.csp,
|
|
641
815
|
allowedHosts: opts.allowedHosts,
|
|
642
|
-
silent: opts.silent
|
|
816
|
+
silent: opts.silent,
|
|
817
|
+
_heartbeatCheckIntervalMs: opts._heartbeatCheckIntervalMs
|
|
643
818
|
});
|
|
644
819
|
const onSignal = (signal) => {
|
|
645
820
|
void (async () => {
|
|
646
|
-
await server.close();
|
|
821
|
+
await server.close("signal");
|
|
647
822
|
process.kill(process.pid, signal);
|
|
648
823
|
})();
|
|
649
824
|
};
|
|
@@ -659,8 +834,8 @@ async function mount(opts) {
|
|
|
659
834
|
return {
|
|
660
835
|
url: server.url,
|
|
661
836
|
port: server.port,
|
|
662
|
-
closed: Promise.resolve(),
|
|
663
|
-
close: server.close,
|
|
837
|
+
closed: Promise.resolve("caller"),
|
|
838
|
+
close: () => server.close(),
|
|
664
839
|
update: server.update.bind(server),
|
|
665
840
|
swapView: (source) => server.swapView(source),
|
|
666
841
|
patch: (data, source) => server.patch(data, source),
|
|
@@ -683,7 +858,7 @@ async function mount(opts) {
|
|
|
683
858
|
url: server.url,
|
|
684
859
|
port: server.port,
|
|
685
860
|
closed,
|
|
686
|
-
close: server.close,
|
|
861
|
+
close: () => server.close(),
|
|
687
862
|
update: server.update.bind(server),
|
|
688
863
|
swapView: (source) => server.swapView(source),
|
|
689
864
|
patch: (data, source) => server.patch(data, source),
|