@openthink/ui-leaf 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -9
- package/package.json +1 -1
- package/packages/cli/dist/cli.js +112 -83
- package/packages/cli/dist/cli.js.map +1 -1
- package/packages/cli/dist/index.d.ts +28 -19
- package/packages/cli/dist/index.js +103 -80
- 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/README.md
CHANGED
|
@@ -78,7 +78,11 @@ Three reasons:
|
|
|
78
78
|
|
|
79
79
|
## How it works
|
|
80
80
|
|
|
81
|
-
`mount()` compiles your view file once with `Bun.build`, injects data into `window.__UI_LEAF__`, starts a `Bun.serve` HTTP server, and opens the user's default browser. Mutations from the view POST back to a localhost endpoint with a per-launch random token; the runtime dispatches them to the handlers you registered.
|
|
81
|
+
`mount()` compiles your view file once with `Bun.build`, injects data into `window.__UI_LEAF__`, starts a `Bun.serve` HTTP server, and opens the user's default browser. Mutations from the view POST back to a localhost endpoint with a per-launch random token; the runtime dispatches them to the handlers you registered.
|
|
82
|
+
|
|
83
|
+
**Heartbeat and lifecycle.** The browser tab sends periodic heartbeats while the page is open. When heartbeats stop (tab closed or navigated away), `mount()` emits a `disconnected` event but the server stays running — the port, token, and in-memory data are all preserved. When a tab reconnects (heartbeat resumes), a `reconnected` event fires. Any data updates or view swaps that arrive during the disconnected window take effect on the next load. The mount terminates only on an explicit caller close (`view.close()` or the inbound `{type:"close"}` message), a signal (SIGINT/SIGTERM), or an internal error — and `view.closed` resolves to the reason (`"caller"`, `"signal"`, or `"error"`).
|
|
84
|
+
|
|
85
|
+
Multi-tab note: `disconnected` fires only when **all** open tabs go silent — the heartbeat is a single high-water mark across tabs, so closing one tab while another is open emits no event.
|
|
82
86
|
|
|
83
87
|
The transport is HTTP + JSON over loopback. The token is in `window.__UI_LEAF__.token`, served inline in the compiled HTML — so the token only protects against drive-by cross-origin requests in the user's browser, not against other processes on the same machine. Any local process that can reach `127.0.0.1:<port>` can fetch `GET /`, grep the token out, and call `/mutate` or `/api/data` with it; treat any local process you don't trust as having the same access as the view. View bundling resolves React from `ui-leaf`'s installed location, so your project doesn't need to install React.
|
|
84
88
|
|
|
@@ -237,12 +241,22 @@ What the consumer CLI is responsible for (out of ui-leaf's scope):
|
|
|
237
241
|
{"type":"result","id":1,"value":{"ok":true}}
|
|
238
242
|
{"type":"error","id":2,"message":"…"}
|
|
239
243
|
```
|
|
244
|
+
- **Or live-update / control messages:**
|
|
245
|
+
```json
|
|
246
|
+
{"version":"1","type":"update","data":{}}
|
|
247
|
+
{"version":"1","type":"view","source":"<tsx>"}
|
|
248
|
+
{"version":"1","type":"patch","data":{},"view":{"source":"<tsx>"}}
|
|
249
|
+
{"version":"1","type":"reopen"}
|
|
250
|
+
{"version":"1","type":"close"}
|
|
251
|
+
```
|
|
240
252
|
- **stdout (line-delimited JSON):**
|
|
241
253
|
- `{"type":"ready","url":"http://127.0.0.1:54321","port":54321}` — emitted once when the dev server is up
|
|
242
254
|
- `{"type":"mutate","id":1,"name":"refresh","args":{}}` — emitted when a view triggers a mutation; respond on stdin
|
|
243
|
-
- `{"type":"
|
|
255
|
+
- `{"type":"disconnected"}` — browser tab stopped heartbeating; mount stays alive
|
|
256
|
+
- `{"type":"reconnected"}` — browser reconnected after a disconnect
|
|
257
|
+
- `{"type":"closed","reason":"caller"}` — mount terminated; reason is `caller` | `signal` | `error`
|
|
244
258
|
- `{"type":"error","message":"…"}` — emitted on internal failure
|
|
245
|
-
- **Lifecycle:** binary exits 0 on
|
|
259
|
+
- **Lifecycle:** binary exits 0 on `closed`, 1 on internal error. The mount does **not** auto-terminate when the browser disconnects — only an explicit `{type:"close"}` message, stdin close, SIGINT/SIGTERM, or internal error terminates it. Closing stdin from the parent triggers a `caller` close.
|
|
246
260
|
|
|
247
261
|
### Minimal Bash example (read-only view, no mutations)
|
|
248
262
|
|
|
@@ -250,8 +264,10 @@ What the consumer CLI is responsible for (out of ui-leaf's scope):
|
|
|
250
264
|
CONFIG='{"view":"spec","viewsRoot":"/abs/path/to/views","data":{"markdown":"# hi"},"port":0}'
|
|
251
265
|
echo "$CONFIG" | ui-leaf mount
|
|
252
266
|
# → {"type":"ready","url":"http://127.0.0.1:54321","port":54321}
|
|
253
|
-
# (browser opens; user closes tab)
|
|
254
|
-
# → {"type":"
|
|
267
|
+
# (browser opens; user closes tab → disconnected; mount stays running)
|
|
268
|
+
# → {"type":"disconnected"}
|
|
269
|
+
# (send {"type":"close"} on stdin to terminate)
|
|
270
|
+
# → {"type":"closed","reason":"caller"}
|
|
255
271
|
```
|
|
256
272
|
|
|
257
273
|
### Worked example with mutations
|
|
@@ -273,10 +289,18 @@ Child → parent stdout:
|
|
|
273
289
|
Parent → child stdin (after handling the mutation):
|
|
274
290
|
{"type":"result","id":1,"value":{"count":1}}
|
|
275
291
|
|
|
276
|
-
(user closes tab)
|
|
292
|
+
(user closes tab → mount stays running)
|
|
293
|
+
|
|
294
|
+
Child → parent stdout:
|
|
295
|
+
{"type":"disconnected"}
|
|
296
|
+
|
|
297
|
+
(parent decides to shut down)
|
|
298
|
+
|
|
299
|
+
Parent → child stdin:
|
|
300
|
+
{"type":"close"}
|
|
277
301
|
|
|
278
302
|
Child → parent stdout:
|
|
279
|
-
{"type":"closed"}
|
|
303
|
+
{"type":"closed","reason":"caller"}
|
|
280
304
|
```
|
|
281
305
|
|
|
282
306
|
Each pending mutation has a unique `id`. Multiple mutations can be in flight concurrently — match `result`/`error` responses by id.
|
|
@@ -285,8 +309,8 @@ Each pending mutation has a unique `id`. Multiple mutations can be in flight con
|
|
|
285
309
|
|
|
286
310
|
- **Pass `viewsRoot` as an absolute path.** No `cwd/views` default games when invoked from another process.
|
|
287
311
|
- **Pass `port: 0`.** ui-leaf asks the OS for a free port and reports it back in the `ready` event. Lets you run concurrent views without collision.
|
|
288
|
-
- **Lower `heartbeatTimeoutMs`** (e.g. 5000)
|
|
289
|
-
- **Kill the child on parent shutdown** rather than relying on heartbeat — `kill <pid>` from the parent
|
|
312
|
+
- **Lower `heartbeatTimeoutMs`** (e.g. 5000) to get faster `disconnected` events. Note that heartbeat timeout no longer terminates the mount — the mount only terminates on an explicit `{type:"close"}` message, stdin close, or signal. If you want fast shutdown on tab close, listen for `disconnected` and send `{type:"close"}` on stdin.
|
|
313
|
+
- **Kill the child on parent shutdown** rather than relying on heartbeat — `kill <pid>` from the parent, or close stdin (which triggers a `caller` close).
|
|
290
314
|
- **Declare every mutation name** the view will call in the `mutations: []` array. The binary only routes mutations whose names appear in the list; calls to undeclared names get a 404 from `/mutate` with the standard "no mutation handler registered for X" error, and the view's `mutate()` promise rejects.
|
|
291
315
|
|
|
292
316
|
### Driving from Node via `mount()` directly
|
package/package.json
CHANGED
package/packages/cli/dist/cli.js
CHANGED
|
@@ -337,7 +337,8 @@ async function startDevServer(opts) {
|
|
|
337
337
|
csp,
|
|
338
338
|
allowedHosts,
|
|
339
339
|
silent = false,
|
|
340
|
-
_opener
|
|
340
|
+
_opener,
|
|
341
|
+
_heartbeatCheckIntervalMs = 1e3
|
|
341
342
|
} = opts;
|
|
342
343
|
const cspHeader = resolveCsp(csp);
|
|
343
344
|
const allowedHostSet = new Set(DEFAULT_LOOPBACK_HOSTNAMES);
|
|
@@ -378,10 +379,13 @@ async function startDevServer(opts) {
|
|
|
378
379
|
const viewState = { html: result.html, data: dataLoader ? loadedData : data };
|
|
379
380
|
const listeners = /* @__PURE__ */ new Map([
|
|
380
381
|
["data-updated", /* @__PURE__ */ new Set()],
|
|
381
|
-
["view-swapped", /* @__PURE__ */ new Set()]
|
|
382
|
+
["view-swapped", /* @__PURE__ */ new Set()],
|
|
383
|
+
["disconnected", /* @__PURE__ */ new Set()],
|
|
384
|
+
["reconnected", /* @__PURE__ */ new Set()]
|
|
382
385
|
]);
|
|
383
386
|
let lastHeartbeatAt = Date.now();
|
|
384
387
|
let closeRequested = false;
|
|
388
|
+
let connectionState = "connecting";
|
|
385
389
|
let resolveClosed = () => {
|
|
386
390
|
};
|
|
387
391
|
const closed = new Promise((r) => {
|
|
@@ -389,82 +393,105 @@ async function startDevServer(opts) {
|
|
|
389
393
|
});
|
|
390
394
|
const bunPort = port === void 0 ? 5810 : port;
|
|
391
395
|
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.
|
|
396
|
+
const handler = (req) => {
|
|
397
|
+
const host = req.headers.get("host") ?? void 0;
|
|
398
|
+
const origin = req.headers.get("origin") ?? void 0;
|
|
399
|
+
const hostOk = isAllowedHost(host, allowedHostSet);
|
|
400
|
+
const originOk = isAllowedOrigin(origin, allowedHostSet);
|
|
401
|
+
if (!hostOk || !originOk) {
|
|
402
|
+
const offender = !hostOk ? `Host "${host ?? "(absent)"}"` : `Origin "${origin}"`;
|
|
403
|
+
return new Response(
|
|
404
|
+
`ui-leaf: refusing request with ${offender} \u2014 only the following hostnames are accepted to prevent DNS rebinding: ${allowedHostList}. Open the server at http://localhost:${actualPort}/ or http://127.0.0.1:${actualPort}/, or pass { allowedHosts: ["my-alias"] } to mount() to permit a custom alias.
|
|
402
405
|
`,
|
|
403
|
-
|
|
404
|
-
|
|
406
|
+
{ status: 403, headers: { "Content-Type": "text/plain; charset=utf-8" } }
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
const headers = {};
|
|
410
|
+
if (cspHeader) {
|
|
411
|
+
headers["Content-Security-Policy"] = cspHeader;
|
|
412
|
+
}
|
|
413
|
+
const url2 = new URL(req.url);
|
|
414
|
+
const path = url2.pathname;
|
|
415
|
+
const method = req.method;
|
|
416
|
+
if (method === "GET" && path === "/") {
|
|
417
|
+
return new Response(viewState.html, {
|
|
418
|
+
status: 200,
|
|
419
|
+
headers: { ...headers, "Content-Type": "text/html; charset=utf-8" }
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
if (method === "POST" && path === "/heartbeat") {
|
|
423
|
+
if (!checkAuth(req, token)) {
|
|
424
|
+
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
425
|
+
status: 401,
|
|
426
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
427
|
+
});
|
|
405
428
|
}
|
|
406
|
-
|
|
407
|
-
if (
|
|
408
|
-
|
|
429
|
+
lastHeartbeatAt = Date.now();
|
|
430
|
+
if (connectionState === "disconnected") {
|
|
431
|
+
connectionState = "connected";
|
|
432
|
+
fireEvent2("reconnected");
|
|
433
|
+
} else if (connectionState === "connecting") {
|
|
434
|
+
connectionState = "connected";
|
|
409
435
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if (
|
|
414
|
-
return new Response(
|
|
415
|
-
status:
|
|
416
|
-
headers: { ...headers, "Content-Type": "
|
|
436
|
+
return new Response("", { status: 204, headers });
|
|
437
|
+
}
|
|
438
|
+
if (method === "POST" && path === "/mutate") {
|
|
439
|
+
if (!checkAuth(req, token)) {
|
|
440
|
+
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
441
|
+
status: 401,
|
|
442
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
417
443
|
});
|
|
418
444
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
return new Response("", { status: 204, headers });
|
|
428
|
-
}
|
|
429
|
-
if (method === "POST" && path === "/mutate") {
|
|
430
|
-
if (!checkAuth(req, token)) {
|
|
431
|
-
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
432
|
-
status: 401,
|
|
433
|
-
headers: { ...headers, "Content-Type": "application/json" }
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
return handleMutate(req, mutations, headers);
|
|
445
|
+
return handleMutate(req, mutations, headers);
|
|
446
|
+
}
|
|
447
|
+
if (method === "GET" && path === "/api/data") {
|
|
448
|
+
if (!dataLoader) {
|
|
449
|
+
return new Response(JSON.stringify({ error: "not found" }), {
|
|
450
|
+
status: 404,
|
|
451
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
452
|
+
});
|
|
437
453
|
}
|
|
438
|
-
if (
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
status: 404,
|
|
442
|
-
headers: { ...headers, "Content-Type": "application/json" }
|
|
443
|
-
});
|
|
444
|
-
}
|
|
445
|
-
if (!checkAuth(req, token)) {
|
|
446
|
-
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
447
|
-
status: 401,
|
|
448
|
-
headers: { ...headers, "Content-Type": "application/json" }
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
|
-
return new Response(JSON.stringify(viewState.data !== void 0 ? viewState.data : null), {
|
|
452
|
-
status: 200,
|
|
454
|
+
if (!checkAuth(req, token)) {
|
|
455
|
+
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
456
|
+
status: 401,
|
|
453
457
|
headers: { ...headers, "Content-Type": "application/json" }
|
|
454
458
|
});
|
|
455
459
|
}
|
|
456
|
-
return new Response(JSON.stringify(
|
|
457
|
-
status:
|
|
460
|
+
return new Response(JSON.stringify(viewState.data !== void 0 ? viewState.data : null), {
|
|
461
|
+
status: 200,
|
|
458
462
|
headers: { ...headers, "Content-Type": "application/json" }
|
|
459
463
|
});
|
|
460
|
-
}
|
|
464
|
+
}
|
|
465
|
+
return new Response(JSON.stringify({ error: "not found" }), {
|
|
466
|
+
status: 404,
|
|
467
|
+
headers: { ...headers, "Content-Type": "application/json" }
|
|
468
|
+
});
|
|
469
|
+
};
|
|
470
|
+
let heartbeatWatcher;
|
|
471
|
+
let bunServer;
|
|
472
|
+
const cleanup = async (reason) => {
|
|
473
|
+
if (closeRequested) return;
|
|
474
|
+
closeRequested = true;
|
|
475
|
+
if (heartbeatWatcher) clearInterval(heartbeatWatcher);
|
|
476
|
+
await bunServer.stop(true);
|
|
477
|
+
if (restoreStdout) restoreStdout();
|
|
478
|
+
resolveClosed(reason);
|
|
479
|
+
};
|
|
480
|
+
const serverErrorHandler = (_err) => {
|
|
481
|
+
void cleanup("error");
|
|
482
|
+
return new Response(JSON.stringify({ error: "internal server error" }), {
|
|
483
|
+
status: 500,
|
|
484
|
+
headers: { "Content-Type": "application/json" }
|
|
485
|
+
});
|
|
486
|
+
};
|
|
487
|
+
bunServer = (() => {
|
|
461
488
|
if (bunPort === 0) {
|
|
462
|
-
return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler });
|
|
489
|
+
return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler, error: serverErrorHandler });
|
|
463
490
|
}
|
|
464
491
|
const MAX_PORT_ATTEMPTS = 10;
|
|
465
492
|
for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) {
|
|
466
493
|
try {
|
|
467
|
-
return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler });
|
|
494
|
+
return Bun.serve({ hostname: "127.0.0.1", port: bunPort + i, fetch: handler, error: serverErrorHandler });
|
|
468
495
|
} catch (err) {
|
|
469
496
|
const isAddrinuse = err instanceof Error && err.message.includes("EADDRINUSE");
|
|
470
497
|
if (!isAddrinuse || i === MAX_PORT_ATTEMPTS - 1) {
|
|
@@ -479,25 +506,20 @@ async function startDevServer(opts) {
|
|
|
479
506
|
}
|
|
480
507
|
throw new Error("unreachable");
|
|
481
508
|
})();
|
|
482
|
-
actualPort =
|
|
509
|
+
actualPort = bunServer.port ?? bunPort;
|
|
483
510
|
const url = `http://127.0.0.1:${actualPort}`;
|
|
484
511
|
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
512
|
heartbeatWatcher = setInterval(() => {
|
|
513
|
+
if (closeRequested) return;
|
|
495
514
|
const now = Date.now();
|
|
496
515
|
if (now - startedAt < startupGraceMs) return;
|
|
497
516
|
if (now - lastHeartbeatAt > heartbeatTimeoutMs) {
|
|
498
|
-
|
|
517
|
+
if (connectionState !== "disconnected") {
|
|
518
|
+
connectionState = "disconnected";
|
|
519
|
+
fireEvent2("disconnected");
|
|
520
|
+
}
|
|
499
521
|
}
|
|
500
|
-
},
|
|
522
|
+
}, _heartbeatCheckIntervalMs);
|
|
501
523
|
const doOpen = _opener ? () => _opener(url) : async () => {
|
|
502
524
|
if (shell === "app") {
|
|
503
525
|
const launched = await openInAppMode(url);
|
|
@@ -519,7 +541,7 @@ async function startDevServer(opts) {
|
|
|
519
541
|
url,
|
|
520
542
|
port: actualPort,
|
|
521
543
|
closed,
|
|
522
|
-
close: cleanup,
|
|
544
|
+
close: (reason = "caller") => cleanup(reason),
|
|
523
545
|
on(event, listener) {
|
|
524
546
|
listeners.get(event)?.add(listener);
|
|
525
547
|
},
|
|
@@ -644,11 +666,12 @@ async function mount(opts) {
|
|
|
644
666
|
startupGraceMs: opts.startupGraceMs,
|
|
645
667
|
csp: opts.csp,
|
|
646
668
|
allowedHosts: opts.allowedHosts,
|
|
647
|
-
silent: opts.silent
|
|
669
|
+
silent: opts.silent,
|
|
670
|
+
_heartbeatCheckIntervalMs: opts._heartbeatCheckIntervalMs
|
|
648
671
|
});
|
|
649
672
|
const onSignal = (signal) => {
|
|
650
673
|
void (async () => {
|
|
651
|
-
await server.close();
|
|
674
|
+
await server.close("signal");
|
|
652
675
|
process.kill(process.pid, signal);
|
|
653
676
|
})();
|
|
654
677
|
};
|
|
@@ -664,8 +687,8 @@ async function mount(opts) {
|
|
|
664
687
|
return {
|
|
665
688
|
url: server.url,
|
|
666
689
|
port: server.port,
|
|
667
|
-
closed: Promise.resolve(),
|
|
668
|
-
close: server.close,
|
|
690
|
+
closed: Promise.resolve("caller"),
|
|
691
|
+
close: () => server.close(),
|
|
669
692
|
update: server.update.bind(server),
|
|
670
693
|
swapView: (source) => server.swapView(source),
|
|
671
694
|
patch: (data, source) => server.patch(data, source),
|
|
@@ -688,7 +711,7 @@ async function mount(opts) {
|
|
|
688
711
|
url: server.url,
|
|
689
712
|
port: server.port,
|
|
690
713
|
closed,
|
|
691
|
-
close: server.close,
|
|
714
|
+
close: () => server.close(),
|
|
692
715
|
update: server.update.bind(server),
|
|
693
716
|
swapView: (source) => server.swapView(source),
|
|
694
717
|
patch: (data, source) => server.patch(data, source),
|
|
@@ -894,6 +917,10 @@ async function runMount() {
|
|
|
894
917
|
});
|
|
895
918
|
return;
|
|
896
919
|
}
|
|
920
|
+
if (msg.type === "close") {
|
|
921
|
+
void mountedView.close();
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
897
924
|
emit2({ type: "error", message: `unknown message type: ${msg.type}` });
|
|
898
925
|
});
|
|
899
926
|
rl.on("close", () => {
|
|
@@ -942,10 +969,12 @@ async function runMount() {
|
|
|
942
969
|
if (stdinClosed) {
|
|
943
970
|
void view.close();
|
|
944
971
|
}
|
|
972
|
+
view.on("disconnected", () => emit2({ type: "disconnected" }));
|
|
973
|
+
view.on("reconnected", () => emit2({ type: "reconnected" }));
|
|
945
974
|
emit2({ type: "ready", url: view.url, port: view.port });
|
|
946
|
-
await view.closed;
|
|
947
|
-
emit2({ type: "closed" });
|
|
948
|
-
process.exit(0);
|
|
975
|
+
const closeReason = await view.closed;
|
|
976
|
+
emit2({ type: "closed", reason: closeReason });
|
|
977
|
+
process.exit(closeReason === "error" ? 1 : 0);
|
|
949
978
|
} catch (err) {
|
|
950
979
|
emit2({
|
|
951
980
|
type: "error",
|