@evolu/web 3.2.0 → 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 +1 @@
1
- {"version":3,"file":"Sqlite.d.ts","sourceRoot":"","sources":["../../src/Sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAa,MAAM,eAAe,CAAC;AAqCnE,eAAO,MAAM,sBAAsB,EAAE,kBAmLlC,CAAC"}
1
+ {"version":3,"file":"Sqlite.d.ts","sourceRoot":"","sources":["../../src/Sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAa,MAAM,eAAe,CAAC;AAqCnE,eAAO,MAAM,sBAAsB,EAAE,kBAyLlC,CAAC"}
@@ -179,17 +179,24 @@ export const createWasmSqliteDriver = (name, options) => async (run) => {
179
179
  exec: (query) => {
180
180
  const prepared = cache.get(query);
181
181
  if (prepared) {
182
- if (query.parameters.length > 0)
183
- prepared.bind(query.parameters);
184
- const rows = [];
185
- while (prepared.step()) {
186
- rows.push(prepared.get({}));
182
+ try {
183
+ if (query.parameters.length > 0)
184
+ prepared.bind(query.parameters);
185
+ const rows = [];
186
+ while (prepared.step()) {
187
+ rows.push(prepared.get({}));
188
+ }
189
+ return {
190
+ rows: rows,
191
+ changes: db.changes(),
192
+ };
193
+ }
194
+ finally {
195
+ // SQLite refuses to bind a statement whose step failed until it is
196
+ // reset. That reset returns the step's error again, which
197
+ // PreparedStatement.reset would throw over the original one.
198
+ sqlite3.capi.sqlite3_reset(prepared);
187
199
  }
188
- prepared.reset();
189
- return {
190
- rows: rows,
191
- changes: db.changes(),
192
- };
193
200
  }
194
201
  const rows = db.exec(query.sql, {
195
202
  returnValue: "resultRows",
@@ -44,7 +44,9 @@ export declare const createWorkerSelf: <Input, Output = never>(nativeSelf: Dedic
44
44
  * Creates an Evolu {@link SharedWorkerSelf} from a Web `SharedWorkerGlobalScope`
45
45
  * (`self` inside a shared worker).
46
46
  *
47
- * Disposing closes the shared worker scope for all connected clients.
47
+ * Disposing closes the shared worker scope for all connected clients. Browsers
48
+ * never pass a shared worker's uncaught errors to its pages; see
49
+ * {@link addUncaughtErrorListener}.
48
50
  */
49
51
  export declare const createSharedWorkerSelf: <Input, Output = never>(nativeSelf: SharedWorkerGlobalScope) => SharedWorkerSelf<Input, Output>;
50
52
  /**
@@ -55,6 +57,41 @@ export declare const createSharedWorkerSelf: <Input, Output = never>(nativeSelf:
55
57
  * `onMessage` is set, and does not share state across tabs.
56
58
  */
57
59
  export declare const createOneTabSharedWorkerSelfPolyfill: <Input, Output = never>(nativeSelf: DedicatedWorkerGlobalScope) => SharedWorkerSelf<Input, Output>;
60
+ /**
61
+ * Passes each uncaught error and unhandled rejection of a worker global scope
62
+ * to `listener` as an `Error` from {@link defectToError}.
63
+ *
64
+ * Browsers never pass a shared worker's uncaught errors to a page's error
65
+ * handlers, and no worker passes its unhandled rejections to its page. A shared
66
+ * worker calls this with its global scope, `self`, to send them to its pages,
67
+ * for example over a BroadcastChannel. Defects of a Run from {@link createRun}
68
+ * arrive too, because it reports them with `reportError`, which dispatches
69
+ * "error" on the global scope.
70
+ *
71
+ * The events stay uncanceled, so the browser still logs them, and a dedicated
72
+ * worker still passes its uncaught errors to its page. The listeners stay until
73
+ * disposal, so they also receive errors after a Run's panic.
74
+ *
75
+ * ### Example
76
+ *
77
+ * ```ts
78
+ * import { assertEqual } from "@evolu/common";
79
+ * import { addUncaughtErrorListener } from "@evolu/web";
80
+ *
81
+ * const messages: Array<string> = [];
82
+ * // A worker passes its global scope, `self`.
83
+ * const scope = new EventTarget();
84
+ * using _listener = addUncaughtErrorListener(scope, (error) => {
85
+ * messages.push(error.message);
86
+ * });
87
+ *
88
+ * scope.dispatchEvent(
89
+ * Object.assign(new Event("error"), { error: new Error("unexpected") }),
90
+ * );
91
+ * assertEqual(messages, ["unexpected"]);
92
+ * ```
93
+ */
94
+ export declare const addUncaughtErrorListener: (scope: EventTarget, listener: (error: Error) => void) => Disposable;
58
95
  /** Creates deps shared by web worker entry points. */
59
96
  export declare const createWorkerDeps: () => WorkerDeps & CreateBroadcastChannelDep & CreateMessageChannelDep;
60
97
  //# sourceMappingURL=Worker.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Worker.d.ts","sourceRoot":"","sources":["../../src/Worker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,sBAAsB,EACtB,yBAAyB,EACzB,uBAAuB,EACvB,iBAAiB,EACjB,cAAc,EAGd,YAAY,EACZ,gBAAgB,EAEhB,MAAM,EACN,UAAU,EACV,UAAU,EACX,MAAM,eAAe,CAAC;AAQvB,kDAAkD;AAClD,eAAO,MAAM,YAAY,GAAI,KAAK,EAAE,MAAM,gBAC1B,UAAU,CAAC,MAAM,KAC9B,MAAM,CAAC,KAAK,EAAE,MAAM,CAAuB,CAAC;AAE/C;;;;;GAKG;AACH,eAAO,MAAM,iCAAiC,QAAO,IAiBpD,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,sBAClC,UAAU,CAAC,YAAY,KAC1C,YAAY,CAAC,KAAK,EAAE,MAAM,CAM5B,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,OAAK,cAAc,CAC3E,KAAK,EACL,MAAM,CAcP,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,EAAE,iBACuB,CAAC;AAExD,6EAA6E;AAC7E,eAAO,MAAM,sBAAsB,EAAE,sBAwCpC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,cACxC,0BAA0B,KACrC,UAAU,CAAC,KAAK,EAAE,MAAM,CAAoC,CAAC;AAEhE;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,cAC9C,uBAAuB,KAClC,gBAAgB,CAAC,KAAK,EAAE,MAAM,CAoBhC,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,oCAAoC,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,cAC5D,0BAA0B,KACrC,gBAAgB,CAAC,KAAK,EAAE,MAAM,CA2EhC,CAAC;AAEF,sDAAsD;AACtD,eAAO,MAAM,gBAAgB,QAAO,UAAU,GAC5C,yBAAyB,GACzB,uBAWD,CAAC"}
1
+ {"version":3,"file":"Worker.d.ts","sourceRoot":"","sources":["../../src/Worker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,sBAAsB,EACtB,yBAAyB,EACzB,uBAAuB,EACvB,iBAAiB,EACjB,cAAc,EAGd,YAAY,EACZ,gBAAgB,EAEhB,MAAM,EACN,UAAU,EACV,UAAU,EACX,MAAM,eAAe,CAAC;AAUvB,kDAAkD;AAClD,eAAO,MAAM,YAAY,GAAI,KAAK,EAAE,MAAM,gBAC1B,UAAU,CAAC,MAAM,KAC9B,MAAM,CAAC,KAAK,EAAE,MAAM,CAAuB,CAAC;AAE/C;;;;;GAKG;AACH,eAAO,MAAM,iCAAiC,QAAO,IAiBpD,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,sBAClC,UAAU,CAAC,YAAY,KAC1C,YAAY,CAAC,KAAK,EAAE,MAAM,CAM5B,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,OAAK,cAAc,CAC3E,KAAK,EACL,MAAM,CAcP,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,EAAE,iBACuB,CAAC;AAExD,6EAA6E;AAC7E,eAAO,MAAM,sBAAsB,EAAE,sBAwCpC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,cACxC,0BAA0B,KACrC,UAAU,CAAC,KAAK,EAAE,MAAM,CAAoC,CAAC;AAEhE;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,cAC9C,uBAAuB,KAClC,gBAAgB,CAAC,KAAK,EAAE,MAAM,CAoBhC,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,oCAAoC,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,cAC5D,0BAA0B,KACrC,gBAAgB,CAAC,KAAK,EAAE,MAAM,CA2EhC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,eAAO,MAAM,wBAAwB,UAC5B,WAAW,YACR,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,KAC/B,UAsBF,CAAC;AAEF,sDAAsD;AACtD,eAAO,MAAM,gBAAgB,QAAO,UAAU,GAC5C,yBAAyB,GACzB,uBAWD,CAAC"}
@@ -50,7 +50,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
50
50
  var e = new Error(message);
51
51
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
52
  });
53
- import { assertNonNullable, createConsole, createConsoleStoreOutput, disposable, } from "@evolu/common";
53
+ import { assertNonNullable, createConsole, createConsoleStoreOutput, defectToError, disposable, } from "@evolu/common";
54
54
  /** Creates a {@link Worker} from a Web Worker. */
55
55
  export const createWorker = (nativeWorker) => wrap(nativeWorker);
56
56
  /**
@@ -176,7 +176,9 @@ export const createWorkerSelf = (nativeSelf) => wrap(nativeSelf);
176
176
  * Creates an Evolu {@link SharedWorkerSelf} from a Web `SharedWorkerGlobalScope`
177
177
  * (`self` inside a shared worker).
178
178
  *
179
- * Disposing closes the shared worker scope for all connected clients.
179
+ * Disposing closes the shared worker scope for all connected clients. Browsers
180
+ * never pass a shared worker's uncaught errors to its pages; see
181
+ * {@link addUncaughtErrorListener}.
180
182
  */
181
183
  export const createSharedWorkerSelf = (nativeSelf) => {
182
184
  const self = {
@@ -285,6 +287,63 @@ export const createOneTabSharedWorkerSelfPolyfill = (nativeSelf) => {
285
287
  __disposeResources(env_3);
286
288
  }
287
289
  };
290
+ /**
291
+ * Passes each uncaught error and unhandled rejection of a worker global scope
292
+ * to `listener` as an `Error` from {@link defectToError}.
293
+ *
294
+ * Browsers never pass a shared worker's uncaught errors to a page's error
295
+ * handlers, and no worker passes its unhandled rejections to its page. A shared
296
+ * worker calls this with its global scope, `self`, to send them to its pages,
297
+ * for example over a BroadcastChannel. Defects of a Run from {@link createRun}
298
+ * arrive too, because it reports them with `reportError`, which dispatches
299
+ * "error" on the global scope.
300
+ *
301
+ * The events stay uncanceled, so the browser still logs them, and a dedicated
302
+ * worker still passes its uncaught errors to its page. The listeners stay until
303
+ * disposal, so they also receive errors after a Run's panic.
304
+ *
305
+ * ### Example
306
+ *
307
+ * ```ts
308
+ * import { assertEqual } from "@evolu/common";
309
+ * import { addUncaughtErrorListener } from "@evolu/web";
310
+ *
311
+ * const messages: Array<string> = [];
312
+ * // A worker passes its global scope, `self`.
313
+ * const scope = new EventTarget();
314
+ * using _listener = addUncaughtErrorListener(scope, (error) => {
315
+ * messages.push(error.message);
316
+ * });
317
+ *
318
+ * scope.dispatchEvent(
319
+ * Object.assign(new Event("error"), { error: new Error("unexpected") }),
320
+ * );
321
+ * assertEqual(messages, ["unexpected"]);
322
+ * ```
323
+ */
324
+ export const addUncaughtErrorListener = (scope, listener) => {
325
+ const env_4 = { stack: [], error: void 0, hasError: false };
326
+ try {
327
+ const disposer = __addDisposableResource(env_4, new DisposableStack(), false);
328
+ const { signal } = disposer.adopt(new AbortController(), (controller) => {
329
+ controller.abort();
330
+ });
331
+ scope.addEventListener("error", (event) => {
332
+ listener(defectToError(event.error));
333
+ }, { signal });
334
+ scope.addEventListener("unhandledrejection", (event) => {
335
+ listener(defectToError(event.reason));
336
+ }, { signal });
337
+ return disposable({}, disposer);
338
+ }
339
+ catch (e_4) {
340
+ env_4.error = e_4;
341
+ env_4.hasError = true;
342
+ }
343
+ finally {
344
+ __disposeResources(env_4);
345
+ }
346
+ };
288
347
  /** Creates deps shared by web worker entry points. */
289
348
  export const createWorkerDeps = () => {
290
349
  const consoleStoreOutput = createConsoleStoreOutput();
@@ -53,10 +53,16 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
53
53
  /// <reference lib="webworker" />
54
54
  import { installPolyfills } from "@evolu/common/polyfills";
55
55
  installPolyfills();
56
- import { createWebSocket, ok, tryAsync, waitForAbort } from "@evolu/common";
57
- import { initSharedWorker } from "@evolu/common/local-first";
56
+ import { createUnknownError, createWebSocket, ok, tryAsync, waitForAbort, } from "@evolu/common";
57
+ import { consoleEntryOrErrorBroadcastChannelName, initSharedWorker, } from "@evolu/common/local-first";
58
58
  import { createRun } from "../Task.js";
59
- import { createOneTabSharedWorkerSelfPolyfill, createSharedWorkerSelf, createWorkerDeps, } from "../Worker.js";
59
+ import { addUncaughtErrorListener, createBroadcastChannel, createOneTabSharedWorkerSelfPolyfill, createSharedWorkerSelf, createWorkerDeps, } from "../Worker.js";
60
+ const errors = createBroadcastChannel(consoleEntryOrErrorBroadcastChannelName);
61
+ // Each tab logs an uncaught error, including a Run defect, and sets it as its
62
+ // evoluError.
63
+ addUncaughtErrorListener(self, (error) => {
64
+ errors.postMessage({ type: "Error", error: createUnknownError(error) });
65
+ });
60
66
  const run = createRun({
61
67
  ...createWorkerDeps(),
62
68
  createWebSocket,
@@ -70,7 +76,9 @@ void run(async (run) => {
70
76
  if ("onconnect" in self) {
71
77
  const env_2 = { stack: [], error: void 0, hasError: false };
72
78
  try {
73
- const _ = __addDisposableResource(env_2, await run.ok(initSharedWorker(createSharedWorkerSelf(self))), true);
79
+ // A panic closes the worker, so tabs that connect later start another.
80
+ const workerSelf = __addDisposableResource(env_2, createSharedWorkerSelf(self), false);
81
+ const _ = __addDisposableResource(env_2, await run.ok(initSharedWorker(workerSelf)), true);
74
82
  return await run(waitForAbort);
75
83
  }
76
84
  catch (e_1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evolu/web",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Evolu for web",
5
5
  "keywords": [
6
6
  "evolu",
@@ -35,7 +35,7 @@
35
35
  "@evolu/sqlite-wasm": "2.2.4"
36
36
  },
37
37
  "devDependencies": {
38
- "@evolu/common": "8.11.0",
38
+ "@evolu/common": "8.13.0",
39
39
  "@evolu/typescript-config": "0.1.1",
40
40
  "@types/node": "^24.10.9",
41
41
  "@types/sharedworker": "^0.0.229",
@@ -44,7 +44,7 @@
44
44
  "user-agent-data-types": "^0.4.2"
45
45
  },
46
46
  "peerDependencies": {
47
- "@evolu/common": "^8.11.0"
47
+ "@evolu/common": "^8.13.0"
48
48
  },
49
49
  "publishConfig": {
50
50
  "access": "public"
@@ -117,10 +117,6 @@ const sqliteMock = (() => {
117
117
  return { data: "prepared" };
118
118
  }
119
119
 
120
- reset(): void {
121
- this.resetCount += 1;
122
- }
123
-
124
120
  finalize(): void {
125
121
  this.finalized = true;
126
122
  }
@@ -193,6 +189,10 @@ const sqliteMock = (() => {
193
189
  const sqlite3 = {
194
190
  capi: {
195
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
+ }),
196
196
  sqlite3mc_vfs_create: mock.fn(),
197
197
  },
198
198
  installOpfsSAHPoolVfs: mock.fn(() => Promise.resolve(pool)),
@@ -217,6 +217,7 @@ const sqliteMock = (() => {
217
217
  pool.unpauseVfs.mock.resetCalls();
218
218
  pool.unlink.mock.resetCalls();
219
219
  sqlite3.capi.sqlite3_js_db_export.mock.resetCalls();
220
+ sqlite3.capi.sqlite3_reset.mock.resetCalls();
220
221
  sqlite3.capi.sqlite3mc_vfs_create.mock.resetCalls();
221
222
  sqlite3.installOpfsSAHPoolVfs.mock.resetCalls();
222
223
  },
package/src/Sqlite.ts CHANGED
@@ -178,18 +178,24 @@ export const createWasmSqliteDriver: CreateSqliteDriver =
178
178
  const prepared = cache.get(query);
179
179
 
180
180
  if (prepared) {
181
- if (query.parameters.length > 0) prepared.bind(query.parameters);
182
-
183
- const rows = [];
184
- while (prepared.step()) {
185
- 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);
186
198
  }
187
- prepared.reset();
188
-
189
- return {
190
- rows: rows as ReadonlyArray<SqliteRow>,
191
- changes: db.changes(),
192
- };
193
199
  }
194
200
 
195
201
  const rows = db.exec(query.sql, {
@@ -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 &
@@ -572,6 +572,7 @@ const setupWebEvoluDeps = ({
572
572
  sqliteSchema: { tables: {}, indexes: [] },
573
573
  encryptionKey: testAppOwner.encryptionKey,
574
574
  memoryOnly: false,
575
+ sharedWorkerId: createIdFromString<"SharedWorker">("worker"),
575
576
  port: createClosableNativePort() as unknown as NativeMessagePort<
576
577
  DbWorkerOutput,
577
578
  DbWorkerInput
@@ -4,20 +4,41 @@ declare const self: DedicatedWorkerGlobalScope | SharedWorkerGlobalScope;
4
4
  import { installPolyfills } from "@evolu/common/polyfills";
5
5
  installPolyfills();
6
6
 
7
- import { createWebSocket, ok, tryAsync, waitForAbort } from "@evolu/common";
7
+ import {
8
+ createUnknownError,
9
+ createWebSocket,
10
+ ok,
11
+ tryAsync,
12
+ waitForAbort,
13
+ } from "@evolu/common";
8
14
  import type {
15
+ ConsoleEntryOrError,
9
16
  SharedWorkerInput,
10
17
  SharedWorkerOutput,
11
18
  } from "@evolu/common/local-first";
12
- import { initSharedWorker } from "@evolu/common/local-first";
19
+ import {
20
+ consoleEntryOrErrorBroadcastChannelName,
21
+ initSharedWorker,
22
+ } from "@evolu/common/local-first";
13
23
  import type { SharedWorkerUnsupported } from "./Evolu.ts";
14
24
  import { createRun } from "../Task.ts";
15
25
  import {
26
+ addUncaughtErrorListener,
27
+ createBroadcastChannel,
16
28
  createOneTabSharedWorkerSelfPolyfill,
17
29
  createSharedWorkerSelf,
18
30
  createWorkerDeps,
19
31
  } from "../Worker.ts";
20
32
 
33
+ const errors = createBroadcastChannel<ConsoleEntryOrError>(
34
+ consoleEntryOrErrorBroadcastChannelName,
35
+ );
36
+ // Each tab logs an uncaught error, including a Run defect, and sets it as its
37
+ // evoluError.
38
+ addUncaughtErrorListener(self, (error) => {
39
+ errors.postMessage({ type: "Error", error: createUnknownError(error) });
40
+ });
41
+
21
42
  const run = createRun({
22
43
  ...createWorkerDeps(),
23
44
  createWebSocket,
@@ -29,9 +50,12 @@ const run = createRun({
29
50
 
30
51
  void run(async (run) => {
31
52
  if ("onconnect" in self) {
32
- await using _ = await run.ok(
33
- initSharedWorker(createSharedWorkerSelf(self)),
34
- );
53
+ // A panic closes the worker, so tabs that connect later start another.
54
+ using workerSelf = createSharedWorkerSelf<
55
+ SharedWorkerInput,
56
+ SharedWorkerOutput
57
+ >(self);
58
+ await using _ = await run.ok(initSharedWorker(workerSelf));
35
59
  return await run(waitForAbort);
36
60
  }
37
61