@evolu/web 3.1.2 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/Platform.d.ts.map +1 -1
- package/dist/src/Platform.js +4 -1
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +70 -12
- package/dist/src/Task.d.ts +6 -3
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +2 -2
- package/dist/src/Worker.d.ts +38 -1
- package/dist/src/Worker.d.ts.map +1 -1
- package/dist/src/Worker.js +61 -2
- package/dist/src/local-first/Evolu.d.ts +47 -3
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +336 -50
- package/dist/src/local-first/Shared.worker.js +14 -4
- package/package.json +4 -5
- package/src/Platform.test.ts +29 -1
- package/src/Platform.ts +2 -1
- package/src/Sqlite.test.ts +181 -4
- package/src/Sqlite.ts +85 -12
- package/src/Task.test.ts +3 -14
- package/src/Task.ts +8 -4
- package/src/Worker.test.ts +59 -0
- package/src/Worker.ts +66 -1
- package/src/local-first/Evolu.test.ts +601 -2
- package/src/local-first/Evolu.ts +321 -40
- package/src/local-first/Shared.worker.ts +32 -5
- package/dist/src/local-first/LocalAuth.d.ts +0 -3
- package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
- package/dist/src/local-first/LocalAuth.js +0 -185
- package/src/local-first/LocalAuth.ts +0 -320
package/src/Sqlite.test.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AbortError,
|
|
3
|
+
assert,
|
|
2
4
|
assertEqual,
|
|
3
5
|
assertEqualBytes,
|
|
6
|
+
assertSame,
|
|
4
7
|
assertTrue,
|
|
8
|
+
assertType,
|
|
5
9
|
EncryptionKey,
|
|
6
10
|
Name,
|
|
7
11
|
sql,
|
|
@@ -9,6 +13,90 @@ import {
|
|
|
9
13
|
} from "@evolu/common";
|
|
10
14
|
import { describe, it, mock } from "node:test";
|
|
11
15
|
|
|
16
|
+
// OPFS with an optional pool directory. Each pool file lists the error names
|
|
17
|
+
// its next openings reject with, in order.
|
|
18
|
+
const opfsMock = (() => {
|
|
19
|
+
const state = {
|
|
20
|
+
closedAccessHandleCount: 0,
|
|
21
|
+
directoryNames: [] as Array<string>,
|
|
22
|
+
getDirectoryCount: 0,
|
|
23
|
+
hasOpaqueDirectory: true,
|
|
24
|
+
openedAccessHandleCount: 0,
|
|
25
|
+
poolFiles: null as Array<Array<string>> | null,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const opaqueDirectory = {
|
|
29
|
+
// for await also iterates a sync iterable.
|
|
30
|
+
values: function* () {
|
|
31
|
+
yield { kind: "directory" };
|
|
32
|
+
for (const errorNames of state.poolFiles ?? [])
|
|
33
|
+
yield {
|
|
34
|
+
kind: "file",
|
|
35
|
+
createSyncAccessHandle: () => {
|
|
36
|
+
const errorName = errorNames.shift();
|
|
37
|
+
if (errorName !== undefined)
|
|
38
|
+
return Promise.reject(new DOMException("Held", errorName));
|
|
39
|
+
state.openedAccessHandleCount += 1;
|
|
40
|
+
return Promise.resolve({
|
|
41
|
+
close: () => {
|
|
42
|
+
state.closedAccessHandleCount += 1;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const poolDirectory = {
|
|
51
|
+
getDirectoryHandle: (name: string) => {
|
|
52
|
+
state.directoryNames.push(name);
|
|
53
|
+
return state.hasOpaqueDirectory
|
|
54
|
+
? Promise.resolve(opaqueDirectory)
|
|
55
|
+
: Promise.reject(new DOMException("Missing", "NotFoundError"));
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const root = {
|
|
60
|
+
getDirectoryHandle: (name: string) => {
|
|
61
|
+
state.directoryNames.push(name);
|
|
62
|
+
return state.poolFiles
|
|
63
|
+
? Promise.resolve(poolDirectory)
|
|
64
|
+
: Promise.reject(new DOMException("Missing", "NotFoundError"));
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
Object.defineProperty(navigator, "storage", {
|
|
69
|
+
configurable: true,
|
|
70
|
+
value: {
|
|
71
|
+
getDirectory: () => {
|
|
72
|
+
state.getDirectoryCount += 1;
|
|
73
|
+
return Promise.resolve(root);
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
reset: (
|
|
80
|
+
poolFiles: Array<Array<string>> | null = null,
|
|
81
|
+
{ hasOpaqueDirectory = true } = {},
|
|
82
|
+
) => {
|
|
83
|
+
state.closedAccessHandleCount = 0;
|
|
84
|
+
state.directoryNames.length = 0;
|
|
85
|
+
state.getDirectoryCount = 0;
|
|
86
|
+
state.hasOpaqueDirectory = hasOpaqueDirectory;
|
|
87
|
+
state.openedAccessHandleCount = 0;
|
|
88
|
+
state.poolFiles = poolFiles;
|
|
89
|
+
},
|
|
90
|
+
state,
|
|
91
|
+
};
|
|
92
|
+
})();
|
|
93
|
+
|
|
94
|
+
// Lets resolved mocks settle before test time advances.
|
|
95
|
+
const flushMicrotasks = (): Promise<void> =>
|
|
96
|
+
new Promise((resolve) => {
|
|
97
|
+
setImmediate(resolve);
|
|
98
|
+
});
|
|
99
|
+
|
|
12
100
|
const sqliteMock = (() => {
|
|
13
101
|
class PreparedStatement {
|
|
14
102
|
finalized = false;
|
|
@@ -29,10 +117,6 @@ const sqliteMock = (() => {
|
|
|
29
117
|
return { data: "prepared" };
|
|
30
118
|
}
|
|
31
119
|
|
|
32
|
-
reset(): void {
|
|
33
|
-
this.resetCount += 1;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
120
|
finalize(): void {
|
|
37
121
|
this.finalized = true;
|
|
38
122
|
}
|
|
@@ -105,6 +189,10 @@ const sqliteMock = (() => {
|
|
|
105
189
|
const sqlite3 = {
|
|
106
190
|
capi: {
|
|
107
191
|
sqlite3_js_db_export: mock.fn(() => new Uint8Array([1, 2, 3])),
|
|
192
|
+
sqlite3_reset: mock.fn((statement: PreparedStatement) => {
|
|
193
|
+
statement.resetCount += 1;
|
|
194
|
+
return 0;
|
|
195
|
+
}),
|
|
108
196
|
sqlite3mc_vfs_create: mock.fn(),
|
|
109
197
|
},
|
|
110
198
|
installOpfsSAHPoolVfs: mock.fn(() => Promise.resolve(pool)),
|
|
@@ -115,6 +203,7 @@ const sqliteMock = (() => {
|
|
|
115
203
|
consoleWarn: mock.fn<typeof console.warn>(),
|
|
116
204
|
pool,
|
|
117
205
|
reset: () => {
|
|
206
|
+
opfsMock.reset();
|
|
118
207
|
state.closedDatabases.length = 0;
|
|
119
208
|
state.createdDatabases.length = 0;
|
|
120
209
|
state.deletedFilenames.length = 0;
|
|
@@ -128,6 +217,7 @@ const sqliteMock = (() => {
|
|
|
128
217
|
pool.unpauseVfs.mock.resetCalls();
|
|
129
218
|
pool.unlink.mock.resetCalls();
|
|
130
219
|
sqlite3.capi.sqlite3_js_db_export.mock.resetCalls();
|
|
220
|
+
sqlite3.capi.sqlite3_reset.mock.resetCalls();
|
|
131
221
|
sqlite3.capi.sqlite3mc_vfs_create.mock.resetCalls();
|
|
132
222
|
sqlite3.installOpfsSAHPoolVfs.mock.resetCalls();
|
|
133
223
|
},
|
|
@@ -193,6 +283,7 @@ describe("createWasmSqliteDriver coverage helpers", () => {
|
|
|
193
283
|
|
|
194
284
|
assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
|
|
195
285
|
assertEqual(sqliteMock.state.createdDatabases[0]?.filename, ":memory:");
|
|
286
|
+
assertEqual(opfsMock.state.getDirectoryCount, 0);
|
|
196
287
|
});
|
|
197
288
|
|
|
198
289
|
it("executes non-prepared query and exports database", async () => {
|
|
@@ -354,3 +445,89 @@ describe("createWasmSqliteDriver coverage helpers", () => {
|
|
|
354
445
|
);
|
|
355
446
|
});
|
|
356
447
|
});
|
|
448
|
+
|
|
449
|
+
describe("createWasmSqliteDriver held pool files", () => {
|
|
450
|
+
for (const errorName of ["InvalidStateError", "NoModificationAllowedError"])
|
|
451
|
+
it(`sets up the pool once a file held with ${errorName} is released`, async () => {
|
|
452
|
+
sqliteMock.reset();
|
|
453
|
+
opfsMock.reset([[], [errorName, errorName]]);
|
|
454
|
+
|
|
455
|
+
await using run = testCreateRun();
|
|
456
|
+
const driver = run.ok(createWasmSqliteDriver(Name.orThrow("MockPlain")));
|
|
457
|
+
await flushMicrotasks();
|
|
458
|
+
|
|
459
|
+
assertEqual(opfsMock.state.directoryNames, [".MockPlain", ".opaque"]);
|
|
460
|
+
run.deps.time.advance("49ms");
|
|
461
|
+
await flushMicrotasks();
|
|
462
|
+
assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
|
|
463
|
+
|
|
464
|
+
// The retry delay doubles after each held pass.
|
|
465
|
+
run.deps.time.advance("1ms");
|
|
466
|
+
await flushMicrotasks();
|
|
467
|
+
run.deps.time.advance("99ms");
|
|
468
|
+
await flushMicrotasks();
|
|
469
|
+
assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
|
|
470
|
+
|
|
471
|
+
run.deps.time.advance("1ms");
|
|
472
|
+
using _driver = await driver;
|
|
473
|
+
|
|
474
|
+
// The free file opens on each of the three passes, the held one once.
|
|
475
|
+
assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 1);
|
|
476
|
+
assertEqual(opfsMock.state.openedAccessHandleCount, 4);
|
|
477
|
+
assertEqual(opfsMock.state.closedAccessHandleCount, 4);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("sets up the pool at once when its directory has no files yet", async () => {
|
|
481
|
+
sqliteMock.reset();
|
|
482
|
+
opfsMock.reset([], { hasOpaqueDirectory: false });
|
|
483
|
+
|
|
484
|
+
await using run = testCreateRun();
|
|
485
|
+
using _driver = await run.ok(
|
|
486
|
+
createWasmSqliteDriver(Name.orThrow("MockPlain")),
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
assertEqual(opfsMock.state.directoryNames, [".MockPlain", ".opaque"]);
|
|
490
|
+
assertEqual(opfsMock.state.openedAccessHandleCount, 0);
|
|
491
|
+
assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 1);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("warns once when pool files stay held for five seconds", async () => {
|
|
495
|
+
sqliteMock.reset();
|
|
496
|
+
opfsMock.reset([Array.from({ length: 12 }, () => "InvalidStateError")]);
|
|
497
|
+
|
|
498
|
+
await using run = testCreateRun();
|
|
499
|
+
const driver = run.ok(createWasmSqliteDriver(Name.orThrow("MockPlain")));
|
|
500
|
+
for (let second = 0; second < 12; second += 1) {
|
|
501
|
+
await flushMicrotasks();
|
|
502
|
+
run.deps.time.advance("1s");
|
|
503
|
+
}
|
|
504
|
+
using _driver = await driver;
|
|
505
|
+
|
|
506
|
+
const warnings = run.deps.console
|
|
507
|
+
.getEntriesSnapshot()
|
|
508
|
+
.filter((entry) => entry.method === "warn");
|
|
509
|
+
assertEqual(
|
|
510
|
+
warnings.map((entry) => entry.args),
|
|
511
|
+
[
|
|
512
|
+
[
|
|
513
|
+
"Waiting for an ended DbWorker to release the files of database MockPlain.",
|
|
514
|
+
],
|
|
515
|
+
],
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("fails before setting up the pool when a file cannot be opened", async () => {
|
|
520
|
+
sqliteMock.reset();
|
|
521
|
+
opfsMock.reset([["UnknownError"]]);
|
|
522
|
+
|
|
523
|
+
await using run = testCreateRun();
|
|
524
|
+
const result = await run.abortable(
|
|
525
|
+
createWasmSqliteDriver(Name.orThrow("MockPlain")),
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
assert(!result.ok, "Expected the driver to fail.");
|
|
529
|
+
assertType(AbortError, result.error);
|
|
530
|
+
assertSame(result.error.reason.type, "PanicAbortReason");
|
|
531
|
+
assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
|
|
532
|
+
});
|
|
533
|
+
});
|
package/src/Sqlite.ts
CHANGED
|
@@ -4,6 +4,10 @@ import {
|
|
|
4
4
|
createPreparedStatementsCache,
|
|
5
5
|
exhaustiveCheck,
|
|
6
6
|
ok,
|
|
7
|
+
performanceDurationBetween,
|
|
8
|
+
PositiveMillis,
|
|
9
|
+
sleep,
|
|
10
|
+
tryAsync,
|
|
7
11
|
} from "@evolu/common";
|
|
8
12
|
import sqlite3InitModule, {
|
|
9
13
|
type Database,
|
|
@@ -32,7 +36,7 @@ const sqlite3Promise = sqlite3InitModule();
|
|
|
32
36
|
const fileName = "evolu1.db";
|
|
33
37
|
|
|
34
38
|
export const createWasmSqliteDriver: CreateSqliteDriver =
|
|
35
|
-
(name, options) => async () => {
|
|
39
|
+
(name, options) => async (run) => {
|
|
36
40
|
const sqlite3 = await sqlite3Promise;
|
|
37
41
|
|
|
38
42
|
using disposer = new DisposableStack();
|
|
@@ -45,6 +49,69 @@ export const createWasmSqliteDriver: CreateSqliteDriver =
|
|
|
45
49
|
const createOpfsSAHPoolVfs = async (
|
|
46
50
|
options: Parameters<typeof sqlite3.installOpfsSAHPoolVfs>[0],
|
|
47
51
|
): Promise<SAHPoolUtil> => {
|
|
52
|
+
// Evolu opens a database only while it holds the database lock, but a
|
|
53
|
+
// DbWorker that ended without closing it, because its tab closed,
|
|
54
|
+
// crashed or navigated away, can hold pool files after the lock has
|
|
55
|
+
// passed on: WebKit releases a terminated worker's locks and its files
|
|
56
|
+
// separately, in no set order. sqlite-wasm cannot set up a pool with a
|
|
57
|
+
// held file, and it then deletes the pool directory, which the held file
|
|
58
|
+
// usually but not always prevents. Only a worker that has ended can hold
|
|
59
|
+
// the files, and it can only release them, so the pool is set up once
|
|
60
|
+
// every file opens.
|
|
61
|
+
const canOpenPoolFiles = async (): Promise<boolean> => {
|
|
62
|
+
const root = await navigator.storage.getDirectory();
|
|
63
|
+
// sqlite-wasm keeps the pool in `.${name}/.opaque` in both OPFS modes.
|
|
64
|
+
const poolDirectory = await tryAsync(() =>
|
|
65
|
+
root.getDirectoryHandle(`.${name}`),
|
|
66
|
+
);
|
|
67
|
+
if (!poolDirectory.ok) return true;
|
|
68
|
+
const opaqueDirectory = await tryAsync(() =>
|
|
69
|
+
poolDirectory.value.getDirectoryHandle(".opaque"),
|
|
70
|
+
);
|
|
71
|
+
if (!opaqueDirectory.ok) return true;
|
|
72
|
+
for await (const handle of opaqueDirectory.value.values()) {
|
|
73
|
+
if (handle.kind !== "file") continue;
|
|
74
|
+
const accessHandle = await tryAsync(() =>
|
|
75
|
+
handle.createSyncAccessHandle(),
|
|
76
|
+
);
|
|
77
|
+
if (!accessHandle.ok) {
|
|
78
|
+
// WebKit rejects a held file with InvalidStateError, other
|
|
79
|
+
// engines with NoModificationAllowedError, as the spec says.
|
|
80
|
+
// WebKit also uses InvalidStateError for a closed or invalid
|
|
81
|
+
// handle and a stopped context. A retry opens fresh handles from
|
|
82
|
+
// a new listing, and a stopped context ends the loop with its
|
|
83
|
+
// worker, so retrying those is harmless.
|
|
84
|
+
if (
|
|
85
|
+
accessHandle.error instanceof DOMException &&
|
|
86
|
+
(accessHandle.error.name === "InvalidStateError" ||
|
|
87
|
+
accessHandle.error.name === "NoModificationAllowedError")
|
|
88
|
+
)
|
|
89
|
+
return false;
|
|
90
|
+
throw accessHandle.error;
|
|
91
|
+
}
|
|
92
|
+
accessHandle.value.close();
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const waitStart = run.deps.time.performance.now();
|
|
98
|
+
let retryDelay = 50;
|
|
99
|
+
let isWaitReported = false;
|
|
100
|
+
while (!(await canOpenPoolFiles())) {
|
|
101
|
+
const waited = performanceDurationBetween(
|
|
102
|
+
waitStart,
|
|
103
|
+
run.deps.time.performance.now(),
|
|
104
|
+
);
|
|
105
|
+
if (!isWaitReported && waited >= 5000) {
|
|
106
|
+
isWaitReported = true;
|
|
107
|
+
run.deps.console.warn(
|
|
108
|
+
`Waiting for an ended DbWorker to release the files of database ${name}.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
await run.ok(sleep(PositiveMillis.orThrow(retryDelay)));
|
|
112
|
+
retryDelay = Math.min(retryDelay * 2, 1000);
|
|
113
|
+
}
|
|
114
|
+
|
|
48
115
|
const pool = await sqlite3.installOpfsSAHPoolVfs(options);
|
|
49
116
|
if (pool.isPaused()) await pool.unpauseVfs();
|
|
50
117
|
disposer.defer(() => {
|
|
@@ -111,18 +178,24 @@ export const createWasmSqliteDriver: CreateSqliteDriver =
|
|
|
111
178
|
const prepared = cache.get(query);
|
|
112
179
|
|
|
113
180
|
if (prepared) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
181
|
+
try {
|
|
182
|
+
if (query.parameters.length > 0) prepared.bind(query.parameters);
|
|
183
|
+
|
|
184
|
+
const rows = [];
|
|
185
|
+
while (prepared.step()) {
|
|
186
|
+
rows.push(prepared.get({}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
rows: rows as ReadonlyArray<SqliteRow>,
|
|
191
|
+
changes: db.changes(),
|
|
192
|
+
};
|
|
193
|
+
} finally {
|
|
194
|
+
// SQLite refuses to bind a statement whose step failed until it is
|
|
195
|
+
// reset. That reset returns the step's error again, which
|
|
196
|
+
// PreparedStatement.reset would throw over the original one.
|
|
197
|
+
sqlite3.capi.sqlite3_reset(prepared);
|
|
119
198
|
}
|
|
120
|
-
prepared.reset();
|
|
121
|
-
|
|
122
|
-
return {
|
|
123
|
-
rows: rows as ReadonlyArray<SqliteRow>,
|
|
124
|
-
changes: db.changes(),
|
|
125
|
-
};
|
|
126
199
|
}
|
|
127
200
|
|
|
128
201
|
const rows = db.exec(query.sql, {
|
package/src/Task.test.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
assertEqual,
|
|
3
|
-
assertNonNullable,
|
|
4
|
-
assertTrue,
|
|
5
|
-
testStubGlobal,
|
|
6
|
-
} from "@evolu/common";
|
|
1
|
+
import { assertEqual, assertSame, testStubGlobal } from "@evolu/common";
|
|
7
2
|
import { describe, it, mock } from "node:test";
|
|
8
3
|
import { createRun } from "./Task.ts";
|
|
9
4
|
|
|
10
5
|
describe("createRun", () => {
|
|
11
|
-
it("createRun reports
|
|
6
|
+
it("createRun reports a panic's defect with global reportError", async () => {
|
|
12
7
|
const reportError = mock.fn<(error: unknown) => void>();
|
|
13
8
|
using _reportError = testStubGlobal("reportError", reportError);
|
|
14
9
|
await using run = createRun();
|
|
@@ -17,13 +12,7 @@ describe("createRun", () => {
|
|
|
17
12
|
run.panic(defect);
|
|
18
13
|
|
|
19
14
|
assertEqual(reportError.mock.callCount(), 1);
|
|
20
|
-
|
|
21
|
-
assertNonNullable(reported);
|
|
22
|
-
assertTrue(typeof reported === "object");
|
|
23
|
-
assertEqual(Reflect.get(reported, "reason"), {
|
|
24
|
-
type: "PanicAbortReason",
|
|
25
|
-
defect,
|
|
26
|
-
});
|
|
15
|
+
assertSame(reportError.mock.calls[0]?.arguments[0], defect);
|
|
27
16
|
});
|
|
28
17
|
|
|
29
18
|
it("createRun preserves a custom reportDefect", async () => {
|
package/src/Task.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
createRun as createCommonRun,
|
|
9
|
+
defectToError,
|
|
9
10
|
type DisposableRun,
|
|
10
11
|
type Run,
|
|
11
12
|
type RunCustomDeps,
|
|
@@ -14,13 +15,16 @@ import {
|
|
|
14
15
|
/**
|
|
15
16
|
* Creates a root {@link Run} for the browser.
|
|
16
17
|
*
|
|
17
|
-
* Defects are reported with the browser's global `reportError
|
|
18
|
-
*
|
|
18
|
+
* Defects are reported with the browser's global `reportError` as an `Error`
|
|
19
|
+
* from {@link defectToError}, because browsers show any other value only as
|
|
20
|
+
* unhelpful text. A custom `reportDefect` dependency overrides the browser
|
|
21
|
+
* default.
|
|
19
22
|
*
|
|
20
23
|
* ### Example
|
|
21
24
|
*
|
|
22
25
|
* ```ts
|
|
23
26
|
* import {
|
|
27
|
+
* assertEqual,
|
|
24
28
|
* createConsole,
|
|
25
29
|
* createConsoleFormatter,
|
|
26
30
|
* ok,
|
|
@@ -36,7 +40,7 @@ import {
|
|
|
36
40
|
* await using run = createRun({ console });
|
|
37
41
|
* const appPromise = run.ok(() => ok("started"));
|
|
38
42
|
*
|
|
39
|
-
*
|
|
43
|
+
* assertEqual(await appPromise, "started");
|
|
40
44
|
* ```
|
|
41
45
|
*/
|
|
42
46
|
export function createRun(): DisposableRun;
|
|
@@ -49,7 +53,7 @@ export function createRun<D extends object>(
|
|
|
49
53
|
deps?: RunCustomDeps<D>,
|
|
50
54
|
): DisposableRun | DisposableRun<D> {
|
|
51
55
|
const reportDefect = (reported: unknown): void => {
|
|
52
|
-
reportError(reported);
|
|
56
|
+
reportError(defectToError(reported));
|
|
53
57
|
};
|
|
54
58
|
|
|
55
59
|
return deps === undefined
|
package/src/Worker.test.ts
CHANGED
|
@@ -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 &
|