@evolu/web 3.1.1 → 3.2.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":"Platform.d.ts","sourceRoot":"","sources":["../../src/Platform.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAE5D,+DAA+D;AAC/D,eAAO,MAAM,eAAe,QAAO,OAIlC,CAAC;AAEF,yEAAyE;AACzE,eAAO,MAAM,oBAAoB,QAAO,WACY,CAAC;AAErD,eAAO,MAAM,SAAS,EAAE,SAMvB,CAAC"}
1
+ {"version":3,"file":"Platform.d.ts","sourceRoot":"","sources":["../../src/Platform.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAE5D,+DAA+D;AAC/D,eAAO,MAAM,eAAe,QAAO,OAIlC,CAAC;AAEF,yEAAyE;AACzE,eAAO,MAAM,oBAAoB,QAAO,WACY,CAAC;AAErD,eAAO,MAAM,SAAS,EAAE,SAOvB,CAAC"}
@@ -10,5 +10,8 @@ export const reloadApp = (url) => {
10
10
  if (typeof document === "undefined") {
11
11
  return;
12
12
  }
13
- location.replace(url ?? "/");
13
+ if (url === undefined)
14
+ location.reload();
15
+ else
16
+ location.replace(url);
14
17
  };
@@ -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;AAiCnE,eAAO,MAAM,sBAAsB,EAAE,kBAoHlC,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,kBAmLlC,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 { bytesToHex, createPreparedStatementsCache, exhaustiveCheck, ok, } from "@evolu/common";
53
+ import { bytesToHex, createPreparedStatementsCache, exhaustiveCheck, ok, performanceDurationBetween, PositiveMillis, sleep, tryAsync, } from "@evolu/common";
54
54
  import sqlite3InitModule, {} from "@evolu/sqlite-wasm";
55
55
  // @ts-expect-error Missing types.
56
56
  globalThis.sqlite3ApiConfig = {
@@ -67,7 +67,7 @@ globalThis.sqlite3ApiConfig = {
67
67
  // Init ASAP.
68
68
  const sqlite3Promise = sqlite3InitModule();
69
69
  const fileName = "evolu1.db";
70
- export const createWasmSqliteDriver = (name, options) => async () => {
70
+ export const createWasmSqliteDriver = (name, options) => async (run) => {
71
71
  const env_1 = { stack: [], error: void 0, hasError: false };
72
72
  try {
73
73
  const sqlite3 = await sqlite3Promise;
@@ -77,6 +77,57 @@ export const createWasmSqliteDriver = (name, options) => async () => {
77
77
  });
78
78
  let deleteDatabaseFile = false;
79
79
  const createOpfsSAHPoolVfs = async (options) => {
80
+ // Evolu opens a database only while it holds the database lock, but a
81
+ // DbWorker that ended without closing it, because its tab closed,
82
+ // crashed or navigated away, can hold pool files after the lock has
83
+ // passed on: WebKit releases a terminated worker's locks and its files
84
+ // separately, in no set order. sqlite-wasm cannot set up a pool with a
85
+ // held file, and it then deletes the pool directory, which the held file
86
+ // usually but not always prevents. Only a worker that has ended can hold
87
+ // the files, and it can only release them, so the pool is set up once
88
+ // every file opens.
89
+ const canOpenPoolFiles = async () => {
90
+ const root = await navigator.storage.getDirectory();
91
+ // sqlite-wasm keeps the pool in `.${name}/.opaque` in both OPFS modes.
92
+ const poolDirectory = await tryAsync(() => root.getDirectoryHandle(`.${name}`));
93
+ if (!poolDirectory.ok)
94
+ return true;
95
+ const opaqueDirectory = await tryAsync(() => poolDirectory.value.getDirectoryHandle(".opaque"));
96
+ if (!opaqueDirectory.ok)
97
+ return true;
98
+ for await (const handle of opaqueDirectory.value.values()) {
99
+ if (handle.kind !== "file")
100
+ continue;
101
+ const accessHandle = await tryAsync(() => handle.createSyncAccessHandle());
102
+ if (!accessHandle.ok) {
103
+ // WebKit rejects a held file with InvalidStateError, other
104
+ // engines with NoModificationAllowedError, as the spec says.
105
+ // WebKit also uses InvalidStateError for a closed or invalid
106
+ // handle and a stopped context. A retry opens fresh handles from
107
+ // a new listing, and a stopped context ends the loop with its
108
+ // worker, so retrying those is harmless.
109
+ if (accessHandle.error instanceof DOMException &&
110
+ (accessHandle.error.name === "InvalidStateError" ||
111
+ accessHandle.error.name === "NoModificationAllowedError"))
112
+ return false;
113
+ throw accessHandle.error;
114
+ }
115
+ accessHandle.value.close();
116
+ }
117
+ return true;
118
+ };
119
+ const waitStart = run.deps.time.performance.now();
120
+ let retryDelay = 50;
121
+ let isWaitReported = false;
122
+ while (!(await canOpenPoolFiles())) {
123
+ const waited = performanceDurationBetween(waitStart, run.deps.time.performance.now());
124
+ if (!isWaitReported && waited >= 5000) {
125
+ isWaitReported = true;
126
+ run.deps.console.warn(`Waiting for an ended DbWorker to release the files of database ${name}.`);
127
+ }
128
+ await run.ok(sleep(PositiveMillis.orThrow(retryDelay)));
129
+ retryDelay = Math.min(retryDelay * 2, 1000);
130
+ }
80
131
  const pool = await sqlite3.installOpfsSAHPoolVfs(options);
81
132
  if (pool.isPaused())
82
133
  await pool.unpauseVfs();
@@ -7,13 +7,16 @@ import { type DisposableRun, type RunCustomDeps } from "@evolu/common";
7
7
  /**
8
8
  * Creates a root {@link Run} for the browser.
9
9
  *
10
- * Defects are reported with the browser's global `reportError`. A custom
11
- * `reportDefect` dependency overrides the browser default.
10
+ * Defects are reported with the browser's global `reportError` as an `Error`
11
+ * from {@link defectToError}, because browsers show any other value only as
12
+ * unhelpful text. A custom `reportDefect` dependency overrides the browser
13
+ * default.
12
14
  *
13
15
  * ### Example
14
16
  *
15
17
  * ```ts
16
18
  * import {
19
+ * assertEqual,
17
20
  * createConsole,
18
21
  * createConsoleFormatter,
19
22
  * ok,
@@ -29,7 +32,7 @@ import { type DisposableRun, type RunCustomDeps } from "@evolu/common";
29
32
  * await using run = createRun({ console });
30
33
  * const appPromise = run.ok(() => ok("started"));
31
34
  *
32
- * expect(await appPromise).toBe("started");
35
+ * assertEqual(await appPromise, "started");
33
36
  * ```
34
37
  */
35
38
  export declare function createRun(): DisposableRun;
@@ -1 +1 @@
1
- {"version":3,"file":"Task.d.ts","sourceRoot":"","sources":["../../src/Task.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAEL,KAAK,aAAa,EAElB,KAAK,aAAa,EACnB,MAAM,eAAe,CAAC;AAEvB;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,SAAS,IAAI,aAAa,CAAC;AAE3C,2EAA2E;AAC3E,wBAAgB,SAAS,CAAC,CAAC,SAAS,MAAM,EACxC,IAAI,EAAE,aAAa,CAAC,CAAC,CAAC,GACrB,aAAa,CAAC,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"Task.d.ts","sourceRoot":"","sources":["../../src/Task.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAGL,KAAK,aAAa,EAElB,KAAK,aAAa,EACnB,MAAM,eAAe,CAAC;AAEvB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,SAAS,IAAI,aAAa,CAAC;AAE3C,2EAA2E;AAC3E,wBAAgB,SAAS,CAAC,CAAC,SAAS,MAAM,EACxC,IAAI,EAAE,aAAa,CAAC,CAAC,CAAC,GACrB,aAAa,CAAC,CAAC,CAAC,CAAC"}
package/dist/src/Task.js CHANGED
@@ -3,10 +3,10 @@
3
3
  *
4
4
  * @module
5
5
  */
6
- import { createRun as createCommonRun, } from "@evolu/common";
6
+ import { createRun as createCommonRun, defectToError, } from "@evolu/common";
7
7
  export function createRun(deps) {
8
8
  const reportDefect = (reported) => {
9
- reportError(reported);
9
+ reportError(defectToError(reported));
10
10
  };
11
11
  return deps === undefined
12
12
  ? createCommonRun({ reportDefect })
@@ -1,4 +1,4 @@
1
- import { type ConsoleDep } from "@evolu/common";
1
+ import { type ConsoleDep, type ReloadAppDep } from "@evolu/common";
2
2
  import type { EvoluDeps } from "@evolu/common/local-first";
3
3
  export interface SharedWorkerUnsupported {
4
4
  readonly type: "SharedWorkerUnsupported";
@@ -6,6 +6,50 @@ export interface SharedWorkerUnsupported {
6
6
  export interface SharedWorkerUnsupportedDep {
7
7
  readonly onSharedWorkerUnsupported: () => void;
8
8
  }
9
- /** Creates Evolu dependencies for the web platform. */
10
- export declare const createEvoluDeps: (deps?: Partial<ConsoleDep> & Partial<SharedWorkerUnsupportedDep>) => EvoluDeps;
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
+ /**
26
+ * Creates Evolu dependencies for the web platform.
27
+ *
28
+ * When another build of the app waits for this one, this tab reloads to load
29
+ * the build the server now serves: at once if the user is not in the tab,
30
+ * otherwise once they leave it. See Builds in the Shared module of
31
+ * `@evolu/common`. A tab whose database refuses to start with
32
+ * {@link UnsupportedDbVersionError} reloads once for each stored version, even
33
+ * while the user is in it, because the server may now serve a build that
34
+ * supports the database. The error is reported when the reloaded build refuses
35
+ * it too, or when the tab has no session storage. A tab whose worker was
36
+ * relaunched without its state, as WebKit does when the process hosting it
37
+ * ends, also reloads at once.
38
+ *
39
+ * When the page enters the browser's back-forward cache, as Safari does on
40
+ * every navigation away, this tab ends its part as if it closed: it stops the
41
+ * database workers it hosts, so another tab takes them over, and it reloads if
42
+ * the user comes back to it.
43
+ *
44
+ * 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.
48
+ *
49
+ * A custom {@link ReloadApp} replaces the default page reload, for example to
50
+ * save state first. It should end by reloading the page, because the other
51
+ * build waits until this tab reloads or closes, and a page restored from the
52
+ * back-forward cache cannot work until it reloads.
53
+ */
54
+ export declare const createEvoluDeps: (deps?: Partial<ConsoleDep> & Partial<ReloadAppDep> & Partial<SharedWorkerUnsupportedDep> & Partial<StorageUnavailableDep>) => EvoluDeps;
11
55
  //# 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,EAAmB,KAAK,UAAU,EAAE,MAAM,eAAe,CAAC;AACjE,OAAO,KAAK,EAIV,SAAS,EAGV,MAAM,2BAA2B,CAAC;AAiBnC,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,uDAAuD;AACvD,eAAO,MAAM,eAAe,UACpB,OAAO,CAAC,UAAU,CAAC,GAAG,OAAO,CAAC,0BAA0B,CAAC,KAC9D,SAkEF,CAAC"}
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,57 +1,343 @@
1
- import { exhaustiveCheck } from "@evolu/common";
2
- import { createEvoluDeps as createCommonEvoluDeps } from "@evolu/common/local-first";
1
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
+ if (value !== null && value !== void 0) {
3
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
+ var dispose, inner;
5
+ if (async) {
6
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
+ dispose = value[Symbol.asyncDispose];
8
+ }
9
+ if (dispose === void 0) {
10
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
+ dispose = value[Symbol.dispose];
12
+ if (async) inner = dispose;
13
+ }
14
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
+ env.stack.push({ value: value, dispose: dispose, async: async });
17
+ }
18
+ else if (async) {
19
+ env.stack.push({ async: true });
20
+ }
21
+ return value;
22
+ };
23
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
+ return function (env) {
25
+ function fail(e) {
26
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
+ env.hasError = true;
28
+ }
29
+ var r, s = 0;
30
+ function next() {
31
+ while (r = env.stack.pop()) {
32
+ try {
33
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
+ if (r.dispose) {
35
+ var result = r.dispose.call(r.value);
36
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
+ }
38
+ else s |= 1;
39
+ }
40
+ catch (e) {
41
+ fail(e);
42
+ }
43
+ }
44
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
+ if (env.hasError) throw env.error;
46
+ }
47
+ return next();
48
+ };
49
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
+ var e = new Error(message);
51
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
+ });
53
+ import { exhaustiveCheck, trySync, } from "@evolu/common";
54
+ import { BuildWaiting, BuildWaitingRequest, buildsBroadcastChannelName, createEvoluDeps as createCommonEvoluDeps, } from "@evolu/common/local-first";
3
55
  import { reloadApp } from "../Platform.js";
4
56
  import { createBroadcastChannel, createMessageChannel, createSharedWorker, createWorker, installOneTabSharedWorkerPolyfill, } from "../Worker.js";
5
- /** Creates Evolu dependencies for the web platform. */
57
+ /**
58
+ * Creates Evolu dependencies for the web platform.
59
+ *
60
+ * When another build of the app waits for this one, this tab reloads to load
61
+ * the build the server now serves: at once if the user is not in the tab,
62
+ * otherwise once they leave it. See Builds in the Shared module of
63
+ * `@evolu/common`. A tab whose database refuses to start with
64
+ * {@link UnsupportedDbVersionError} reloads once for each stored version, even
65
+ * while the user is in it, because the server may now serve a build that
66
+ * supports the database. The error is reported when the reloaded build refuses
67
+ * it too, or when the tab has no session storage. A tab whose worker was
68
+ * relaunched without its state, as WebKit does when the process hosting it
69
+ * ends, also reloads at once.
70
+ *
71
+ * When the page enters the browser's back-forward cache, as Safari does on
72
+ * every navigation away, this tab ends its part as if it closed: it stops the
73
+ * database workers it hosts, so another tab takes them over, and it reloads if
74
+ * the user comes back to it.
75
+ *
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.
80
+ *
81
+ * A custom {@link ReloadApp} replaces the default page reload, for example to
82
+ * save state first. It should end by reloading the page, because the other
83
+ * build waits until this tab reloads or closes, and a page restored from the
84
+ * back-forward cache cannot work until it reloads.
85
+ */
6
86
  export const createEvoluDeps = (deps = {}) => {
7
- installOneTabSharedWorkerPolyfill();
8
- const createDbWorker = () => createWorker(new Worker(new URL("Db.worker.js", import.meta.url), {
9
- type: "module",
10
- }));
11
- const webSharedWorker = createSharedWorker(new SharedWorker(new URL("Shared.worker.js", import.meta.url), {
12
- type: "module",
13
- }));
14
- let onSharedWorkerMessage = null;
15
- const sharedWorker = {
16
- ...webSharedWorker,
17
- port: {
18
- ...webSharedWorker.port,
19
- get onMessage() {
20
- return onSharedWorkerMessage;
21
- },
22
- set onMessage(fn) {
23
- onSharedWorkerMessage = fn;
24
- webSharedWorker.port.onMessage = fn
25
- ? (message) => {
26
- switch (message.type) {
27
- case "DbWorkerInit": {
28
- fn(message);
29
- break;
30
- }
31
- case "SharedWorkerUnsupported": {
32
- if (deps.onSharedWorkerUnsupported) {
33
- deps.onSharedWorkerUnsupported();
34
- }
35
- else {
36
- alert("This browser supports Evolu in one tab only. Close this tab and use the already open tab.");
37
- }
38
- break;
39
- }
40
- default:
41
- exhaustiveCheck(message);
42
- }
87
+ const env_1 = { stack: [], error: void 0, hasError: false };
88
+ try {
89
+ installOneTabSharedWorkerPolyfill();
90
+ const reloadThisApp = deps.reloadApp ?? reloadApp;
91
+ // A page an automatic reload loaded never announces its build, so two builds
92
+ // cannot keep reloading each other.
93
+ const isAutomaticReload = takeAutomaticReload();
94
+ // Waiting workers this tab already reloaded for. A reload that loaded the
95
+ // running build again did not help, so the tab does not repeat it.
96
+ const reloadedForWorkerIds = getReloadedForWorkerIds();
97
+ // Versions a refusal already reloaded this tab for, read once, so every
98
+ // refusal before this page unloads reloads it.
99
+ const refusalReloadVersions = getRefusalReloadVersions();
100
+ let connectedWorkerId = null;
101
+ let waitingWorkerId = null;
102
+ // Workers announced before this tab connected. The lock can pass to this
103
+ // tab's worker first, and their tabs may not answer a request, for example
104
+ // when frozen, so the tab handles them once it connects.
105
+ const earlyAnnouncedWorkerIds = new Set();
106
+ // Checks whether the user left this tab while another build waits.
107
+ let focusCheckId = null;
108
+ const disposer = __addDisposableResource(env_1, new DisposableStack(), false);
109
+ const buildsBroadcastChannel = disposer.use(createBroadcastChannel(buildsBroadcastChannelName));
110
+ const stopFocusCheck = () => {
111
+ if (focusCheckId === null)
112
+ return;
113
+ clearInterval(focusCheckId);
114
+ focusCheckId = null;
115
+ };
116
+ disposer.defer(stopFocusCheck);
117
+ const announceWaitingBuild = () => {
118
+ if (waitingWorkerId === null || isAutomaticReload)
119
+ return;
120
+ buildsBroadcastChannel.postMessage({
121
+ type: "BuildWaiting",
122
+ workerId: waitingWorkerId,
123
+ });
124
+ };
125
+ const reloadForWaitingBuild = (workerId) => {
126
+ stopFocusCheck();
127
+ // Without session storage, the reloaded page could announce in turn.
128
+ if (!markAutomaticReload(workerId))
129
+ return;
130
+ reloadThisApp();
131
+ };
132
+ const handleWaitingBuild = (workerId) => {
133
+ if (workerId === connectedWorkerId || reloadedForWorkerIds.has(workerId)) {
134
+ return;
135
+ }
136
+ if (!document.hasFocus()) {
137
+ reloadForWaitingBuild(workerId);
138
+ return;
139
+ }
140
+ // Focus can leave the page from a frame without a window event.
141
+ focusCheckId ??= setInterval(() => {
142
+ if (!document.hasFocus())
143
+ reloadForWaitingBuild(workerId);
144
+ }, 1000);
145
+ };
146
+ // Builds of different releases share the channel, so messages are validated.
147
+ buildsBroadcastChannel.onMessage = (message) => {
148
+ if (BuildWaitingRequest.is(message)) {
149
+ announceWaitingBuild();
150
+ }
151
+ else if (BuildWaiting.is(message)) {
152
+ if (connectedWorkerId === null) {
153
+ earlyAnnouncedWorkerIds.add(message.workerId);
154
+ }
155
+ else
156
+ handleWaitingBuild(message.workerId);
157
+ }
158
+ };
159
+ const handleSharedWorkerMessage = (message, forward) => {
160
+ // WebKit can relaunch a SharedWorker without its state when the process
161
+ // hosting it ends, and connect the tabs' existing ports to the new worker
162
+ // (https://bugs.webkit.org/show_bug.cgi?id=318873). A worker tells a tab
163
+ // at most once that it waits and once that it connected, both with its own
164
+ // id, so only then does a tab hear from a worker with another id. The
165
+ // tab's state, including the databases it asked for while it waited, ended
166
+ // with the old worker, so it reloads at once.
167
+ const knownWorkerId = connectedWorkerId ?? waitingWorkerId;
168
+ if ((message.type === "Waiting" || message.type === "Connected") &&
169
+ knownWorkerId !== null &&
170
+ message.workerId !== knownWorkerId) {
171
+ reloadThisApp();
172
+ return;
173
+ }
174
+ switch (message.type) {
175
+ case "DbWorkerInit": {
176
+ forward(message);
177
+ break;
178
+ }
179
+ case "Error": {
180
+ // The page is about to reload, so the error is not reported.
181
+ if (message.error.type === "UnsupportedDbVersionError" &&
182
+ !refusalReloadVersions.has(String(message.error.storedVersion)) &&
183
+ markRefusalReload(message.error)) {
184
+ reloadThisApp();
185
+ break;
186
+ }
187
+ forward(message);
188
+ break;
189
+ }
190
+ case "Waiting": {
191
+ waitingWorkerId = message.workerId;
192
+ announceWaitingBuild();
193
+ forward(message);
194
+ break;
195
+ }
196
+ case "Connected": {
197
+ waitingWorkerId = null;
198
+ connectedWorkerId = message.workerId;
199
+ forward(message);
200
+ for (const workerId of earlyAnnouncedWorkerIds) {
201
+ handleWaitingBuild(workerId);
202
+ }
203
+ // An announcement may have come before this tab started.
204
+ buildsBroadcastChannel.postMessage({
205
+ type: "BuildWaitingRequest",
206
+ });
207
+ break;
208
+ }
209
+ case "StorageUnavailable": {
210
+ deps.onStorageUnavailable?.();
211
+ forward(message);
212
+ break;
213
+ }
214
+ case "SharedWorkerUnsupported": {
215
+ if (deps.onSharedWorkerUnsupported) {
216
+ deps.onSharedWorkerUnsupported();
43
217
  }
44
- : null;
218
+ else {
219
+ alert("This browser supports Evolu in one tab only. Close this tab and use the already open tab.");
220
+ }
221
+ break;
222
+ }
223
+ default:
224
+ exhaustiveCheck(message);
225
+ }
226
+ };
227
+ // Disposing the deps ends the DbWorkers this tab hosts, which releases their
228
+ // database locks for the tab that takes over.
229
+ const dbWorkers = new Set();
230
+ disposer.defer(() => {
231
+ for (const dbWorker of dbWorkers)
232
+ dbWorker[Symbol.dispose]();
233
+ });
234
+ const createDbWorker = () => {
235
+ const dbWorker = createWorker(new Worker(new URL("Db.worker.js", import.meta.url), {
236
+ type: "module",
237
+ }));
238
+ dbWorkers.add(dbWorker);
239
+ return dbWorker;
240
+ };
241
+ const webSharedWorker = createSharedWorker(new SharedWorker(new URL("Shared.worker.js", import.meta.url), {
242
+ type: "module",
243
+ }));
244
+ let onSharedWorkerMessage = null;
245
+ const sharedWorker = {
246
+ ...webSharedWorker,
247
+ port: {
248
+ ...webSharedWorker.port,
249
+ get onMessage() {
250
+ return onSharedWorkerMessage;
251
+ },
252
+ set onMessage(fn) {
253
+ onSharedWorkerMessage = fn;
254
+ webSharedWorker.port.onMessage = fn
255
+ ? (message) => {
256
+ handleSharedWorkerMessage(message, fn);
257
+ }
258
+ : null;
259
+ },
45
260
  },
46
- },
47
- };
48
- return createCommonEvoluDeps({
49
- ...deps,
50
- createDbWorker,
51
- createBroadcastChannel,
52
- createMessageChannel,
53
- lockManager: navigator.locks,
54
- reloadApp,
55
- sharedWorker,
261
+ };
262
+ const evoluDeps = disposer.use(createCommonEvoluDeps({
263
+ ...deps,
264
+ createDbWorker,
265
+ createBroadcastChannel,
266
+ createMessageChannel,
267
+ lockManager: navigator.locks,
268
+ reloadApp: reloadThisApp,
269
+ sharedWorker,
270
+ }));
271
+ // A page entering the back-forward cache is frozen with its DbWorkers, which
272
+ // keep their database locks, so the other tabs would stall. WebKit also
273
+ // releases the page's own locks, so a restored page would work with state
274
+ // the other tabs gave up on. The page therefore ends its part as if it
275
+ // closed, and reloads when it is shown again.
276
+ const reloadRestoredPage = (event) => {
277
+ if (event.persisted)
278
+ reloadThisApp();
279
+ };
280
+ const handlePageHide = (event) => {
281
+ if (!event.persisted)
282
+ return;
283
+ addEventListener("pageshow", reloadRestoredPage, { once: true });
284
+ disposables.dispose();
285
+ };
286
+ addEventListener("pagehide", handlePageHide);
287
+ disposer.defer(() => {
288
+ removeEventListener("pagehide", handlePageHide);
289
+ });
290
+ const disposables = disposer.move();
291
+ return {
292
+ ...evoluDeps,
293
+ [Symbol.dispose]: () => {
294
+ disposables.dispose();
295
+ },
296
+ };
297
+ }
298
+ catch (e_1) {
299
+ env_1.error = e_1;
300
+ env_1.hasError = true;
301
+ }
302
+ finally {
303
+ __disposeResources(env_1);
304
+ }
305
+ };
306
+ // Marks, for the tab's session, that Evolu reloaded the page for another build.
307
+ const automaticReloadKey = "evolu:automatic-reload";
308
+ // Holds, for the tab's session, every waiting worker Evolu reloaded it for.
309
+ const reloadedForKey = "evolu:reloaded-for";
310
+ // Removes the mark, so only the page the reload loaded sees it.
311
+ const takeAutomaticReload = () => {
312
+ const item = trySync(() => {
313
+ const value = sessionStorage.getItem(automaticReloadKey);
314
+ sessionStorage.removeItem(automaticReloadKey);
315
+ return value;
56
316
  });
317
+ return item.ok && item.value !== null;
318
+ };
319
+ const getReloadedForWorkerIds = () => {
320
+ const item = trySync(() => sessionStorage.getItem(reloadedForKey));
321
+ return new Set(item.ok && item.value !== null ? item.value.split(",") : []);
322
+ };
323
+ const markAutomaticReload = (workerId) => trySync(() => {
324
+ const workerIds = new Set(sessionStorage.getItem(reloadedForKey)?.split(","));
325
+ workerIds.add(workerId);
326
+ sessionStorage.setItem(reloadedForKey, [...workerIds].join(","));
327
+ sessionStorage.setItem(automaticReloadKey, "1");
328
+ }).ok;
329
+ // Holds, for the tab's session, every stored database version a refusal
330
+ // reloaded the page for. Each version reloads it once, so refusals of several
331
+ // databases cannot keep reloading it, and a build that refuses them again
332
+ // reports the error instead.
333
+ const refusalReloadKey = "evolu:refusal-reloads";
334
+ const getRefusalReloadVersions = () => {
335
+ const item = trySync(() => sessionStorage.getItem(refusalReloadKey));
336
+ return new Set(item.ok && item.value !== null ? item.value.split(",") : []);
57
337
  };
338
+ // Adds to the stored versions, which include those marked earlier in this page.
339
+ const markRefusalReload = (error) => trySync(() => {
340
+ const versions = new Set(sessionStorage.getItem(refusalReloadKey)?.split(","));
341
+ versions.add(String(error.storedVersion));
342
+ sessionStorage.setItem(refusalReloadKey, [...versions].join(","));
343
+ }).ok;
@@ -53,13 +53,15 @@ 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, waitForAbort } from "@evolu/common";
56
+ import { createWebSocket, ok, tryAsync, waitForAbort } from "@evolu/common";
57
57
  import { initSharedWorker } from "@evolu/common/local-first";
58
58
  import { createRun } from "../Task.js";
59
59
  import { createOneTabSharedWorkerSelfPolyfill, createSharedWorkerSelf, createWorkerDeps, } from "../Worker.js";
60
60
  const run = createRun({
61
61
  ...createWorkerDeps(),
62
62
  createWebSocket,
63
+ // Safari's Private Browsing offers no OPFS; see Storage in the Shared module.
64
+ isPersistentStorageAvailable: async () => (await tryAsync(() => navigator.storage.getDirectory())).ok,
63
65
  lockManager: navigator.locks,
64
66
  });
65
67
  void run(async (run) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evolu/web",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
4
4
  "description": "Evolu for web",
5
5
  "keywords": [
6
6
  "evolu",
@@ -32,11 +32,10 @@
32
32
  "README.md"
33
33
  ],
34
34
  "dependencies": {
35
- "@evolu/sqlite-wasm": "2.2.4",
36
- "idb-keyval": "^6.2.2"
35
+ "@evolu/sqlite-wasm": "2.2.4"
37
36
  },
38
37
  "devDependencies": {
39
- "@evolu/common": "8.8.0",
38
+ "@evolu/common": "8.11.0",
40
39
  "@evolu/typescript-config": "0.1.1",
41
40
  "@types/node": "^24.10.9",
42
41
  "@types/sharedworker": "^0.0.229",
@@ -45,7 +44,7 @@
45
44
  "user-agent-data-types": "^0.4.2"
46
45
  },
47
46
  "peerDependencies": {
48
- "@evolu/common": "^8.0.0"
47
+ "@evolu/common": "^8.11.0"
49
48
  },
50
49
  "publishConfig": {
51
50
  "access": "public"