@evolu/web 3.1.2 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,11 @@
1
1
  import {
2
+ AbortError,
3
+ assert,
2
4
  assertEqual,
3
5
  assertEqualBytes,
6
+ assertSame,
4
7
  assertTrue,
8
+ assertType,
5
9
  EncryptionKey,
6
10
  Name,
7
11
  sql,
@@ -9,6 +13,90 @@ import {
9
13
  } from "@evolu/common";
10
14
  import { describe, it, mock } from "node:test";
11
15
 
16
+ // OPFS with an optional pool directory. Each pool file lists the error names
17
+ // its next openings reject with, in order.
18
+ const opfsMock = (() => {
19
+ const state = {
20
+ closedAccessHandleCount: 0,
21
+ directoryNames: [] as Array<string>,
22
+ getDirectoryCount: 0,
23
+ hasOpaqueDirectory: true,
24
+ openedAccessHandleCount: 0,
25
+ poolFiles: null as Array<Array<string>> | null,
26
+ };
27
+
28
+ const opaqueDirectory = {
29
+ // for await also iterates a sync iterable.
30
+ values: function* () {
31
+ yield { kind: "directory" };
32
+ for (const errorNames of state.poolFiles ?? [])
33
+ yield {
34
+ kind: "file",
35
+ createSyncAccessHandle: () => {
36
+ const errorName = errorNames.shift();
37
+ if (errorName !== undefined)
38
+ return Promise.reject(new DOMException("Held", errorName));
39
+ state.openedAccessHandleCount += 1;
40
+ return Promise.resolve({
41
+ close: () => {
42
+ state.closedAccessHandleCount += 1;
43
+ },
44
+ });
45
+ },
46
+ };
47
+ },
48
+ };
49
+
50
+ const poolDirectory = {
51
+ getDirectoryHandle: (name: string) => {
52
+ state.directoryNames.push(name);
53
+ return state.hasOpaqueDirectory
54
+ ? Promise.resolve(opaqueDirectory)
55
+ : Promise.reject(new DOMException("Missing", "NotFoundError"));
56
+ },
57
+ };
58
+
59
+ const root = {
60
+ getDirectoryHandle: (name: string) => {
61
+ state.directoryNames.push(name);
62
+ return state.poolFiles
63
+ ? Promise.resolve(poolDirectory)
64
+ : Promise.reject(new DOMException("Missing", "NotFoundError"));
65
+ },
66
+ };
67
+
68
+ Object.defineProperty(navigator, "storage", {
69
+ configurable: true,
70
+ value: {
71
+ getDirectory: () => {
72
+ state.getDirectoryCount += 1;
73
+ return Promise.resolve(root);
74
+ },
75
+ },
76
+ });
77
+
78
+ return {
79
+ reset: (
80
+ poolFiles: Array<Array<string>> | null = null,
81
+ { hasOpaqueDirectory = true } = {},
82
+ ) => {
83
+ state.closedAccessHandleCount = 0;
84
+ state.directoryNames.length = 0;
85
+ state.getDirectoryCount = 0;
86
+ state.hasOpaqueDirectory = hasOpaqueDirectory;
87
+ state.openedAccessHandleCount = 0;
88
+ state.poolFiles = poolFiles;
89
+ },
90
+ state,
91
+ };
92
+ })();
93
+
94
+ // Lets resolved mocks settle before test time advances.
95
+ const flushMicrotasks = (): Promise<void> =>
96
+ new Promise((resolve) => {
97
+ setImmediate(resolve);
98
+ });
99
+
12
100
  const sqliteMock = (() => {
13
101
  class PreparedStatement {
14
102
  finalized = false;
@@ -29,10 +117,6 @@ const sqliteMock = (() => {
29
117
  return { data: "prepared" };
30
118
  }
31
119
 
32
- reset(): void {
33
- this.resetCount += 1;
34
- }
35
-
36
120
  finalize(): void {
37
121
  this.finalized = true;
38
122
  }
@@ -105,6 +189,10 @@ const sqliteMock = (() => {
105
189
  const sqlite3 = {
106
190
  capi: {
107
191
  sqlite3_js_db_export: mock.fn(() => new Uint8Array([1, 2, 3])),
192
+ sqlite3_reset: mock.fn((statement: PreparedStatement) => {
193
+ statement.resetCount += 1;
194
+ return 0;
195
+ }),
108
196
  sqlite3mc_vfs_create: mock.fn(),
109
197
  },
110
198
  installOpfsSAHPoolVfs: mock.fn(() => Promise.resolve(pool)),
@@ -115,6 +203,7 @@ const sqliteMock = (() => {
115
203
  consoleWarn: mock.fn<typeof console.warn>(),
116
204
  pool,
117
205
  reset: () => {
206
+ opfsMock.reset();
118
207
  state.closedDatabases.length = 0;
119
208
  state.createdDatabases.length = 0;
120
209
  state.deletedFilenames.length = 0;
@@ -128,6 +217,7 @@ const sqliteMock = (() => {
128
217
  pool.unpauseVfs.mock.resetCalls();
129
218
  pool.unlink.mock.resetCalls();
130
219
  sqlite3.capi.sqlite3_js_db_export.mock.resetCalls();
220
+ sqlite3.capi.sqlite3_reset.mock.resetCalls();
131
221
  sqlite3.capi.sqlite3mc_vfs_create.mock.resetCalls();
132
222
  sqlite3.installOpfsSAHPoolVfs.mock.resetCalls();
133
223
  },
@@ -193,6 +283,7 @@ describe("createWasmSqliteDriver coverage helpers", () => {
193
283
 
194
284
  assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
195
285
  assertEqual(sqliteMock.state.createdDatabases[0]?.filename, ":memory:");
286
+ assertEqual(opfsMock.state.getDirectoryCount, 0);
196
287
  });
197
288
 
198
289
  it("executes non-prepared query and exports database", async () => {
@@ -354,3 +445,89 @@ describe("createWasmSqliteDriver coverage helpers", () => {
354
445
  );
355
446
  });
356
447
  });
448
+
449
+ describe("createWasmSqliteDriver held pool files", () => {
450
+ for (const errorName of ["InvalidStateError", "NoModificationAllowedError"])
451
+ it(`sets up the pool once a file held with ${errorName} is released`, async () => {
452
+ sqliteMock.reset();
453
+ opfsMock.reset([[], [errorName, errorName]]);
454
+
455
+ await using run = testCreateRun();
456
+ const driver = run.ok(createWasmSqliteDriver(Name.orThrow("MockPlain")));
457
+ await flushMicrotasks();
458
+
459
+ assertEqual(opfsMock.state.directoryNames, [".MockPlain", ".opaque"]);
460
+ run.deps.time.advance("49ms");
461
+ await flushMicrotasks();
462
+ assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
463
+
464
+ // The retry delay doubles after each held pass.
465
+ run.deps.time.advance("1ms");
466
+ await flushMicrotasks();
467
+ run.deps.time.advance("99ms");
468
+ await flushMicrotasks();
469
+ assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
470
+
471
+ run.deps.time.advance("1ms");
472
+ using _driver = await driver;
473
+
474
+ // The free file opens on each of the three passes, the held one once.
475
+ assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 1);
476
+ assertEqual(opfsMock.state.openedAccessHandleCount, 4);
477
+ assertEqual(opfsMock.state.closedAccessHandleCount, 4);
478
+ });
479
+
480
+ it("sets up the pool at once when its directory has no files yet", async () => {
481
+ sqliteMock.reset();
482
+ opfsMock.reset([], { hasOpaqueDirectory: false });
483
+
484
+ await using run = testCreateRun();
485
+ using _driver = await run.ok(
486
+ createWasmSqliteDriver(Name.orThrow("MockPlain")),
487
+ );
488
+
489
+ assertEqual(opfsMock.state.directoryNames, [".MockPlain", ".opaque"]);
490
+ assertEqual(opfsMock.state.openedAccessHandleCount, 0);
491
+ assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 1);
492
+ });
493
+
494
+ it("warns once when pool files stay held for five seconds", async () => {
495
+ sqliteMock.reset();
496
+ opfsMock.reset([Array.from({ length: 12 }, () => "InvalidStateError")]);
497
+
498
+ await using run = testCreateRun();
499
+ const driver = run.ok(createWasmSqliteDriver(Name.orThrow("MockPlain")));
500
+ for (let second = 0; second < 12; second += 1) {
501
+ await flushMicrotasks();
502
+ run.deps.time.advance("1s");
503
+ }
504
+ using _driver = await driver;
505
+
506
+ const warnings = run.deps.console
507
+ .getEntriesSnapshot()
508
+ .filter((entry) => entry.method === "warn");
509
+ assertEqual(
510
+ warnings.map((entry) => entry.args),
511
+ [
512
+ [
513
+ "Waiting for an ended DbWorker to release the files of database MockPlain.",
514
+ ],
515
+ ],
516
+ );
517
+ });
518
+
519
+ it("fails before setting up the pool when a file cannot be opened", async () => {
520
+ sqliteMock.reset();
521
+ opfsMock.reset([["UnknownError"]]);
522
+
523
+ await using run = testCreateRun();
524
+ const result = await run.abortable(
525
+ createWasmSqliteDriver(Name.orThrow("MockPlain")),
526
+ );
527
+
528
+ assert(!result.ok, "Expected the driver to fail.");
529
+ assertType(AbortError, result.error);
530
+ assertSame(result.error.reason.type, "PanicAbortReason");
531
+ assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
532
+ });
533
+ });
package/src/Sqlite.ts CHANGED
@@ -4,6 +4,10 @@ import {
4
4
  createPreparedStatementsCache,
5
5
  exhaustiveCheck,
6
6
  ok,
7
+ performanceDurationBetween,
8
+ PositiveMillis,
9
+ sleep,
10
+ tryAsync,
7
11
  } from "@evolu/common";
8
12
  import sqlite3InitModule, {
9
13
  type Database,
@@ -32,7 +36,7 @@ const sqlite3Promise = sqlite3InitModule();
32
36
  const fileName = "evolu1.db";
33
37
 
34
38
  export const createWasmSqliteDriver: CreateSqliteDriver =
35
- (name, options) => async () => {
39
+ (name, options) => async (run) => {
36
40
  const sqlite3 = await sqlite3Promise;
37
41
 
38
42
  using disposer = new DisposableStack();
@@ -45,6 +49,69 @@ export const createWasmSqliteDriver: CreateSqliteDriver =
45
49
  const createOpfsSAHPoolVfs = async (
46
50
  options: Parameters<typeof sqlite3.installOpfsSAHPoolVfs>[0],
47
51
  ): Promise<SAHPoolUtil> => {
52
+ // Evolu opens a database only while it holds the database lock, but a
53
+ // DbWorker that ended without closing it, because its tab closed,
54
+ // crashed or navigated away, can hold pool files after the lock has
55
+ // passed on: WebKit releases a terminated worker's locks and its files
56
+ // separately, in no set order. sqlite-wasm cannot set up a pool with a
57
+ // held file, and it then deletes the pool directory, which the held file
58
+ // usually but not always prevents. Only a worker that has ended can hold
59
+ // the files, and it can only release them, so the pool is set up once
60
+ // every file opens.
61
+ const canOpenPoolFiles = async (): Promise<boolean> => {
62
+ const root = await navigator.storage.getDirectory();
63
+ // sqlite-wasm keeps the pool in `.${name}/.opaque` in both OPFS modes.
64
+ const poolDirectory = await tryAsync(() =>
65
+ root.getDirectoryHandle(`.${name}`),
66
+ );
67
+ if (!poolDirectory.ok) return true;
68
+ const opaqueDirectory = await tryAsync(() =>
69
+ poolDirectory.value.getDirectoryHandle(".opaque"),
70
+ );
71
+ if (!opaqueDirectory.ok) return true;
72
+ for await (const handle of opaqueDirectory.value.values()) {
73
+ if (handle.kind !== "file") continue;
74
+ const accessHandle = await tryAsync(() =>
75
+ handle.createSyncAccessHandle(),
76
+ );
77
+ if (!accessHandle.ok) {
78
+ // WebKit rejects a held file with InvalidStateError, other
79
+ // engines with NoModificationAllowedError, as the spec says.
80
+ // WebKit also uses InvalidStateError for a closed or invalid
81
+ // handle and a stopped context. A retry opens fresh handles from
82
+ // a new listing, and a stopped context ends the loop with its
83
+ // worker, so retrying those is harmless.
84
+ if (
85
+ accessHandle.error instanceof DOMException &&
86
+ (accessHandle.error.name === "InvalidStateError" ||
87
+ accessHandle.error.name === "NoModificationAllowedError")
88
+ )
89
+ return false;
90
+ throw accessHandle.error;
91
+ }
92
+ accessHandle.value.close();
93
+ }
94
+ return true;
95
+ };
96
+
97
+ const waitStart = run.deps.time.performance.now();
98
+ let retryDelay = 50;
99
+ let isWaitReported = false;
100
+ while (!(await canOpenPoolFiles())) {
101
+ const waited = performanceDurationBetween(
102
+ waitStart,
103
+ run.deps.time.performance.now(),
104
+ );
105
+ if (!isWaitReported && waited >= 5000) {
106
+ isWaitReported = true;
107
+ run.deps.console.warn(
108
+ `Waiting for an ended DbWorker to release the files of database ${name}.`,
109
+ );
110
+ }
111
+ await run.ok(sleep(PositiveMillis.orThrow(retryDelay)));
112
+ retryDelay = Math.min(retryDelay * 2, 1000);
113
+ }
114
+
48
115
  const pool = await sqlite3.installOpfsSAHPoolVfs(options);
49
116
  if (pool.isPaused()) await pool.unpauseVfs();
50
117
  disposer.defer(() => {
@@ -111,18 +178,24 @@ export const createWasmSqliteDriver: CreateSqliteDriver =
111
178
  const prepared = cache.get(query);
112
179
 
113
180
  if (prepared) {
114
- if (query.parameters.length > 0) prepared.bind(query.parameters);
115
-
116
- const rows = [];
117
- while (prepared.step()) {
118
- rows.push(prepared.get({}));
181
+ try {
182
+ if (query.parameters.length > 0) prepared.bind(query.parameters);
183
+
184
+ const rows = [];
185
+ while (prepared.step()) {
186
+ rows.push(prepared.get({}));
187
+ }
188
+
189
+ return {
190
+ rows: rows as ReadonlyArray<SqliteRow>,
191
+ changes: db.changes(),
192
+ };
193
+ } finally {
194
+ // SQLite refuses to bind a statement whose step failed until it is
195
+ // reset. That reset returns the step's error again, which
196
+ // PreparedStatement.reset would throw over the original one.
197
+ sqlite3.capi.sqlite3_reset(prepared);
119
198
  }
120
- prepared.reset();
121
-
122
- return {
123
- rows: rows as ReadonlyArray<SqliteRow>,
124
- changes: db.changes(),
125
- };
126
199
  }
127
200
 
128
201
  const rows = db.exec(query.sql, {
package/src/Task.test.ts CHANGED
@@ -1,14 +1,9 @@
1
- import {
2
- assertEqual,
3
- assertNonNullable,
4
- assertTrue,
5
- testStubGlobal,
6
- } from "@evolu/common";
1
+ import { assertEqual, assertSame, testStubGlobal } from "@evolu/common";
7
2
  import { describe, it, mock } from "node:test";
8
3
  import { createRun } from "./Task.ts";
9
4
 
10
5
  describe("createRun", () => {
11
- it("createRun reports defects with global reportError", async () => {
6
+ it("createRun reports a panic's defect with global reportError", async () => {
12
7
  const reportError = mock.fn<(error: unknown) => void>();
13
8
  using _reportError = testStubGlobal("reportError", reportError);
14
9
  await using run = createRun();
@@ -17,13 +12,7 @@ describe("createRun", () => {
17
12
  run.panic(defect);
18
13
 
19
14
  assertEqual(reportError.mock.callCount(), 1);
20
- const reported = reportError.mock.calls[0]?.arguments[0];
21
- assertNonNullable(reported);
22
- assertTrue(typeof reported === "object");
23
- assertEqual(Reflect.get(reported, "reason"), {
24
- type: "PanicAbortReason",
25
- defect,
26
- });
15
+ assertSame(reportError.mock.calls[0]?.arguments[0], defect);
27
16
  });
28
17
 
29
18
  it("createRun preserves a custom reportDefect", async () => {
package/src/Task.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import {
8
8
  createRun as createCommonRun,
9
+ defectToError,
9
10
  type DisposableRun,
10
11
  type Run,
11
12
  type RunCustomDeps,
@@ -14,13 +15,16 @@ import {
14
15
  /**
15
16
  * Creates a root {@link Run} for the browser.
16
17
  *
17
- * Defects are reported with the browser's global `reportError`. A custom
18
- * `reportDefect` dependency overrides the browser default.
18
+ * Defects are reported with the browser's global `reportError` as an `Error`
19
+ * from {@link defectToError}, because browsers show any other value only as
20
+ * unhelpful text. A custom `reportDefect` dependency overrides the browser
21
+ * default.
19
22
  *
20
23
  * ### Example
21
24
  *
22
25
  * ```ts
23
26
  * import {
27
+ * assertEqual,
24
28
  * createConsole,
25
29
  * createConsoleFormatter,
26
30
  * ok,
@@ -36,7 +40,7 @@ import {
36
40
  * await using run = createRun({ console });
37
41
  * const appPromise = run.ok(() => ok("started"));
38
42
  *
39
- * expect(await appPromise).toBe("started");
43
+ * assertEqual(await appPromise, "started");
40
44
  * ```
41
45
  */
42
46
  export function createRun(): DisposableRun;
@@ -49,7 +53,7 @@ export function createRun<D extends object>(
49
53
  deps?: RunCustomDeps<D>,
50
54
  ): DisposableRun | DisposableRun<D> {
51
55
  const reportDefect = (reported: unknown): void => {
52
- reportError(reported);
56
+ reportError(defectToError(reported));
53
57
  };
54
58
 
55
59
  return deps === undefined
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  assertEqual,
3
+ assertFalse,
4
+ assertLength,
3
5
  assertNonNullable,
4
6
  assertSame,
5
7
  assertThrowsInstanceOf,
@@ -9,6 +11,7 @@ import {
9
11
  } from "@evolu/common";
10
12
  import { describe, it, mock, test } from "node:test";
11
13
  import {
14
+ addUncaughtErrorListener,
12
15
  createBroadcastChannel,
13
16
  createOneTabSharedWorkerSelfPolyfill,
14
17
  createMessageChannel,
@@ -450,6 +453,62 @@ test("createSharedWorkerSelf asserts when a connection arrives before onConnect
450
453
  );
451
454
  });
452
455
 
456
+ describe("addUncaughtErrorListener", () => {
457
+ const dispatch = (
458
+ scope: EventTarget,
459
+ type: "error" | "unhandledrejection",
460
+ fields: object,
461
+ ): Event => {
462
+ const event = Object.assign(new Event(type, { cancelable: true }), fields);
463
+ scope.dispatchEvent(event);
464
+ return event;
465
+ };
466
+
467
+ it("passes uncaught errors and unhandled rejections without canceling them", () => {
468
+ const scope = new EventTarget();
469
+ const listener = mock.fn<(error: Error) => void>();
470
+ using _listener = addUncaughtErrorListener(scope, listener);
471
+ const error = new Error("uncaught");
472
+ const reason = new Error("rejected");
473
+
474
+ const errorEvent = dispatch(scope, "error", { error });
475
+ const rejectionEvent = dispatch(scope, "unhandledrejection", { reason });
476
+
477
+ const errors = listener.mock.calls.map((call) => call.arguments[0]);
478
+ assertLength(errors, 2);
479
+ assertSame(errors[0], error);
480
+ assertSame(errors[1], reason);
481
+ assertFalse(errorEvent.defaultPrevented);
482
+ assertFalse(rejectionEvent.defaultPrevented);
483
+ });
484
+
485
+ it("passes a thrown non-Error as an Error", () => {
486
+ const scope = new EventTarget();
487
+ const listener = mock.fn<(error: Error) => void>();
488
+ using _listener = addUncaughtErrorListener(scope, listener);
489
+
490
+ dispatch(scope, "error", { error: null });
491
+ dispatch(scope, "unhandledrejection", { reason: "rejected" });
492
+
493
+ assertEqual(
494
+ listener.mock.calls.map((call) => call.arguments[0].message),
495
+ ["Defect: null", 'Defect: "rejected"'],
496
+ );
497
+ });
498
+
499
+ it("stops passing errors after disposal", () => {
500
+ const scope = new EventTarget();
501
+ const listener = mock.fn<(error: Error) => void>();
502
+ const uncaughtErrorListener = addUncaughtErrorListener(scope, listener);
503
+
504
+ uncaughtErrorListener[Symbol.dispose]();
505
+ dispatch(scope, "error", { error: new Error("uncaught") });
506
+ dispatch(scope, "unhandledrejection", { reason: new Error("rejected") });
507
+
508
+ assertSame(listener.mock.callCount(), 0);
509
+ });
510
+ });
511
+
453
512
  test("createWorkerDeps stores console output entries and exposes createMessagePort", () => {
454
513
  const deps = createWorkerDeps();
455
514
 
package/src/Worker.ts CHANGED
@@ -18,8 +18,10 @@ import {
18
18
  assertNonNullable,
19
19
  createConsole,
20
20
  createConsoleStoreOutput,
21
+ defectToError,
21
22
  disposable,
22
23
  } from "@evolu/common";
24
+ import type { createRun } from "./Task.ts";
23
25
 
24
26
  /** Creates a {@link Worker} from a Web Worker. */
25
27
  export const createWorker = <Input, Output>(
@@ -159,7 +161,9 @@ export const createWorkerSelf = <Input, Output = never>(
159
161
  * Creates an Evolu {@link SharedWorkerSelf} from a Web `SharedWorkerGlobalScope`
160
162
  * (`self` inside a shared worker).
161
163
  *
162
- * Disposing closes the shared worker scope for all connected clients.
164
+ * Disposing closes the shared worker scope for all connected clients. Browsers
165
+ * never pass a shared worker's uncaught errors to its pages; see
166
+ * {@link addUncaughtErrorListener}.
163
167
  */
164
168
  export const createSharedWorkerSelf = <Input, Output = never>(
165
169
  nativeSelf: SharedWorkerGlobalScope,
@@ -271,6 +275,67 @@ export const createOneTabSharedWorkerSelfPolyfill = <Input, Output = never>(
271
275
  };
272
276
  };
273
277
 
278
+ /**
279
+ * Passes each uncaught error and unhandled rejection of a worker global scope
280
+ * to `listener` as an `Error` from {@link defectToError}.
281
+ *
282
+ * Browsers never pass a shared worker's uncaught errors to a page's error
283
+ * handlers, and no worker passes its unhandled rejections to its page. A shared
284
+ * worker calls this with its global scope, `self`, to send them to its pages,
285
+ * for example over a BroadcastChannel. Defects of a Run from {@link createRun}
286
+ * arrive too, because it reports them with `reportError`, which dispatches
287
+ * "error" on the global scope.
288
+ *
289
+ * The events stay uncanceled, so the browser still logs them, and a dedicated
290
+ * worker still passes its uncaught errors to its page. The listeners stay until
291
+ * disposal, so they also receive errors after a Run's panic.
292
+ *
293
+ * ### Example
294
+ *
295
+ * ```ts
296
+ * import { assertEqual } from "@evolu/common";
297
+ * import { addUncaughtErrorListener } from "@evolu/web";
298
+ *
299
+ * const messages: Array<string> = [];
300
+ * // A worker passes its global scope, `self`.
301
+ * const scope = new EventTarget();
302
+ * using _listener = addUncaughtErrorListener(scope, (error) => {
303
+ * messages.push(error.message);
304
+ * });
305
+ *
306
+ * scope.dispatchEvent(
307
+ * Object.assign(new Event("error"), { error: new Error("unexpected") }),
308
+ * );
309
+ * assertEqual(messages, ["unexpected"]);
310
+ * ```
311
+ */
312
+ export const addUncaughtErrorListener = (
313
+ scope: EventTarget,
314
+ listener: (error: Error) => void,
315
+ ): Disposable => {
316
+ using disposer = new DisposableStack();
317
+ const { signal } = disposer.adopt(new AbortController(), (controller) => {
318
+ controller.abort();
319
+ });
320
+
321
+ scope.addEventListener(
322
+ "error",
323
+ (event) => {
324
+ listener(defectToError((event as ErrorEvent).error));
325
+ },
326
+ { signal },
327
+ );
328
+ scope.addEventListener(
329
+ "unhandledrejection",
330
+ (event) => {
331
+ listener(defectToError((event as PromiseRejectionEvent).reason));
332
+ },
333
+ { signal },
334
+ );
335
+
336
+ return disposable({}, disposer);
337
+ };
338
+
274
339
  /** Creates deps shared by web worker entry points. */
275
340
  export const createWorkerDeps = (): WorkerDeps &
276
341
  CreateBroadcastChannelDep &