@evolu/web 3.2.0 → 3.4.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();
@@ -1,27 +1,11 @@
1
1
  import { type ConsoleDep, type ReloadAppDep } from "@evolu/common";
2
- import type { EvoluDeps } from "@evolu/common/local-first";
2
+ import type { EvoluDeps, RequestPersistentStorageDep } from "@evolu/common/local-first";
3
3
  export interface SharedWorkerUnsupported {
4
4
  readonly type: "SharedWorkerUnsupported";
5
5
  }
6
6
  export interface SharedWorkerUnsupportedDep {
7
7
  readonly onSharedWorkerUnsupported: () => void;
8
8
  }
9
- export interface StorageUnavailableDep {
10
- /**
11
- * Called when the browser offers no persistent storage, as in Safari's
12
- * Private Browsing, so every database is kept in memory. See Storage in the
13
- * Shared module of `@evolu/common`.
14
- *
15
- * The app keeps working. Data synced with a relay comes back, as on a new
16
- * device, and nothing stays on the device once the tabs close. That suits
17
- * someone checking their app on a borrowed phone, so a message such as
18
- * "Nothing from this session is kept on this device." tells the user what to
19
- * expect. Data that exists only locally, or has not synced yet, is lost when
20
- * the tab hosting the database closes or navigates away, even while other
21
- * tabs stay open.
22
- */
23
- readonly onStorageUnavailable: () => void;
24
- }
25
9
  /**
26
10
  * Creates Evolu dependencies for the web platform.
27
11
  *
@@ -42,14 +26,25 @@ export interface StorageUnavailableDep {
42
26
  * the user comes back to it.
43
27
  *
44
28
  * Where the browser offers no persistent storage, as in Safari's Private
45
- * Browsing, the database is kept in memory, and
46
- * {@link StorageUnavailableDep.onStorageUnavailable} lets the app tell the
47
- * user.
29
+ * Browsing or a Firefox private window, the database is kept in memory, and
30
+ * {@link Evolu.devicePersistence} resolves to `NotPersisted`, so the app can
31
+ * tell the user. Data that exists only locally, or has not synced yet, is lost
32
+ * when the tab hosting the database closes or navigates away, even while other
33
+ * tabs stay open.
34
+ *
35
+ * After the first local mutation of a database the browser stores, this tab
36
+ * asks the browser once with `navigator.storage.persist()` not to delete the
37
+ * site's data when disk space runs low. Chrome and Safari decide silently, and
38
+ * Firefox asks the user, so every tab asks until the user allows it. A custom
39
+ * {@link RequestPersistentStorageDep.requestPersistentStorage} replaces the
40
+ * request, for example with `constVoid` to never ask. See [Will my data stay on
41
+ * the
42
+ * device?](https://www.evolu.dev/docs/faq#will-my-data-stay-on-the-device).
48
43
  *
49
44
  * A custom {@link ReloadApp} replaces the default page reload, for example to
50
45
  * save state first. It should end by reloading the page, because the other
51
46
  * build waits until this tab reloads or closes, and a page restored from the
52
47
  * back-forward cache cannot work until it reloads.
53
48
  */
54
- export declare const createEvoluDeps: (deps?: Partial<ConsoleDep> & Partial<ReloadAppDep> & Partial<SharedWorkerUnsupportedDep> & Partial<StorageUnavailableDep>) => EvoluDeps;
49
+ export declare const createEvoluDeps: (deps?: Partial<ConsoleDep> & Partial<ReloadAppDep> & Partial<SharedWorkerUnsupportedDep> & Partial<RequestPersistentStorageDep>) => EvoluDeps;
55
50
  //# sourceMappingURL=Evolu.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Evolu.d.ts","sourceRoot":"","sources":["../../../src/local-first/Evolu.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,UAAU,EAEf,KAAK,YAAY,EAElB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAIV,SAAS,EAIV,MAAM,2BAA2B,CAAC;AAgBnC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,IAAI,EAAE,yBAAyB,CAAC;CAC1C;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,yBAAyB,EAAE,MAAM,IAAI,CAAC;CAChD;AAED,MAAM,WAAW,qBAAqB;IACpC;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,oBAAoB,EAAE,MAAM,IAAI,CAAC;CAC3C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,eAAe,UACpB,OAAO,CAAC,UAAU,CAAC,GACvB,OAAO,CAAC,YAAY,CAAC,GACrB,OAAO,CAAC,0BAA0B,CAAC,GACnC,OAAO,CAAC,qBAAqB,CAAC,KAC/B,SAiPF,CAAC"}
