@evolu/web 3.1.0 → 3.1.1
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/README.md +7 -1
- package/dist/src/Platform.d.ts.map +1 -1
- package/dist/src/Platform.js +2 -3
- package/dist/src/Sqlite.js +2 -2
- package/dist/src/Task.js +1 -1
- package/dist/src/Worker.d.ts +3 -3
- package/dist/src/Worker.d.ts.map +1 -1
- package/dist/src/local-first/Db.worker.js +1 -1
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +2 -2
- package/dist/src/local-first/Shared.worker.js +2 -2
- package/package.json +6 -5
- package/src/Platform.test.ts +45 -0
- package/src/Platform.ts +2 -4
- package/src/Sqlite.test.ts +356 -0
- package/src/Sqlite.ts +2 -2
- package/src/Task.test.ts +51 -0
- package/src/Task.ts +1 -1
- package/src/Worker.test.ts +473 -0
- package/src/Worker.ts +3 -3
- package/src/local-first/Db.worker.ts +1 -1
- package/src/local-first/Evolu.test.ts +50 -0
- package/src/local-first/Evolu.ts +2 -3
- package/src/local-first/Shared.worker.ts +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# Evolu for Web
|
|
2
2
|
|
|
3
|
-
This package provides
|
|
3
|
+
This package provides browser-specific Evolu implementations, including
|
|
4
|
+
OPFS-backed SQLite, Web Workers, Shared Workers, and browser platform
|
|
5
|
+
dependencies.
|
|
6
|
+
|
|
7
|
+
As runtimes increasingly implement the same Web Platform APIs, Evolu's portable
|
|
8
|
+
abstractions for `WebSocket`, `MessagePort`, and Web Locks live in
|
|
9
|
+
`@evolu/common`. Their concrete implementations are supplied by the runtime.
|
|
4
10
|
|
|
5
11
|
## Documentation
|
|
6
12
|
|
|
@@ -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,
|
|
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"}
|
package/dist/src/Platform.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { PositiveInt } from "@evolu/common";
|
|
2
2
|
/** Returns true when the browser runs on an Apple platform. */
|
|
3
3
|
export const isApplePlatform = () => {
|
|
4
|
-
const platform =
|
|
5
|
-
globalThis.navigator.platform;
|
|
4
|
+
const platform = navigator.userAgentData?.platform ?? navigator.platform;
|
|
6
5
|
return /^(?:Mac|iPhone|iPad|iPod)/iu.test(platform);
|
|
7
6
|
};
|
|
8
7
|
/** Returns the amount of logical processors available to the browser. */
|
|
9
|
-
export const availableParallelism = () => PositiveInt.orThrow(
|
|
8
|
+
export const availableParallelism = () => PositiveInt.orThrow(navigator.hardwareConcurrency);
|
|
10
9
|
export const reloadApp = (url) => {
|
|
11
10
|
if (typeof document === "undefined") {
|
|
12
11
|
return;
|
package/dist/src/Sqlite.js
CHANGED
|
@@ -60,7 +60,7 @@ globalThis.sqlite3ApiConfig = {
|
|
|
60
60
|
if (typeof arg === "string" &&
|
|
61
61
|
arg.startsWith("Ignoring inability to install OPFS sqlite3_vfs"))
|
|
62
62
|
return;
|
|
63
|
-
//
|
|
63
|
+
// oxlint-disable-next-line eslint/no-console
|
|
64
64
|
console.warn(arg);
|
|
65
65
|
},
|
|
66
66
|
};
|
|
@@ -96,7 +96,7 @@ export const createWasmSqliteDriver = (name, options) => async () => {
|
|
|
96
96
|
case "encrypted": {
|
|
97
97
|
// MultipleCiphers encryption requires its VFS wrapper for OPFS SAH-pool.
|
|
98
98
|
// @ts-expect-error Missing types (update @evolu/sqlite-wasm types)
|
|
99
|
-
//
|
|
99
|
+
// oxlint-disable-next-line typescript/no-unsafe-call
|
|
100
100
|
sqlite3.capi.sqlite3mc_vfs_create("opfs", 1);
|
|
101
101
|
const pool = await createOpfsSAHPoolVfs({
|
|
102
102
|
directory: `.${name}`,
|
package/dist/src/Task.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { createRun as createCommonRun, } from "@evolu/common";
|
|
7
7
|
export function createRun(deps) {
|
|
8
8
|
const reportDefect = (reported) => {
|
|
9
|
-
|
|
9
|
+
reportError(reported);
|
|
10
10
|
};
|
|
11
11
|
return deps === undefined
|
|
12
12
|
? createCommonRun({ reportDefect })
|
package/dist/src/Worker.d.ts
CHANGED
|
@@ -39,14 +39,14 @@ export declare const createBroadcastChannel: CreateBroadcastChannel;
|
|
|
39
39
|
* Creates an Evolu {@link WorkerSelf} from a Web `DedicatedWorkerGlobalScope`
|
|
40
40
|
* (`self` inside a dedicated worker).
|
|
41
41
|
*/
|
|
42
|
-
export declare const createWorkerSelf: <Input, Output = never>(nativeSelf:
|
|
42
|
+
export declare const createWorkerSelf: <Input, Output = never>(nativeSelf: DedicatedWorkerGlobalScope) => WorkerSelf<Input, Output>;
|
|
43
43
|
/**
|
|
44
44
|
* Creates an Evolu {@link SharedWorkerSelf} from a Web `SharedWorkerGlobalScope`
|
|
45
45
|
* (`self` inside a shared worker).
|
|
46
46
|
*
|
|
47
47
|
* Disposing closes the shared worker scope for all connected clients.
|
|
48
48
|
*/
|
|
49
|
-
export declare const createSharedWorkerSelf: <Input, Output = never>(nativeSelf:
|
|
49
|
+
export declare const createSharedWorkerSelf: <Input, Output = never>(nativeSelf: SharedWorkerGlobalScope) => SharedWorkerSelf<Input, Output>;
|
|
50
50
|
/**
|
|
51
51
|
* Creates a one-tab {@link SharedWorkerSelf} polyfill for browsers without
|
|
52
52
|
* native SharedWorker support.
|
|
@@ -54,7 +54,7 @@ export declare const createSharedWorkerSelf: <Input, Output = never>(nativeSelf:
|
|
|
54
54
|
* Delivers one synthetic `onConnect` port, queues startup messages until
|
|
55
55
|
* `onMessage` is set, and does not share state across tabs.
|
|
56
56
|
*/
|
|
57
|
-
export declare const createOneTabSharedWorkerSelfPolyfill: <Input, Output = never>(nativeSelf:
|
|
57
|
+
export declare const createOneTabSharedWorkerSelfPolyfill: <Input, Output = never>(nativeSelf: DedicatedWorkerGlobalScope) => SharedWorkerSelf<Input, Output>;
|
|
58
58
|
/** Creates deps shared by web worker entry points. */
|
|
59
59
|
export declare const createWorkerDeps: () => WorkerDeps & CreateBroadcastChannelDep & CreateMessageChannelDep;
|
|
60
60
|
//# sourceMappingURL=Worker.d.ts.map
|
package/dist/src/Worker.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|
|
@@ -9,7 +9,7 @@ import { createWorkerDeps, createWorkerSelf } from "../Worker.js";
|
|
|
9
9
|
const run = createRun({
|
|
10
10
|
...createWorkerDeps(),
|
|
11
11
|
createSqliteDriver: createWasmSqliteDriver,
|
|
12
|
-
lockManager:
|
|
12
|
+
lockManager: navigator.locks,
|
|
13
13
|
randomBytes: createRandomBytes(),
|
|
14
14
|
});
|
|
15
15
|
void run(startDbWorker(createWorkerSelf(self)));
|
|
@@ -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;
|
|
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"}
|
|
@@ -33,7 +33,7 @@ export const createEvoluDeps = (deps = {}) => {
|
|
|
33
33
|
deps.onSharedWorkerUnsupported();
|
|
34
34
|
}
|
|
35
35
|
else {
|
|
36
|
-
|
|
36
|
+
alert("This browser supports Evolu in one tab only. Close this tab and use the already open tab.");
|
|
37
37
|
}
|
|
38
38
|
break;
|
|
39
39
|
}
|
|
@@ -50,7 +50,7 @@ export const createEvoluDeps = (deps = {}) => {
|
|
|
50
50
|
createDbWorker,
|
|
51
51
|
createBroadcastChannel,
|
|
52
52
|
createMessageChannel,
|
|
53
|
-
lockManager:
|
|
53
|
+
lockManager: navigator.locks,
|
|
54
54
|
reloadApp,
|
|
55
55
|
sharedWorker,
|
|
56
56
|
});
|
|
@@ -60,7 +60,7 @@ import { createOneTabSharedWorkerSelfPolyfill, createSharedWorkerSelf, createWor
|
|
|
60
60
|
const run = createRun({
|
|
61
61
|
...createWorkerDeps(),
|
|
62
62
|
createWebSocket,
|
|
63
|
-
lockManager:
|
|
63
|
+
lockManager: navigator.locks,
|
|
64
64
|
});
|
|
65
65
|
void run(async (run) => {
|
|
66
66
|
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
@@ -82,7 +82,7 @@ void run(async (run) => {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
const workerSelf = __addDisposableResource(env_1, createOneTabSharedWorkerSelfPolyfill(self), false);
|
|
85
|
-
await
|
|
85
|
+
await navigator.locks.request("evolu-one-tab-sharedworker-polyfill", { ifAvailable: true, mode: "exclusive" }, async (lock) => {
|
|
86
86
|
const env_3 = { stack: [], error: void 0, hasError: false };
|
|
87
87
|
try {
|
|
88
88
|
if (!lock) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evolu/web",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"description": "Evolu for web",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"evolu",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"dist/src/**",
|
|
30
|
+
"!dist/**/*.test.*",
|
|
30
31
|
"src/**",
|
|
31
32
|
"README.md"
|
|
32
33
|
],
|
|
@@ -35,13 +36,13 @@
|
|
|
35
36
|
"idb-keyval": "^6.2.2"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
39
|
+
"@evolu/common": "8.8.0",
|
|
40
|
+
"@evolu/typescript-config": "0.1.1",
|
|
38
41
|
"@types/node": "^24.10.9",
|
|
39
42
|
"@types/sharedworker": "^0.0.229",
|
|
40
43
|
"@types/web-locks-api": "^0.0.5",
|
|
41
44
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
42
|
-
"user-agent-data-types": "^0.4.2"
|
|
43
|
-
"@evolu/common": "8.5.0",
|
|
44
|
-
"@evolu/typescript-config": "0.0.2"
|
|
45
|
+
"user-agent-data-types": "^0.4.2"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
47
48
|
"@evolu/common": "^8.0.0"
|
|
@@ -50,7 +51,7 @@
|
|
|
50
51
|
"access": "public"
|
|
51
52
|
},
|
|
52
53
|
"engines": {
|
|
53
|
-
"node": ">=24.
|
|
54
|
+
"node": ">=24.20.0"
|
|
54
55
|
},
|
|
55
56
|
"sideEffects": false,
|
|
56
57
|
"scripts": {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertEqual,
|
|
3
|
+
assertFalse,
|
|
4
|
+
assertTrue,
|
|
5
|
+
assertType,
|
|
6
|
+
PositiveInt,
|
|
7
|
+
testStubGlobal,
|
|
8
|
+
} from "@evolu/common";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { availableParallelism, isApplePlatform } from "./index.ts";
|
|
11
|
+
|
|
12
|
+
for (const platform of ["macOS", "MacIntel", "iPhone", "iPad", "iPod"]) {
|
|
13
|
+
test(`isApplePlatform recognizes ${platform}`, () => {
|
|
14
|
+
using _navigator = testStubGlobal("navigator", {
|
|
15
|
+
platform: "Win32",
|
|
16
|
+
userAgentData: { platform },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assertTrue(isApplePlatform());
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("isApplePlatform recognizes a non-Apple platform", () => {
|
|
24
|
+
using _navigator = testStubGlobal("navigator", {
|
|
25
|
+
platform: "MacIntel",
|
|
26
|
+
userAgentData: { platform: "Windows" },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
assertFalse(isApplePlatform());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("isApplePlatform falls back to navigator.platform", () => {
|
|
33
|
+
using _navigator = testStubGlobal("navigator", { platform: "MacIntel" });
|
|
34
|
+
|
|
35
|
+
assertTrue(isApplePlatform());
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("availableParallelism returns the validated browser value", () => {
|
|
39
|
+
using _navigator = testStubGlobal("navigator", { hardwareConcurrency: 128 });
|
|
40
|
+
|
|
41
|
+
const parallelism = availableParallelism();
|
|
42
|
+
|
|
43
|
+
assertType<typeof parallelism, PositiveInt>();
|
|
44
|
+
assertEqual(parallelism, 128);
|
|
45
|
+
});
|
package/src/Platform.ts
CHANGED
|
@@ -2,16 +2,14 @@ import { PositiveInt, type ReloadApp } from "@evolu/common";
|
|
|
2
2
|
|
|
3
3
|
/** Returns true when the browser runs on an Apple platform. */
|
|
4
4
|
export const isApplePlatform = (): boolean => {
|
|
5
|
-
const platform =
|
|
6
|
-
globalThis.navigator.userAgentData?.platform ??
|
|
7
|
-
globalThis.navigator.platform;
|
|
5
|
+
const platform = navigator.userAgentData?.platform ?? navigator.platform;
|
|
8
6
|
|
|
9
7
|
return /^(?:Mac|iPhone|iPad|iPod)/iu.test(platform);
|
|
10
8
|
};
|
|
11
9
|
|
|
12
10
|
/** Returns the amount of logical processors available to the browser. */
|
|
13
11
|
export const availableParallelism = (): PositiveInt =>
|
|
14
|
-
PositiveInt.orThrow(
|
|
12
|
+
PositiveInt.orThrow(navigator.hardwareConcurrency);
|
|
15
13
|
|
|
16
14
|
export const reloadApp: ReloadApp = (url) => {
|
|
17
15
|
if (typeof document === "undefined") {
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertEqual,
|
|
3
|
+
assertEqualBytes,
|
|
4
|
+
assertTrue,
|
|
5
|
+
EncryptionKey,
|
|
6
|
+
Name,
|
|
7
|
+
sql,
|
|
8
|
+
testCreateRun,
|
|
9
|
+
} from "@evolu/common";
|
|
10
|
+
import { describe, it, mock } from "node:test";
|
|
11
|
+
|
|
12
|
+
const sqliteMock = (() => {
|
|
13
|
+
class PreparedStatement {
|
|
14
|
+
finalized = false;
|
|
15
|
+
resetCount = 0;
|
|
16
|
+
stepCount = 0;
|
|
17
|
+
readonly bound: Array<ReadonlyArray<unknown>> = [];
|
|
18
|
+
|
|
19
|
+
bind(parameters: ReadonlyArray<unknown>): void {
|
|
20
|
+
this.bound.push(parameters);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
step(): boolean {
|
|
24
|
+
this.stepCount += 1;
|
|
25
|
+
return this.stepCount === 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get(): Record<string, unknown> {
|
|
29
|
+
return { data: "prepared" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
reset(): void {
|
|
33
|
+
this.resetCount += 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
finalize(): void {
|
|
37
|
+
this.finalized = true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class Database {
|
|
42
|
+
readonly execSql: Array<string> = [];
|
|
43
|
+
readonly filename: string;
|
|
44
|
+
|
|
45
|
+
constructor(filename: string) {
|
|
46
|
+
this.filename = filename;
|
|
47
|
+
state.createdDatabases.push(this);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
prepare(): PreparedStatement {
|
|
51
|
+
const statement = new PreparedStatement();
|
|
52
|
+
state.preparedStatements.push(statement);
|
|
53
|
+
return statement;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
exec(sql: string): ReadonlyArray<Record<string, unknown>> {
|
|
57
|
+
this.execSql.push(sql);
|
|
58
|
+
return [{ data: "row" }];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
changes(): number {
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
close(): void {
|
|
66
|
+
state.closedDatabases.push(this.filename);
|
|
67
|
+
state.events.push(`close:${this.filename}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const state = {
|
|
72
|
+
closedDatabases: [] as Array<string>,
|
|
73
|
+
createdDatabases: [] as Array<Database>,
|
|
74
|
+
deletedFilenames: [] as Array<string>,
|
|
75
|
+
events: [] as Array<string>,
|
|
76
|
+
pausedVfsNames: [] as Array<string>,
|
|
77
|
+
preparedStatements: [] as Array<PreparedStatement>,
|
|
78
|
+
unpausedVfsNames: [] as Array<string>,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let poolPaused = false;
|
|
82
|
+
const pool = {
|
|
83
|
+
isPaused: mock.fn(() => poolPaused),
|
|
84
|
+
OpfsSAHPoolDb: Database,
|
|
85
|
+
pauseVfs: mock.fn(() => {
|
|
86
|
+
poolPaused = true;
|
|
87
|
+
state.pausedVfsNames.push(pool.vfsName);
|
|
88
|
+
state.events.push(`pause:${pool.vfsName}`);
|
|
89
|
+
return pool;
|
|
90
|
+
}),
|
|
91
|
+
unpauseVfs: mock.fn(() => {
|
|
92
|
+
poolPaused = false;
|
|
93
|
+
state.unpausedVfsNames.push(pool.vfsName);
|
|
94
|
+
state.events.push(`unpause:${pool.vfsName}`);
|
|
95
|
+
return Promise.resolve(pool);
|
|
96
|
+
}),
|
|
97
|
+
unlink: mock.fn((filename: string) => {
|
|
98
|
+
state.deletedFilenames.push(filename);
|
|
99
|
+
state.events.push(`unlink:${filename}`);
|
|
100
|
+
return true;
|
|
101
|
+
}),
|
|
102
|
+
vfsName: "mock-sahpool",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const sqlite3 = {
|
|
106
|
+
capi: {
|
|
107
|
+
sqlite3_js_db_export: mock.fn(() => new Uint8Array([1, 2, 3])),
|
|
108
|
+
sqlite3mc_vfs_create: mock.fn(),
|
|
109
|
+
},
|
|
110
|
+
installOpfsSAHPoolVfs: mock.fn(() => Promise.resolve(pool)),
|
|
111
|
+
oo1: { DB: Database },
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
consoleWarn: mock.fn<typeof console.warn>(),
|
|
116
|
+
pool,
|
|
117
|
+
reset: () => {
|
|
118
|
+
state.closedDatabases.length = 0;
|
|
119
|
+
state.createdDatabases.length = 0;
|
|
120
|
+
state.deletedFilenames.length = 0;
|
|
121
|
+
state.events.length = 0;
|
|
122
|
+
state.pausedVfsNames.length = 0;
|
|
123
|
+
state.preparedStatements.length = 0;
|
|
124
|
+
state.unpausedVfsNames.length = 0;
|
|
125
|
+
poolPaused = false;
|
|
126
|
+
pool.isPaused.mock.resetCalls();
|
|
127
|
+
pool.pauseVfs.mock.resetCalls();
|
|
128
|
+
pool.unpauseVfs.mock.resetCalls();
|
|
129
|
+
pool.unlink.mock.resetCalls();
|
|
130
|
+
sqlite3.capi.sqlite3_js_db_export.mock.resetCalls();
|
|
131
|
+
sqlite3.capi.sqlite3mc_vfs_create.mock.resetCalls();
|
|
132
|
+
sqlite3.installOpfsSAHPoolVfs.mock.resetCalls();
|
|
133
|
+
},
|
|
134
|
+
sqlite3,
|
|
135
|
+
state,
|
|
136
|
+
};
|
|
137
|
+
})();
|
|
138
|
+
|
|
139
|
+
mock.module("@evolu/sqlite-wasm", {
|
|
140
|
+
// @ts-expect-error -- Node.js 24.20 replaces the deprecated defaultExport option with exports, which @types/node 24.13 does not declare yet.
|
|
141
|
+
exports: {
|
|
142
|
+
default: mock.fn(() => {
|
|
143
|
+
const config = Reflect.get(globalThis, "sqlite3ApiConfig") as
|
|
144
|
+
| {
|
|
145
|
+
readonly warn?: (arg: unknown) => void;
|
|
146
|
+
}
|
|
147
|
+
| undefined;
|
|
148
|
+
config?.warn?.("Ignoring inability to install OPFS sqlite3_vfs");
|
|
149
|
+
config?.warn?.("kept warning");
|
|
150
|
+
return Promise.resolve(sqliteMock.sqlite3);
|
|
151
|
+
}),
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
mock.method(console, "warn", sqliteMock.consoleWarn);
|
|
156
|
+
const { createWasmSqliteDriver } = await import("./Sqlite.ts");
|
|
157
|
+
|
|
158
|
+
describe("createWasmSqliteDriver coverage helpers", () => {
|
|
159
|
+
it("filters sqlite init warnings", () => {
|
|
160
|
+
assertEqual(
|
|
161
|
+
sqliteMock.consoleWarn.mock.calls.map(({ arguments: args }) => args),
|
|
162
|
+
[["kept warning"]],
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("opens plain OPFS SAH-pool database", async () => {
|
|
167
|
+
sqliteMock.reset();
|
|
168
|
+
|
|
169
|
+
await using run = testCreateRun();
|
|
170
|
+
using _driver = await run.ok(
|
|
171
|
+
createWasmSqliteDriver(Name.orThrow("MockPlain")),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
assertEqual(
|
|
175
|
+
sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.calls.map(
|
|
176
|
+
({ arguments: args }) => args,
|
|
177
|
+
),
|
|
178
|
+
[[{ name: "MockPlain" }]],
|
|
179
|
+
);
|
|
180
|
+
assertEqual(
|
|
181
|
+
sqliteMock.state.createdDatabases[0]?.filename,
|
|
182
|
+
"file:evolu1.db",
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("opens memory database", async () => {
|
|
187
|
+
sqliteMock.reset();
|
|
188
|
+
|
|
189
|
+
await using run = testCreateRun();
|
|
190
|
+
using _driver = await run.ok(
|
|
191
|
+
createWasmSqliteDriver(Name.orThrow("MockMemory"), { mode: "memory" }),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
|
|
195
|
+
assertEqual(sqliteMock.state.createdDatabases[0]?.filename, ":memory:");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("executes non-prepared query and exports database", async () => {
|
|
199
|
+
sqliteMock.reset();
|
|
200
|
+
|
|
201
|
+
await using run = testCreateRun();
|
|
202
|
+
using driver = await run.ok(
|
|
203
|
+
createWasmSqliteDriver(Name.orThrow("MockPlain")),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const result = driver.exec(sql`select ${"row"};`);
|
|
207
|
+
const exported = driver.export();
|
|
208
|
+
|
|
209
|
+
assertEqual(result, { rows: [{ data: "row" }], changes: 1 });
|
|
210
|
+
assertEqualBytes(exported, [1, 2, 3]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("executes prepared query and finalizes statement on dispose", async () => {
|
|
214
|
+
sqliteMock.reset();
|
|
215
|
+
|
|
216
|
+
await using run = testCreateRun();
|
|
217
|
+
{
|
|
218
|
+
using driver = await run.ok(
|
|
219
|
+
createWasmSqliteDriver(Name.orThrow("MockPlain")),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const result = driver.exec({
|
|
223
|
+
...sql`select ${"prepared"};`,
|
|
224
|
+
options: { prepare: true },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
assertEqual(result, { rows: [{ data: "prepared" }], changes: 1 });
|
|
228
|
+
|
|
229
|
+
driver.exec({ ...sql`select 1;`, options: { prepare: true } });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
assertEqual(sqliteMock.state.preparedStatements[0]?.bound, [["prepared"]]);
|
|
233
|
+
assertEqual(sqliteMock.state.preparedStatements[0]?.resetCount, 1);
|
|
234
|
+
assertEqual(sqliteMock.state.preparedStatements[0]?.finalized, true);
|
|
235
|
+
assertEqual(sqliteMock.state.preparedStatements[1]?.bound, []);
|
|
236
|
+
assertEqual(sqliteMock.state.preparedStatements[1]?.finalized, true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("closes OPFS database on dispose", async () => {
|
|
240
|
+
sqliteMock.reset();
|
|
241
|
+
|
|
242
|
+
await using run = testCreateRun();
|
|
243
|
+
{
|
|
244
|
+
using _driver = await run.ok(
|
|
245
|
+
createWasmSqliteDriver(Name.orThrow("MockPlain")),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
assertEqual(sqliteMock.state.closedDatabases, ["file:evolu1.db"]);
|
|
250
|
+
assertEqual(sqliteMock.state.pausedVfsNames, ["mock-sahpool"]);
|
|
251
|
+
assertEqual(sqliteMock.pool.unlink.mock.callCount(), 0);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("unpauses OPFS SAH-pool database on reopen", async () => {
|
|
255
|
+
sqliteMock.reset();
|
|
256
|
+
|
|
257
|
+
await using run = testCreateRun();
|
|
258
|
+
{
|
|
259
|
+
using _driver = await run.ok(
|
|
260
|
+
createWasmSqliteDriver(Name.orThrow("MockPlain")),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
using _driver = await run.ok(
|
|
265
|
+
createWasmSqliteDriver(Name.orThrow("MockPlain")),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
assertEqual(sqliteMock.state.unpausedVfsNames, ["mock-sahpool"]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("deletes plain OPFS database file", async () => {
|
|
272
|
+
sqliteMock.reset();
|
|
273
|
+
|
|
274
|
+
await using run = testCreateRun();
|
|
275
|
+
using driver = await run.ok(
|
|
276
|
+
createWasmSqliteDriver(Name.orThrow("MockPlain")),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
driver.deleteDatabase();
|
|
280
|
+
|
|
281
|
+
assertEqual(sqliteMock.state.closedDatabases, ["file:evolu1.db"]);
|
|
282
|
+
assertEqual(sqliteMock.state.deletedFilenames, ["/evolu1.db"]);
|
|
283
|
+
assertEqual(sqliteMock.state.events, [
|
|
284
|
+
"close:file:evolu1.db",
|
|
285
|
+
"unlink:/evolu1.db",
|
|
286
|
+
"pause:mock-sahpool",
|
|
287
|
+
]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("deletes memory database", async () => {
|
|
291
|
+
sqliteMock.reset();
|
|
292
|
+
|
|
293
|
+
await using run = testCreateRun();
|
|
294
|
+
using driver = await run.ok(
|
|
295
|
+
createWasmSqliteDriver(Name.orThrow("MockMemory"), { mode: "memory" }),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
driver.deleteDatabase();
|
|
299
|
+
|
|
300
|
+
assertEqual(sqliteMock.state.events, ["close::memory:"]);
|
|
301
|
+
assertEqual(sqliteMock.pool.unlink.mock.callCount(), 0);
|
|
302
|
+
assertEqual(sqliteMock.pool.pauseVfs.mock.callCount(), 0);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("deletes encrypted OPFS database file", async () => {
|
|
306
|
+
sqliteMock.reset();
|
|
307
|
+
|
|
308
|
+
await using run = testCreateRun();
|
|
309
|
+
using driver = await run.ok(
|
|
310
|
+
createWasmSqliteDriver(Name.orThrow("MockEncrypted"), {
|
|
311
|
+
mode: "encrypted",
|
|
312
|
+
encryptionKey: EncryptionKey.orThrow(new Uint8Array(32).fill(42)),
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
driver.deleteDatabase();
|
|
317
|
+
|
|
318
|
+
assertEqual(sqliteMock.state.closedDatabases, [
|
|
319
|
+
"file:evolu1.db?vfs=multipleciphers-opfs-sahpool",
|
|
320
|
+
]);
|
|
321
|
+
assertEqual(sqliteMock.state.deletedFilenames, ["/evolu1.db"]);
|
|
322
|
+
assertEqual(sqliteMock.state.events, [
|
|
323
|
+
"close:file:evolu1.db?vfs=multipleciphers-opfs-sahpool",
|
|
324
|
+
"unlink:/evolu1.db",
|
|
325
|
+
"pause:mock-sahpool",
|
|
326
|
+
]);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("configures encrypted OPFS database", async () => {
|
|
330
|
+
sqliteMock.reset();
|
|
331
|
+
|
|
332
|
+
await using run = testCreateRun();
|
|
333
|
+
using _driver = await run.ok(
|
|
334
|
+
createWasmSqliteDriver(Name.orThrow("MockEncrypted"), {
|
|
335
|
+
mode: "encrypted",
|
|
336
|
+
encryptionKey: EncryptionKey.orThrow(new Uint8Array(32).fill(42)),
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
assertEqual(
|
|
341
|
+
sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.calls.map(
|
|
342
|
+
({ arguments: args }) => args,
|
|
343
|
+
),
|
|
344
|
+
[[{ directory: ".MockEncrypted" }]],
|
|
345
|
+
);
|
|
346
|
+
assertEqual(
|
|
347
|
+
sqliteMock.state.createdDatabases[0]?.filename,
|
|
348
|
+
"file:evolu1.db?vfs=multipleciphers-opfs-sahpool",
|
|
349
|
+
);
|
|
350
|
+
assertTrue(
|
|
351
|
+
sqliteMock.state.createdDatabases[0]?.execSql[0]?.includes(
|
|
352
|
+
"PRAGMA cipher = 'sqlcipher';",
|
|
353
|
+
),
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
});
|
package/src/Sqlite.ts
CHANGED
|
@@ -21,7 +21,7 @@ globalThis.sqlite3ApiConfig = {
|
|
|
21
21
|
arg.startsWith("Ignoring inability to install OPFS sqlite3_vfs")
|
|
22
22
|
)
|
|
23
23
|
return;
|
|
24
|
-
//
|
|
24
|
+
// oxlint-disable-next-line eslint/no-console
|
|
25
25
|
console.warn(arg);
|
|
26
26
|
},
|
|
27
27
|
};
|
|
@@ -65,7 +65,7 @@ export const createWasmSqliteDriver: CreateSqliteDriver =
|
|
|
65
65
|
case "encrypted": {
|
|
66
66
|
// MultipleCiphers encryption requires its VFS wrapper for OPFS SAH-pool.
|
|
67
67
|
// @ts-expect-error Missing types (update @evolu/sqlite-wasm types)
|
|
68
|
-
//
|
|
68
|
+
// oxlint-disable-next-line typescript/no-unsafe-call
|
|
69
69
|
sqlite3.capi.sqlite3mc_vfs_create("opfs", 1);
|
|
70
70
|
const pool = await createOpfsSAHPoolVfs({
|
|
71
71
|
directory: `.${name}`,
|
package/src/Task.test.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertEqual,
|
|
3
|
+
assertNonNullable,
|
|
4
|
+
assertTrue,
|
|
5
|
+
testStubGlobal,
|
|
6
|
+
} from "@evolu/common";
|
|
7
|
+
import { describe, it, mock } from "node:test";
|
|
8
|
+
import { createRun } from "./Task.ts";
|
|
9
|
+
|
|
10
|
+
describe("createRun", () => {
|
|
11
|
+
it("createRun reports defects with global reportError", async () => {
|
|
12
|
+
const reportError = mock.fn<(error: unknown) => void>();
|
|
13
|
+
using _reportError = testStubGlobal("reportError", reportError);
|
|
14
|
+
await using run = createRun();
|
|
15
|
+
const defect = new Error("boom");
|
|
16
|
+
|
|
17
|
+
run.panic(defect);
|
|
18
|
+
|
|
19
|
+
assertEqual(reportError.mock.callCount(), 1);
|
|
20
|
+
const reported = reportError.mock.calls[0]?.arguments[0];
|
|
21
|
+
assertNonNullable(reported);
|
|
22
|
+
assertTrue(typeof reported === "object");
|
|
23
|
+
assertEqual(Reflect.get(reported, "reason"), {
|
|
24
|
+
type: "PanicAbortReason",
|
|
25
|
+
defect,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("createRun preserves a custom reportDefect", async () => {
|
|
30
|
+
const reportError = mock.fn();
|
|
31
|
+
const reportDefect = mock.fn();
|
|
32
|
+
using _reportError = testStubGlobal("reportError", reportError);
|
|
33
|
+
await using run = createRun({ reportDefect });
|
|
34
|
+
const defect = new Error("boom");
|
|
35
|
+
|
|
36
|
+
run.panic(defect);
|
|
37
|
+
|
|
38
|
+
assertEqual(reportDefect.mock.callCount(), 1);
|
|
39
|
+
assertEqual(reportError.mock.callCount(), 0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("merges custom deps", async () => {
|
|
43
|
+
interface CustomDep {
|
|
44
|
+
readonly customValue: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await using run = createRun<CustomDep>({ customValue: 42 });
|
|
48
|
+
|
|
49
|
+
assertEqual(run.deps.customValue, 42);
|
|
50
|
+
});
|
|
51
|
+
});
|
package/src/Task.ts
CHANGED
|
@@ -49,7 +49,7 @@ export function createRun<D extends object>(
|
|
|
49
49
|
deps?: RunCustomDeps<D>,
|
|
50
50
|
): DisposableRun | DisposableRun<D> {
|
|
51
51
|
const reportDefect = (reported: unknown): void => {
|
|
52
|
-
|
|
52
|
+
reportError(reported);
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
return deps === undefined
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertEqual,
|
|
3
|
+
assertNonNullable,
|
|
4
|
+
assertSame,
|
|
5
|
+
assertThrowsInstanceOf,
|
|
6
|
+
testStubGlobal,
|
|
7
|
+
type MessagePort,
|
|
8
|
+
type NativeMessagePort,
|
|
9
|
+
} from "@evolu/common";
|
|
10
|
+
import { describe, it, mock, test } from "node:test";
|
|
11
|
+
import {
|
|
12
|
+
createBroadcastChannel,
|
|
13
|
+
createOneTabSharedWorkerSelfPolyfill,
|
|
14
|
+
createMessageChannel,
|
|
15
|
+
createMessagePort,
|
|
16
|
+
createSharedWorker,
|
|
17
|
+
createSharedWorkerSelf,
|
|
18
|
+
createWorker,
|
|
19
|
+
createWorkerDeps,
|
|
20
|
+
createWorkerSelf,
|
|
21
|
+
installOneTabSharedWorkerPolyfill,
|
|
22
|
+
} from "./Worker.ts";
|
|
23
|
+
|
|
24
|
+
test("createWorker wraps a native worker and disposes via terminate", () => {
|
|
25
|
+
const nativeWorker = {
|
|
26
|
+
onmessage: null as ((event: MessageEvent<string>) => void) | null,
|
|
27
|
+
postMessage:
|
|
28
|
+
mock.fn<
|
|
29
|
+
(message: unknown, transfer?: ReadonlyArray<Transferable>) => void
|
|
30
|
+
>(),
|
|
31
|
+
terminate: mock.fn(),
|
|
32
|
+
};
|
|
33
|
+
const worker = createWorker<string, string>(
|
|
34
|
+
nativeWorker as unknown as Worker,
|
|
35
|
+
);
|
|
36
|
+
const received: Array<string> = [];
|
|
37
|
+
|
|
38
|
+
worker.onMessage = (message) => {
|
|
39
|
+
received.push(message);
|
|
40
|
+
};
|
|
41
|
+
nativeWorker.onmessage?.({ data: "response" } as MessageEvent<string>);
|
|
42
|
+
worker.postMessage("request");
|
|
43
|
+
worker[Symbol.dispose]();
|
|
44
|
+
|
|
45
|
+
assertEqual(received, ["response"]);
|
|
46
|
+
assertSame(worker.native, nativeWorker);
|
|
47
|
+
assertEqual(
|
|
48
|
+
nativeWorker.postMessage.mock.calls.map(({ arguments: args }) => args),
|
|
49
|
+
[["request"]],
|
|
50
|
+
);
|
|
51
|
+
assertEqual(nativeWorker.terminate.mock.callCount(), 1);
|
|
52
|
+
assertEqual(nativeWorker.onmessage, null);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("createMessageChannel queues messages until onMessage is assigned", async () => {
|
|
56
|
+
using channel = createMessageChannel<string>();
|
|
57
|
+
const received: Array<string> = [];
|
|
58
|
+
const delivered = Promise.withResolvers<void>();
|
|
59
|
+
|
|
60
|
+
channel.port1.postMessage("queued");
|
|
61
|
+
channel.port2.onMessage = (message) => {
|
|
62
|
+
received.push(message);
|
|
63
|
+
delivered.resolve();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
await delivered.promise;
|
|
67
|
+
assertEqual(received, ["queued"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("createMessageChannel supports bidirectional communication and disposal", async () => {
|
|
71
|
+
using channel = createMessageChannel<string, number>();
|
|
72
|
+
const strings: Array<string> = [];
|
|
73
|
+
const numbers: Array<number> = [];
|
|
74
|
+
const stringDelivered = Promise.withResolvers<void>();
|
|
75
|
+
const numberDelivered = Promise.withResolvers<void>();
|
|
76
|
+
|
|
77
|
+
channel.port2.onMessage = (message) => {
|
|
78
|
+
strings.push(message);
|
|
79
|
+
stringDelivered.resolve();
|
|
80
|
+
};
|
|
81
|
+
channel.port1.onMessage = (message) => {
|
|
82
|
+
numbers.push(message);
|
|
83
|
+
numberDelivered.resolve();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
channel.port1.postMessage("hello");
|
|
87
|
+
channel.port2.postMessage(42);
|
|
88
|
+
|
|
89
|
+
await Promise.all([stringDelivered.promise, numberDelivered.promise]);
|
|
90
|
+
assertEqual(strings, ["hello"]);
|
|
91
|
+
assertEqual(numbers, [42]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("createMessagePort wraps a native port received from MessageChannel", async () => {
|
|
95
|
+
using disposer = new DisposableStack();
|
|
96
|
+
const nativeChannel = new MessageChannel();
|
|
97
|
+
disposer.defer(() => {
|
|
98
|
+
nativeChannel.port2.close();
|
|
99
|
+
});
|
|
100
|
+
const wrappedPort = disposer.use(
|
|
101
|
+
createMessagePort<number, string>(
|
|
102
|
+
nativeChannel.port1 as unknown as NativeMessagePort<number, string>,
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
const received: Array<string> = [];
|
|
106
|
+
const delivered = Promise.withResolvers<void>();
|
|
107
|
+
|
|
108
|
+
wrappedPort.onMessage = (message) => {
|
|
109
|
+
received.push(message);
|
|
110
|
+
delivered.resolve();
|
|
111
|
+
};
|
|
112
|
+
nativeChannel.port2.postMessage("hello");
|
|
113
|
+
|
|
114
|
+
await delivered.promise;
|
|
115
|
+
assertEqual(received, ["hello"]);
|
|
116
|
+
|
|
117
|
+
const nativeReceived = new Promise<number>((resolve) => {
|
|
118
|
+
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Assigning onmessage also starts this MessagePort automatically.
|
|
119
|
+
nativeChannel.port2.onmessage = (event) => {
|
|
120
|
+
resolve(event.data as number);
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
wrappedPort.postMessage(42);
|
|
125
|
+
|
|
126
|
+
assertEqual(await nativeReceived, 42);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("createMessagePort assigns and clears the native onmessage handler", () => {
|
|
130
|
+
const nativePort = createClosableNativePort<string>();
|
|
131
|
+
const wrappedPort = createMessagePort<string, string>(
|
|
132
|
+
nativePort as unknown as NativeMessagePort<string, string>,
|
|
133
|
+
);
|
|
134
|
+
const transferable = new ArrayBuffer(1);
|
|
135
|
+
const received: Array<string> = [];
|
|
136
|
+
|
|
137
|
+
wrappedPort.onMessage = (message) => {
|
|
138
|
+
received.push(message);
|
|
139
|
+
};
|
|
140
|
+
nativePort.onmessage?.({ data: "response" } as MessageEvent<string>);
|
|
141
|
+
wrappedPort.postMessage("without transfer");
|
|
142
|
+
wrappedPort.postMessage("with transfer", [transferable]);
|
|
143
|
+
wrappedPort.onMessage = null;
|
|
144
|
+
|
|
145
|
+
assertEqual(received, ["response"]);
|
|
146
|
+
assertEqual(nativePort.onmessage, null);
|
|
147
|
+
assertEqual(wrappedPort.onMessage, null);
|
|
148
|
+
assertEqual(
|
|
149
|
+
nativePort.postMessage.mock.calls.map(({ arguments: args }) => args),
|
|
150
|
+
[["without transfer"], ["with transfer", [transferable]]],
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("createBroadcastChannel wraps native BroadcastChannel", async () => {
|
|
155
|
+
const channelName = `test-channel-${crypto.randomUUID()}`;
|
|
156
|
+
const channel1 = createBroadcastChannel<string>(channelName);
|
|
157
|
+
const received1: Array<string> = [];
|
|
158
|
+
const received2: Array<string> = [];
|
|
159
|
+
const delivered = Promise.withResolvers<void>();
|
|
160
|
+
|
|
161
|
+
{
|
|
162
|
+
using _channel1 = channel1;
|
|
163
|
+
using channel2 = createBroadcastChannel<string>(channelName);
|
|
164
|
+
|
|
165
|
+
channel1.onMessage = (message) => {
|
|
166
|
+
received1.push(message);
|
|
167
|
+
};
|
|
168
|
+
channel2.onMessage = (message) => {
|
|
169
|
+
received2.push(message);
|
|
170
|
+
};
|
|
171
|
+
assertNonNullable(channel2.onMessage);
|
|
172
|
+
channel2.onMessage = null;
|
|
173
|
+
assertEqual(channel2.onMessage, null);
|
|
174
|
+
channel2.onMessage = (message) => {
|
|
175
|
+
received2.push(message);
|
|
176
|
+
delivered.resolve();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
channel1.postMessage("hello");
|
|
180
|
+
|
|
181
|
+
await delivered.promise;
|
|
182
|
+
assertEqual(received2, ["hello"]);
|
|
183
|
+
|
|
184
|
+
assertEqual(received1, []);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
channel1.onMessage = (message) => {
|
|
188
|
+
received1.push(message);
|
|
189
|
+
};
|
|
190
|
+
assertEqual(channel1.onMessage, null);
|
|
191
|
+
const error = assertThrowsInstanceOf(
|
|
192
|
+
() => channel1.postMessage("closed"),
|
|
193
|
+
Error,
|
|
194
|
+
);
|
|
195
|
+
assertEqual(error.message, "Cannot use a disposed object.");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("createMessagePort dispose uses terminate when available", () => {
|
|
199
|
+
const nativePort = {
|
|
200
|
+
onmessage: null as ((event: MessageEvent<string>) => void) | null,
|
|
201
|
+
postMessage:
|
|
202
|
+
mock.fn<
|
|
203
|
+
(message: unknown, transfer?: ReadonlyArray<Transferable>) => void
|
|
204
|
+
>(),
|
|
205
|
+
terminate: mock.fn(),
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const wrappedPort = createMessagePort(
|
|
209
|
+
nativePort as unknown as NativeMessagePort<string>,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
wrappedPort[Symbol.dispose]();
|
|
213
|
+
|
|
214
|
+
assertEqual(nativePort.onmessage, null);
|
|
215
|
+
assertEqual(nativePort.terminate.mock.callCount(), 1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("createSharedWorker wraps a shared worker port and disposes via close", () => {
|
|
219
|
+
const nativePort = createClosableNativePort<string>();
|
|
220
|
+
const nativeSharedWorker = { port: nativePort };
|
|
221
|
+
const worker = createSharedWorker<string, string>(
|
|
222
|
+
nativeSharedWorker as unknown as SharedWorker,
|
|
223
|
+
);
|
|
224
|
+
const received: Array<string> = [];
|
|
225
|
+
|
|
226
|
+
worker.port.onMessage = (message) => {
|
|
227
|
+
received.push(message);
|
|
228
|
+
};
|
|
229
|
+
nativePort.onmessage?.({ data: "response" } as MessageEvent<string>);
|
|
230
|
+
worker.port.postMessage("request");
|
|
231
|
+
worker[Symbol.dispose]();
|
|
232
|
+
|
|
233
|
+
assertEqual(received, ["response"]);
|
|
234
|
+
assertSame(worker.port.native, nativePort);
|
|
235
|
+
assertEqual(
|
|
236
|
+
nativePort.postMessage.mock.calls.map(({ arguments: args }) => args),
|
|
237
|
+
[["request"]],
|
|
238
|
+
);
|
|
239
|
+
assertEqual(nativePort.close.mock.callCount(), 1);
|
|
240
|
+
assertEqual(nativePort.onmessage, null);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("one-tab SharedWorker polyfill", () => {
|
|
244
|
+
it("installOneTabSharedWorkerPolyfill installs a Worker-backed SharedWorker", () => {
|
|
245
|
+
const nativeWorker = createClosableNativePort<string>();
|
|
246
|
+
const calls: Array<{
|
|
247
|
+
readonly scriptURL: string | URL;
|
|
248
|
+
readonly options: WorkerOptions | undefined;
|
|
249
|
+
}> = [];
|
|
250
|
+
const Worker = function (scriptURL: string | URL, options?: WorkerOptions) {
|
|
251
|
+
calls.push({ scriptURL, options });
|
|
252
|
+
return nativeWorker;
|
|
253
|
+
} as unknown as typeof globalThis.Worker;
|
|
254
|
+
const scriptURL = new URL("https://example.com/Shared.worker.js");
|
|
255
|
+
const options = { type: "module" } as const;
|
|
256
|
+
|
|
257
|
+
using _sharedWorker = testStubGlobal("SharedWorker", undefined);
|
|
258
|
+
using _worker = testStubGlobal("Worker", Worker);
|
|
259
|
+
|
|
260
|
+
installOneTabSharedWorkerPolyfill();
|
|
261
|
+
const nativeSharedWorker = new SharedWorker(scriptURL, options);
|
|
262
|
+
|
|
263
|
+
assertEqual(calls, [{ scriptURL, options }]);
|
|
264
|
+
assertSame(nativeSharedWorker.port, nativeWorker);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("installOneTabSharedWorkerPolyfill keeps native SharedWorker", () => {
|
|
268
|
+
const nativePort = createClosableNativePort<string>();
|
|
269
|
+
const NativeSharedWorker = class {
|
|
270
|
+
readonly port = nativePort;
|
|
271
|
+
} as unknown as typeof SharedWorker;
|
|
272
|
+
|
|
273
|
+
using _sharedWorker = testStubGlobal("SharedWorker", NativeSharedWorker);
|
|
274
|
+
|
|
275
|
+
installOneTabSharedWorkerPolyfill();
|
|
276
|
+
assertSame(SharedWorker, NativeSharedWorker);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("createOneTabSharedWorkerSelfPolyfill creates one queued synthetic connection", () => {
|
|
280
|
+
const nativeSelf = createClosableNativePort<string>();
|
|
281
|
+
const workerSelf = createOneTabSharedWorkerSelfPolyfill<string, string>(
|
|
282
|
+
nativeSelf as unknown as DedicatedWorkerGlobalScope,
|
|
283
|
+
);
|
|
284
|
+
const received: Array<string> = [];
|
|
285
|
+
let connectedPort!: MessagePort<string, string>;
|
|
286
|
+
|
|
287
|
+
assertEqual(workerSelf.onConnect, null);
|
|
288
|
+
workerSelf.onConnect = null;
|
|
289
|
+
workerSelf.onConnect = (port) => {
|
|
290
|
+
connectedPort = port;
|
|
291
|
+
};
|
|
292
|
+
assertNonNullable(workerSelf.onConnect);
|
|
293
|
+
|
|
294
|
+
nativeSelf.onmessage?.({ data: "queued" } as MessageEvent<string>);
|
|
295
|
+
connectedPort.onMessage = (message) => {
|
|
296
|
+
received.push(message);
|
|
297
|
+
};
|
|
298
|
+
assertNonNullable(connectedPort.onMessage);
|
|
299
|
+
|
|
300
|
+
nativeSelf.onmessage?.({ data: "immediate" } as MessageEvent<string>);
|
|
301
|
+
connectedPort.onMessage = null;
|
|
302
|
+
assertEqual(connectedPort.onMessage, null);
|
|
303
|
+
connectedPort.onMessage = (message) => {
|
|
304
|
+
received.push(message);
|
|
305
|
+
};
|
|
306
|
+
workerSelf.onConnect = null;
|
|
307
|
+
|
|
308
|
+
connectedPort.postMessage("response");
|
|
309
|
+
const transferable = new ArrayBuffer(1);
|
|
310
|
+
connectedPort.postMessage("response with transfer", [transferable]);
|
|
311
|
+
const nativeSelfOnMessage = nativeSelf.onmessage;
|
|
312
|
+
workerSelf[Symbol.dispose]();
|
|
313
|
+
nativeSelfOnMessage?.({ data: "ignored" } as MessageEvent<string>);
|
|
314
|
+
connectedPort.postMessage("ignored");
|
|
315
|
+
connectedPort.onMessage = () => {
|
|
316
|
+
received.push("ignored");
|
|
317
|
+
};
|
|
318
|
+
workerSelf[Symbol.dispose]();
|
|
319
|
+
|
|
320
|
+
assertEqual(received, ["queued", "immediate"]);
|
|
321
|
+
assertSame(connectedPort.native, nativeSelf);
|
|
322
|
+
assertEqual(connectedPort.onMessage, null);
|
|
323
|
+
assertEqual(
|
|
324
|
+
nativeSelf.postMessage.mock.calls.map(({ arguments: args }) => args),
|
|
325
|
+
[["response"], ["response with transfer", [transferable]]],
|
|
326
|
+
);
|
|
327
|
+
assertEqual(nativeSelf.close.mock.callCount(), 1);
|
|
328
|
+
assertEqual(nativeSelf.onmessage, null);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("createOneTabSharedWorkerSelfPolyfill stops flushing when onMessage is cleared", () => {
|
|
332
|
+
const nativeSelf = createClosableNativePort<string>();
|
|
333
|
+
const workerSelf = createOneTabSharedWorkerSelfPolyfill<string, string>(
|
|
334
|
+
nativeSelf as unknown as DedicatedWorkerGlobalScope,
|
|
335
|
+
);
|
|
336
|
+
const received: Array<string> = [];
|
|
337
|
+
let connectedPort!: MessagePort<string, string>;
|
|
338
|
+
|
|
339
|
+
workerSelf.onConnect = (port) => {
|
|
340
|
+
connectedPort = port;
|
|
341
|
+
};
|
|
342
|
+
nativeSelf.onmessage?.({ data: "first" } as MessageEvent<string>);
|
|
343
|
+
nativeSelf.onmessage?.({ data: "second" } as MessageEvent<string>);
|
|
344
|
+
connectedPort.onMessage = (message) => {
|
|
345
|
+
received.push(message);
|
|
346
|
+
connectedPort.onMessage = null;
|
|
347
|
+
};
|
|
348
|
+
workerSelf[Symbol.dispose]();
|
|
349
|
+
|
|
350
|
+
assertEqual(received, ["first"]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("createOneTabSharedWorkerSelfPolyfill disposes from connected port", () => {
|
|
354
|
+
const nativeSelf = createClosableNativePort<string>();
|
|
355
|
+
const workerSelf = createOneTabSharedWorkerSelfPolyfill<string, string>(
|
|
356
|
+
nativeSelf as unknown as DedicatedWorkerGlobalScope,
|
|
357
|
+
);
|
|
358
|
+
let connectedPort!: MessagePort<string, string>;
|
|
359
|
+
|
|
360
|
+
workerSelf.onConnect = (port) => {
|
|
361
|
+
connectedPort = port;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
connectedPort[Symbol.dispose]();
|
|
365
|
+
workerSelf.onConnect = () => {
|
|
366
|
+
throw new Error("Disposed worker self must ignore onConnect setter.");
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
assertEqual(workerSelf.onConnect, null);
|
|
370
|
+
assertEqual(connectedPort.onMessage, null);
|
|
371
|
+
assertEqual(nativeSelf.close.mock.callCount(), 1);
|
|
372
|
+
assertEqual(nativeSelf.onmessage, null);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("createWorkerSelf wraps dedicated worker self and disposes via close", () => {
|
|
377
|
+
const nativeSelf = createClosableNativePort<string>();
|
|
378
|
+
const workerSelf = createWorkerSelf<string, string>(
|
|
379
|
+
nativeSelf as unknown as DedicatedWorkerGlobalScope,
|
|
380
|
+
);
|
|
381
|
+
const received: Array<string> = [];
|
|
382
|
+
|
|
383
|
+
workerSelf.onMessage = (message) => {
|
|
384
|
+
received.push(message);
|
|
385
|
+
};
|
|
386
|
+
nativeSelf.onmessage?.({ data: "request" } as MessageEvent<string>);
|
|
387
|
+
workerSelf.postMessage("response");
|
|
388
|
+
workerSelf[Symbol.dispose]();
|
|
389
|
+
|
|
390
|
+
assertEqual(received, ["request"]);
|
|
391
|
+
assertEqual(
|
|
392
|
+
nativeSelf.postMessage.mock.calls.map(({ arguments: args }) => args),
|
|
393
|
+
[["response"]],
|
|
394
|
+
);
|
|
395
|
+
assertEqual(nativeSelf.close.mock.callCount(), 1);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("createSharedWorkerSelf wraps connected ports and disposes the worker scope", () => {
|
|
399
|
+
const nativePort = createClosableNativePort<string>();
|
|
400
|
+
const nativeSelf = {
|
|
401
|
+
close: mock.fn(),
|
|
402
|
+
onconnect: null as ((event: MessageEvent) => void) | null,
|
|
403
|
+
};
|
|
404
|
+
const workerSelf = createSharedWorkerSelf<string, string>(
|
|
405
|
+
nativeSelf as unknown as SharedWorkerGlobalScope,
|
|
406
|
+
);
|
|
407
|
+
const received: Array<string> = [];
|
|
408
|
+
let connectedPort!: MessagePort<string, string>;
|
|
409
|
+
|
|
410
|
+
workerSelf.onConnect = (port) => {
|
|
411
|
+
connectedPort = port;
|
|
412
|
+
};
|
|
413
|
+
nativeSelf.onconnect?.({ ports: [nativePort] } as unknown as MessageEvent);
|
|
414
|
+
|
|
415
|
+
connectedPort.onMessage = (message) => {
|
|
416
|
+
received.push(message);
|
|
417
|
+
};
|
|
418
|
+
nativePort.onmessage?.({ data: "request" } as MessageEvent<string>);
|
|
419
|
+
connectedPort.postMessage("response");
|
|
420
|
+
connectedPort[Symbol.dispose]();
|
|
421
|
+
workerSelf[Symbol.dispose]();
|
|
422
|
+
|
|
423
|
+
assertEqual(received, ["request"]);
|
|
424
|
+
assertSame(connectedPort.native, nativePort);
|
|
425
|
+
assertEqual(
|
|
426
|
+
nativePort.postMessage.mock.calls.map(({ arguments: args }) => args),
|
|
427
|
+
[["response"]],
|
|
428
|
+
);
|
|
429
|
+
assertEqual(nativePort.close.mock.callCount(), 1);
|
|
430
|
+
assertEqual(nativeSelf.onconnect, null);
|
|
431
|
+
assertEqual(nativeSelf.close.mock.callCount(), 1);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("createSharedWorkerSelf asserts when a connection arrives before onConnect is set", () => {
|
|
435
|
+
const nativeSelf = {
|
|
436
|
+
close: mock.fn(),
|
|
437
|
+
onconnect: null as ((event: MessageEvent) => void) | null,
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
createSharedWorkerSelf<string, string>(
|
|
441
|
+
nativeSelf as unknown as SharedWorkerGlobalScope,
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
const error = assertThrowsInstanceOf(() => {
|
|
445
|
+
nativeSelf.onconnect?.({ ports: [] } as unknown as MessageEvent);
|
|
446
|
+
}, Error);
|
|
447
|
+
assertEqual(
|
|
448
|
+
error.message,
|
|
449
|
+
"onConnect must be set before receiving connections",
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("createWorkerDeps stores console output entries and exposes createMessagePort", () => {
|
|
454
|
+
const deps = createWorkerDeps();
|
|
455
|
+
|
|
456
|
+
deps.console.warn("worker-warning");
|
|
457
|
+
|
|
458
|
+
assertSame(deps.createMessagePort, createMessagePort);
|
|
459
|
+
assertEqual(deps.consoleStoreOutputEntry.get(), {
|
|
460
|
+
args: ["worker-warning"],
|
|
461
|
+
method: "warn",
|
|
462
|
+
path: [],
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const createClosableNativePort = <Output = never>() => ({
|
|
467
|
+
close: mock.fn(),
|
|
468
|
+
onmessage: null as ((event: MessageEvent<Output>) => void) | null,
|
|
469
|
+
postMessage:
|
|
470
|
+
mock.fn<
|
|
471
|
+
(message: unknown, transfer?: ReadonlyArray<Transferable>) => void
|
|
472
|
+
>(),
|
|
473
|
+
});
|
package/src/Worker.ts
CHANGED
|
@@ -152,7 +152,7 @@ export const createBroadcastChannel: CreateBroadcastChannel = <
|
|
|
152
152
|
* (`self` inside a dedicated worker).
|
|
153
153
|
*/
|
|
154
154
|
export const createWorkerSelf = <Input, Output = never>(
|
|
155
|
-
nativeSelf:
|
|
155
|
+
nativeSelf: DedicatedWorkerGlobalScope,
|
|
156
156
|
): WorkerSelf<Input, Output> => wrap<Output, Input>(nativeSelf);
|
|
157
157
|
|
|
158
158
|
/**
|
|
@@ -162,7 +162,7 @@ export const createWorkerSelf = <Input, Output = never>(
|
|
|
162
162
|
* Disposing closes the shared worker scope for all connected clients.
|
|
163
163
|
*/
|
|
164
164
|
export const createSharedWorkerSelf = <Input, Output = never>(
|
|
165
|
-
nativeSelf:
|
|
165
|
+
nativeSelf: SharedWorkerGlobalScope,
|
|
166
166
|
): SharedWorkerSelf<Input, Output> => {
|
|
167
167
|
const self: SharedWorkerSelf<Input, Output> = {
|
|
168
168
|
onConnect: null,
|
|
@@ -193,7 +193,7 @@ export const createSharedWorkerSelf = <Input, Output = never>(
|
|
|
193
193
|
* `onMessage` is set, and does not share state across tabs.
|
|
194
194
|
*/
|
|
195
195
|
export const createOneTabSharedWorkerSelfPolyfill = <Input, Output = never>(
|
|
196
|
-
nativeSelf:
|
|
196
|
+
nativeSelf: DedicatedWorkerGlobalScope,
|
|
197
197
|
): SharedWorkerSelf<Input, Output> => {
|
|
198
198
|
using disposer = new DisposableStack();
|
|
199
199
|
|
|
@@ -13,7 +13,7 @@ import { createWorkerDeps, createWorkerSelf } from "../Worker.ts";
|
|
|
13
13
|
const run = createRun({
|
|
14
14
|
...createWorkerDeps(),
|
|
15
15
|
createSqliteDriver: createWasmSqliteDriver,
|
|
16
|
-
lockManager:
|
|
16
|
+
lockManager: navigator.locks,
|
|
17
17
|
randomBytes: createRandomBytes(),
|
|
18
18
|
});
|
|
19
19
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertEqual,
|
|
3
|
+
assertNonNullable,
|
|
4
|
+
testStubGlobal,
|
|
5
|
+
type NativeMessagePort,
|
|
6
|
+
} from "@evolu/common";
|
|
7
|
+
import { describe, it, mock } from "node:test";
|
|
8
|
+
import { createEvoluDeps } from "./Evolu.ts";
|
|
9
|
+
|
|
10
|
+
describe("createEvoluDeps", () => {
|
|
11
|
+
it("createEvoluDeps calls callback when one-tab SharedWorker polyfill is already open", () => {
|
|
12
|
+
const nativeSharedWorkerPort = createClosableNativePort<unknown>();
|
|
13
|
+
const nativeDbWorker = createClosableNativePort();
|
|
14
|
+
const onSharedWorkerUnsupported = mock.fn<() => void>();
|
|
15
|
+
|
|
16
|
+
using _sharedWorker = testStubGlobal(
|
|
17
|
+
"SharedWorker",
|
|
18
|
+
class {
|
|
19
|
+
readonly port = nativeSharedWorkerPort as unknown as NativeMessagePort<
|
|
20
|
+
never,
|
|
21
|
+
unknown
|
|
22
|
+
>;
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
const Worker = mock.fn(function () {
|
|
26
|
+
return nativeDbWorker;
|
|
27
|
+
});
|
|
28
|
+
using _worker = testStubGlobal("Worker", Worker);
|
|
29
|
+
|
|
30
|
+
using deps = createEvoluDeps({
|
|
31
|
+
onSharedWorkerUnsupported,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
nativeSharedWorkerPort.onmessage?.(
|
|
35
|
+
new MessageEvent("message", {
|
|
36
|
+
data: { type: "SharedWorkerUnsupported" },
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
assertEqual(onSharedWorkerUnsupported.mock.callCount(), 1);
|
|
41
|
+
assertEqual(Worker.mock.callCount(), 0);
|
|
42
|
+
assertNonNullable(deps);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const createClosableNativePort = <Output = never>() => ({
|
|
47
|
+
close: mock.fn(),
|
|
48
|
+
onmessage: null as ((event: MessageEvent<Output>) => void) | null,
|
|
49
|
+
postMessage: mock.fn(),
|
|
50
|
+
});
|
package/src/local-first/Evolu.ts
CHANGED
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
} from "../Worker.ts";
|
|
19
19
|
|
|
20
20
|
// // TODO: Redesign.
|
|
21
|
-
// // eslint-disable-next-line evolu/require-pure-annotation
|
|
22
21
|
// export const localAuth = createLocalAuth({
|
|
23
22
|
// randomBytes: createRandomBytes(),
|
|
24
23
|
// secureStorage: createWebAuthnStore({ randomBytes: createRandomBytes() }),
|
|
@@ -76,7 +75,7 @@ export const createEvoluDeps = (
|
|
|
76
75
|
if (deps.onSharedWorkerUnsupported) {
|
|
77
76
|
deps.onSharedWorkerUnsupported();
|
|
78
77
|
} else {
|
|
79
|
-
|
|
78
|
+
alert(
|
|
80
79
|
"This browser supports Evolu in one tab only. Close this tab and use the already open tab.",
|
|
81
80
|
);
|
|
82
81
|
}
|
|
@@ -97,7 +96,7 @@ export const createEvoluDeps = (
|
|
|
97
96
|
createDbWorker,
|
|
98
97
|
createBroadcastChannel,
|
|
99
98
|
createMessageChannel,
|
|
100
|
-
lockManager:
|
|
99
|
+
lockManager: navigator.locks,
|
|
101
100
|
reloadApp,
|
|
102
101
|
sharedWorker,
|
|
103
102
|
});
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
const run = createRun({
|
|
22
22
|
...createWorkerDeps(),
|
|
23
23
|
createWebSocket,
|
|
24
|
-
lockManager:
|
|
24
|
+
lockManager: navigator.locks,
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
void run(async (run) => {
|
|
@@ -37,7 +37,7 @@ void run(async (run) => {
|
|
|
37
37
|
SharedWorkerOutput
|
|
38
38
|
>(self);
|
|
39
39
|
|
|
40
|
-
await
|
|
40
|
+
await navigator.locks.request(
|
|
41
41
|
"evolu-one-tab-sharedworker-polyfill",
|
|
42
42
|
{ ifAvailable: true, mode: "exclusive" },
|
|
43
43
|
async (lock) => {
|