1
+ {"version":3,"file":"Evolu.d.ts","sourceRoot":"","sources":["../../../src/local-first/Evolu.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,UAAU,EAEf,KAAK,YAAY,EAElB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAKV,SAAS,EACT,2BAA2B,EAI5B,MAAM,2BAA2B,CAAC;AAgBnC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,IAAI,EAAE,yBAAyB,CAAC;CAC1C;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,yBAAyB,EAAE,MAAM,IAAI,CAAC;CAChD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,eAAO,MAAM,eAAe,UACpB,OAAO,CAAC,UAAU,CAAC,GACvB,OAAO,CAAC,YAAY,CAAC,GACrB,OAAO,CAAC,0BAA0B,CAAC,GACnC,OAAO,CAAC,2BAA2B,CAAC,KACrC,SAsPF,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 { exhaustiveCheck, trySync, } from "@evolu/common";
53
+ import { exhaustiveCheck, tryAsync, trySync, } from "@evolu/common";
54
54
  import { BuildWaiting, BuildWaitingRequest, buildsBroadcastChannelName, createEvoluDeps as createCommonEvoluDeps, } from "@evolu/common/local-first";
55
55
  import { reloadApp } from "../Platform.js";
56
56
  import { createBroadcastChannel, createMessageChannel, createSharedWorker, createWorker, installOneTabSharedWorkerPolyfill, } from "../Worker.js";
@@ -74,9 +74,20 @@ import { createBroadcastChannel, createMessageChannel, createSharedWorker, creat
74
74
  * the user comes back to it.
75
75
  *
76
76
  * Where the browser offers no persistent storage, as in Safari's Private
77
- * Browsing, the database is kept in memory, and
78
- * {@link StorageUnavailableDep.onStorageUnavailable} lets the app tell the
79
- * user.
77
+ * Browsing or a Firefox private window, the database is kept in memory, and
78
+ * {@link Evolu.devicePersistence} resolves to `NotPersisted`, so the app can
79
+ * tell the user. Data that exists only locally, or has not synced yet, is lost
80
+ * when the tab hosting the database closes or navigates away, even while other
81
+ * tabs stay open.
82
+ *
83
+ * After the first local mutation of a database the browser stores, this tab
84
+ * asks the browser once with `navigator.storage.persist()` not to delete the
85
+ * site's data when disk space runs low. Chrome and Safari decide silently, and
86
+ * Firefox asks the user, so every tab asks until the user allows it. A custom
87
+ * {@link RequestPersistentStorageDep.requestPersistentStorage} replaces the
88
+ * request, for example with `constVoid` to never ask. See [Will my data stay on
89
+ * the
90
+ * device?](https://www.evolu.dev/docs/faq#will-my-data-stay-on-the-device).
80
91
  *
81
92
  * A custom {@link ReloadApp} replaces the default page reload, for example to
82
93
  * save state first. It should end by reloading the page, because the other
@@ -105,6 +116,7 @@ export const createEvoluDeps = (deps = {}) => {
105
116
  const earlyAnnouncedWorkerIds = new Set();
106
117
  // Checks whether the user left this tab while another build waits.
107
118
  let focusCheckId = null;
119
+ let isPersistentStorageRequested = false;
108
120
  const disposer = __addDisposableResource(env_1, new DisposableStack(), false);
109
121
  const buildsBroadcastChannel = disposer.use(createBroadcastChannel(buildsBroadcastChannelName));
110
122
  const stopFocusCheck = () => {
@@ -206,11 +218,6 @@ export const createEvoluDeps = (deps = {}) => {
206
218
  });
207
219
  break;
208
220
  }
209
- case "StorageUnavailable": {
210
- deps.onStorageUnavailable?.();
211
- forward(message);
212
- break;
213
- }
214
221
  case "SharedWorkerUnsupported": {
215
222
  if (deps.onSharedWorkerUnsupported) {
216
223
  deps.onSharedWorkerUnsupported();
@@ -259,7 +266,18 @@ export const createEvoluDeps = (deps = {}) => {
259
266
  },
260
267
  },
261
268
  };
269
+ const requestPersistentStorage = () => {
270
+ if (isPersistentStorageRequested)
271
+ return;
272
+ isPersistentStorageRequested = true;
273
+ void tryAsync(async () => {
274
+ if (await navigator.storage.persisted())
275
+ return;
276
+ await navigator.storage.persist();
277
+ });
278
+ };
262
279
  const evoluDeps = disposer.use(createCommonEvoluDeps({
280
+ requestPersistentStorage,
263
281
  ...deps,
264
282
  createDbWorker,
265
283
  createBroadcastChannel,
@@ -53,15 +53,25 @@ 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,
63
- // Safari's Private Browsing offers no OPFS; see Storage in the Shared module.
64
- isPersistentStorageAvailable: async () => (await tryAsync(() => navigator.storage.getDirectory())).ok,
69
+ // Safari's Private Browsing and Firefox's private windows offer no OPFS, and
70
+ // a browser may delete it, as Chrome's incognito does when the session ends;
71
+ // see Storage in the Shared module.
72
+ getDevicePersistence: async () => (await tryAsync(() => navigator.storage.getDirectory())).ok
73
+ ? "Unknown"
74
+ : "NotPersisted",
65
75
  lockManager: navigator.locks,
66
76
  });
67
77
  void run(async (run) => {
@@ -70,7 +80,9 @@ void run(async (run) => {
70
80
  if ("onconnect" in self) {
71
81
  const env_2 = { stack: [], error: void 0, hasError: false };
72
82
  try {
73
- const _ = __addDisposableResource(env_2, await run.ok(initSharedWorker(createSharedWorkerSelf(self))), true);
83
+ // A panic closes the worker, so tabs that connect later start another.
84
+ const workerSelf = __addDisposableResource(env_2, createSharedWorkerSelf(self), false);
85
+ const _ = __addDisposableResource(env_2, await run.ok(initSharedWorker(workerSelf)), true);
74
86
  return await run(waitForAbort);
75
87
  }
76
88
  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.4.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.14.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.14.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 &
@@ -148,26 +148,59 @@ describe("createEvoluDeps", () => {
148
148
  });
149
149
  });
150
150
 
151
- describe("storage unavailable", () => {
152
- const workerId = createIdFromString<"SharedWorker">("worker");
151
+ describe("persistent storage request", () => {
152
+ const setupStorage = (isPersisted: boolean) => ({
153
+ persisted: mock.fn(() => Promise.resolve(isPersisted)),
154
+ persist: mock.fn(() => Promise.resolve(true)),
155
+ });
153
156
 
154
- it("tells the app when its worker keeps databases in memory", () => {
155
- const onStorageUnavailable = mock.fn<() => void>();
156
- using setup = setupWebEvoluDeps({ onStorageUnavailable });
157
- setup.connect(workerId);
157
+ it("asks the browser once to keep the site's data", async () => {
158
+ const storage = setupStorage(false);
159
+ using setup = setupWebEvoluDeps({ storage });
158
160
 
159
- setup.post({ type: "StorageUnavailable" });
161
+ setup.deps.requestPersistentStorage?.();
162
+ setup.deps.requestPersistentStorage?.();
163
+ await waitForMacrotask();
160
164
 
161
- assertSame(onStorageUnavailable.mock.callCount(), 1);
165
+ assertSame(storage.persisted.mock.callCount(), 1);
166
+ assertSame(storage.persist.mock.callCount(), 1);
162
167
  });
163
168
 
164
- it("works without a callback", () => {
165
- using setup = setupWebEvoluDeps();
166
- setup.connect(workerId);
169
+ it("does not ask when the site's data is already persistent", async () => {
170
+ const storage = setupStorage(true);
171
+ using setup = setupWebEvoluDeps({ storage });
167
172
 
168
- setup.post({ type: "StorageUnavailable" });
173
+ setup.deps.requestPersistentStorage?.();
174
+ await waitForMacrotask();
169
175
 
170
- assertSame(setup.reloadApp.mock.callCount(), 0);
176
+ assertSame(storage.persist.mock.callCount(), 0);
177
+ });
178
+
179
+ it("ignores a browser that refuses to answer", async () => {
180
+ const storage = {
181
+ persisted: mock.fn(() =>
182
+ Promise.reject(new TypeError("Storage is disabled.")),
183
+ ),
184
+ persist: mock.fn(() => Promise.resolve(true)),
185
+ };
186
+ using setup = setupWebEvoluDeps({ storage });
187
+
188
+ setup.deps.requestPersistentStorage?.();
189
+ await waitForMacrotask();
190
+
191
+ assertSame(storage.persist.mock.callCount(), 0);
192
+ });
193
+
194
+ it("uses the app's request instead", async () => {
195
+ const storage = setupStorage(false);
196
+ const requestPersistentStorage = mock.fn<() => void>();
197
+ using setup = setupWebEvoluDeps({ storage, requestPersistentStorage });
198
+
199
+ setup.deps.requestPersistentStorage?.();
200
+ await waitForMacrotask();
201
+
202
+ assertSame(requestPersistentStorage.mock.callCount(), 1);
203
+ assertSame(storage.persisted.mock.callCount(), 0);
171
204
  });
172
205
  });
173
206
 
@@ -443,7 +476,8 @@ const setupWebEvoluDeps = ({
443
476
  isSessionStorageAvailable = true,
444
477
  refusalReloads,
445
478
  reloadedFor,
446
- onStorageUnavailable,
479
+ storage,
480
+ requestPersistentStorage,
447
481
  }: {
448
482
  hasFocus?: boolean;
449
483
  isAutomaticReload?: boolean;
@@ -452,7 +486,9 @@ const setupWebEvoluDeps = ({
452
486
  refusalReloads?: string;
453
487
  /** The stored waiting workers, as a previous page load left them. */
454
488
  reloadedFor?: string;
455
- onStorageUnavailable?: () => void;
489
+ /** Stands in for `navigator.storage`. */
490
+ storage?: Pick<StorageManager, "persist" | "persisted">;
491
+ requestPersistentStorage?: () => void;
456
492
  } = {}) => {
457
493
  using disposer = new DisposableStack();
458
494
  const sharedWorkerPort = createClosableNativePort<unknown>();
@@ -539,12 +575,17 @@ const setupWebEvoluDeps = ({
539
575
  );
540
576
  let hasFocus = initialHasFocus;
541
577
  disposer.use(testStubGlobal("document", { hasFocus: () => hasFocus }));
578
+ if (storage) {
579
+ disposer.use(
580
+ testStubGlobal("navigator", { locks: navigator.locks, storage }),
581
+ );
582
+ }
542
583
  const reloadApp = mock.fn<ReloadApp>();
543
584
 
544
585
  const deps = createEvoluDeps({
545
586
  console: createConsole({ level: "silent" }),
546
587
  reloadApp,
547
- ...(onStorageUnavailable && { onStorageUnavailable }),
588
+ ...(requestPersistentStorage && { requestPersistentStorage }),
548
589
  });
549
590
  const builds = channels.find(
550
591
  (channel) => channel.name === buildsBroadcastChannelName,
@@ -572,6 +613,7 @@ const setupWebEvoluDeps = ({
572
613
  sqliteSchema: { tables: {}, indexes: [] },
573
614
  encryptionKey: testAppOwner.encryptionKey,
574
615
  memoryOnly: false,
616
+ sharedWorkerId: createIdFromString<"SharedWorker">("worker"),
575
617
  port: createClosableNativePort() as unknown as NativeMessagePort<
576
618
  DbWorkerOutput,
577
619
  DbWorkerInput
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  exhaustiveCheck,
3
+ tryAsync,
3
4
  trySync,
4
5
  type ConsoleDep,
5
6
  type ReloadApp,
@@ -10,7 +11,9 @@ import type {
10
11
  SharedWorker as CommonSharedWorker,
11
12
  CreateDbWorker,
12
13
  DbWorkerInit,
14
+ Evolu,
13
15
  EvoluDeps,
16
+ RequestPersistentStorageDep,
14
17
  SharedWorkerId,
15
18
  SharedWorkerInput,
16
19
  SharedWorkerOutput,
@@ -38,23 +41,6 @@ export interface SharedWorkerUnsupportedDep {
38
41
  readonly onSharedWorkerUnsupported: () => void;
39
42
  }
40
43
 
41
- export interface StorageUnavailableDep {
42
- /**
43
- * Called when the browser offers no persistent storage, as in Safari's
44
- * Private Browsing, so every database is kept in memory. See Storage in the
45
- * Shared module of `@evolu/common`.
46
- *
47
- * The app keeps working. Data synced with a relay comes back, as on a new
48
- * device, and nothing stays on the device once the tabs close. That suits
49
- * someone checking their app on a borrowed phone, so a message such as
50
- * "Nothing from this session is kept on this device." tells the user what to
51
- * expect. Data that exists only locally, or has not synced yet, is lost when
52
- * the tab hosting the database closes or navigates away, even while other
53
- * tabs stay open.
54
- */
55
- readonly onStorageUnavailable: () => void;
56
- }
57
-
58
44
  /**
59
45
  * Creates Evolu dependencies for the web platform.
60
46
  *
@@ -75,9 +61,20 @@ export interface StorageUnavailableDep {
75
61
  * the user comes back to it.
76
62
  *
77
63
  * Where the browser offers no persistent storage, as in Safari's Private
78
- * Browsing, the database is kept in memory, and
79
- * {@link StorageUnavailableDep.onStorageUnavailable} lets the app tell the
80
- * user.
64
+ * Browsing or a Firefox private window, the database is kept in memory, and
65
+ * {@link Evolu.devicePersistence} resolves to `NotPersisted`, so the app can
66
+ * tell the user. Data that exists only locally, or has not synced yet, is lost
67
+ * when the tab hosting the database closes or navigates away, even while other
68
+ * tabs stay open.
69
+ *
70
+ * After the first local mutation of a database the browser stores, this tab
71
+ * asks the browser once with `navigator.storage.persist()` not to delete the
72
+ * site's data when disk space runs low. Chrome and Safari decide silently, and
73
+ * Firefox asks the user, so every tab asks until the user allows it. A custom
74
+ * {@link RequestPersistentStorageDep.requestPersistentStorage} replaces the
75
+ * request, for example with `constVoid` to never ask. See [Will my data stay on
76
+ * the
77
+ * device?](https://www.evolu.dev/docs/faq#will-my-data-stay-on-the-device).
81
78
  *
82
79
  * A custom {@link ReloadApp} replaces the default page reload, for example to
83
80
  * save state first. It should end by reloading the page, because the other
@@ -88,7 +85,7 @@ export const createEvoluDeps = (
88
85
  deps: Partial<ConsoleDep> &
89
86
  Partial<ReloadAppDep> &
90
87
  Partial<SharedWorkerUnsupportedDep> &
91
- Partial<StorageUnavailableDep> = {},
88
+ Partial<RequestPersistentStorageDep> = {},
92
89
  ): EvoluDeps => {
93
90
  installOneTabSharedWorkerPolyfill();
94
91
  const reloadThisApp = deps.reloadApp ?? reloadApp;
@@ -110,6 +107,7 @@ export const createEvoluDeps = (
110
107
  const earlyAnnouncedWorkerIds = new Set<SharedWorkerId>();
111
108
  // Checks whether the user left this tab while another build waits.
112
109
  let focusCheckId: ReturnType<typeof setInterval> | null = null;
110
+ let isPersistentStorageRequested = false;
113
111
 
114
112
  using disposer = new DisposableStack();
115
113
  const buildsBroadcastChannel = disposer.use(
@@ -225,12 +223,6 @@ export const createEvoluDeps = (
225
223
  break;
226
224
  }
227
225
 
228
- case "StorageUnavailable": {
229
- deps.onStorageUnavailable?.();
230
- forward(message);
231
- break;
232
- }
233
-
234
226
  case "SharedWorkerUnsupported": {
235
227
  if (deps.onSharedWorkerUnsupported) {
236
228
  deps.onSharedWorkerUnsupported();
@@ -292,8 +284,18 @@ export const createEvoluDeps = (
292
284
  },
293
285
  };
294
286
 
287
+ const requestPersistentStorage = (): void => {
288
+ if (isPersistentStorageRequested) return;
289
+ isPersistentStorageRequested = true;
290
+ void tryAsync(async () => {
291
+ if (await navigator.storage.persisted()) return;
292
+ await navigator.storage.persist();
293
+ });
294
+ };
295
+
295
296
  const evoluDeps = disposer.use(
296
297
  createCommonEvoluDeps({
298
+ requestPersistentStorage,
297
299
  ...deps,
298
300
  createDbWorker,
299
301
  createBroadcastChannel,
@@ -4,34 +4,62 @@ 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,
24
- // Safari's Private Browsing offers no OPFS; see Storage in the Shared module.
25
- isPersistentStorageAvailable: async () =>
26
- (await tryAsync(() => navigator.storage.getDirectory())).ok,
45
+ // Safari's Private Browsing and Firefox's private windows offer no OPFS, and
46
+ // a browser may delete it, as Chrome's incognito does when the session ends;
47
+ // see Storage in the Shared module.
48
+ getDevicePersistence: async () =>
49
+ (await tryAsync(() => navigator.storage.getDirectory())).ok
50
+ ? "Unknown"
51
+ : "NotPersisted",
27
52
  lockManager: navigator.locks,
28
53
  });
29
54
 
30
55
  void run(async (run) => {
31
56
  if ("onconnect" in self) {
32
- await using _ = await run.ok(
33
- initSharedWorker(createSharedWorkerSelf(self)),
34
- );
57
+ // A panic closes the worker, so tabs that connect later start another.
58
+ using workerSelf = createSharedWorkerSelf<
59
+ SharedWorkerInput,
60
+ SharedWorkerOutput
61
+ >(self);
62
+ await using _ = await run.ok(initSharedWorker(workerSelf));
35
63
  return await run(waitForAbort);
36
64
  }
37
65