@evolu/web 3.1.2 → 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.
- 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 +53 -2
- 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/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 +3 -1
- package/package.json +4 -5
- package/src/Platform.test.ts +29 -1
- package/src/Platform.ts +2 -1
- package/src/Sqlite.test.ts +176 -0
- package/src/Sqlite.ts +68 -1
- package/src/Task.test.ts +3 -14
- package/src/Task.ts +8 -4
- package/src/local-first/Evolu.test.ts +600 -2
- package/src/local-first/Evolu.ts +321 -40
- package/src/local-first/Shared.worker.ts +4 -1
- 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/local-first/Evolu.ts
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
exhaustiveCheck,
|
|
3
|
+
trySync,
|
|
4
|
+
type ConsoleDep,
|
|
5
|
+
type ReloadApp,
|
|
6
|
+
type ReloadAppDep,
|
|
7
|
+
type UnsupportedDbVersionError,
|
|
8
|
+
} from "@evolu/common";
|
|
2
9
|
import type {
|
|
3
10
|
SharedWorker as CommonSharedWorker,
|
|
4
11
|
CreateDbWorker,
|
|
5
12
|
DbWorkerInit,
|
|
6
13
|
EvoluDeps,
|
|
14
|
+
SharedWorkerId,
|
|
7
15
|
SharedWorkerInput,
|
|
8
16
|
SharedWorkerOutput,
|
|
9
17
|
} from "@evolu/common/local-first";
|
|
10
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
BuildWaiting,
|
|
20
|
+
BuildWaitingRequest,
|
|
21
|
+
buildsBroadcastChannelName,
|
|
22
|
+
createEvoluDeps as createCommonEvoluDeps,
|
|
23
|
+
} from "@evolu/common/local-first";
|
|
11
24
|
import { reloadApp } from "../Platform.ts";
|
|
12
25
|
import {
|
|
13
26
|
createBroadcastChannel,
|
|
@@ -17,12 +30,6 @@ import {
|
|
|
17
30
|
installOneTabSharedWorkerPolyfill,
|
|
18
31
|
} from "../Worker.ts";
|
|
19
32
|
|
|
20
|
-
// // TODO: Redesign.
|
|
21
|
-
// export const localAuth = createLocalAuth({
|
|
22
|
-
// randomBytes: createRandomBytes(),
|
|
23
|
-
// secureStorage: createWebAuthnStore({ randomBytes: createRandomBytes() }),
|
|
24
|
-
// });
|
|
25
|
-
|
|
26
33
|
export interface SharedWorkerUnsupported {
|
|
27
34
|
readonly type: "SharedWorkerUnsupported";
|
|
28
35
|
}
|
|
@@ -31,18 +38,231 @@ export interface SharedWorkerUnsupportedDep {
|
|
|
31
38
|
readonly onSharedWorkerUnsupported: () => void;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
export interface StorageUnavailableDep {
|
|
42
|
+
/**
|
|
43
|
+
* Called when the browser offers no persistent storage, as in Safari's
|
|
44
|
+
* Private Browsing, so every database is kept in memory. See Storage in the
|
|
45
|
+
* Shared module of `@evolu/common`.
|
|
46
|
+
*
|
|
47
|
+
* The app keeps working. Data synced with a relay comes back, as on a new
|
|
48
|
+
* device, and nothing stays on the device once the tabs close. That suits
|
|
49
|
+
* someone checking their app on a borrowed phone, so a message such as
|
|
50
|
+
* "Nothing from this session is kept on this device." tells the user what to
|
|
51
|
+
* expect. Data that exists only locally, or has not synced yet, is lost when
|
|
52
|
+
* the tab hosting the database closes or navigates away, even while other
|
|
53
|
+
* tabs stay open.
|
|
54
|
+
*/
|
|
55
|
+
readonly onStorageUnavailable: () => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Creates Evolu dependencies for the web platform.
|
|
60
|
+
*
|
|
61
|
+
* When another build of the app waits for this one, this tab reloads to load
|
|
62
|
+
* the build the server now serves: at once if the user is not in the tab,
|
|
63
|
+
* otherwise once they leave it. See Builds in the Shared module of
|
|
64
|
+
* `@evolu/common`. A tab whose database refuses to start with
|
|
65
|
+
* {@link UnsupportedDbVersionError} reloads once for each stored version, even
|
|
66
|
+
* while the user is in it, because the server may now serve a build that
|
|
67
|
+
* supports the database. The error is reported when the reloaded build refuses
|
|
68
|
+
* it too, or when the tab has no session storage. A tab whose worker was
|
|
69
|
+
* relaunched without its state, as WebKit does when the process hosting it
|
|
70
|
+
* ends, also reloads at once.
|
|
71
|
+
*
|
|
72
|
+
* When the page enters the browser's back-forward cache, as Safari does on
|
|
73
|
+
* every navigation away, this tab ends its part as if it closed: it stops the
|
|
74
|
+
* database workers it hosts, so another tab takes them over, and it reloads if
|
|
75
|
+
* the user comes back to it.
|
|
76
|
+
*
|
|
77
|
+
* Where the browser offers no persistent storage, as in Safari's Private
|
|
78
|
+
* Browsing, the database is kept in memory, and
|
|
79
|
+
* {@link StorageUnavailableDep.onStorageUnavailable} lets the app tell the
|
|
80
|
+
* user.
|
|
81
|
+
*
|
|
82
|
+
* A custom {@link ReloadApp} replaces the default page reload, for example to
|
|
83
|
+
* save state first. It should end by reloading the page, because the other
|
|
84
|
+
* build waits until this tab reloads or closes, and a page restored from the
|
|
85
|
+
* back-forward cache cannot work until it reloads.
|
|
86
|
+
*/
|
|
35
87
|
export const createEvoluDeps = (
|
|
36
|
-
deps: Partial<ConsoleDep> &
|
|
88
|
+
deps: Partial<ConsoleDep> &
|
|
89
|
+
Partial<ReloadAppDep> &
|
|
90
|
+
Partial<SharedWorkerUnsupportedDep> &
|
|
91
|
+
Partial<StorageUnavailableDep> = {},
|
|
37
92
|
): EvoluDeps => {
|
|
38
93
|
installOneTabSharedWorkerPolyfill();
|
|
94
|
+
const reloadThisApp = deps.reloadApp ?? reloadApp;
|
|
95
|
+
// A page an automatic reload loaded never announces its build, so two builds
|
|
96
|
+
// cannot keep reloading each other.
|
|
97
|
+
const isAutomaticReload = takeAutomaticReload();
|
|
98
|
+
// Waiting workers this tab already reloaded for. A reload that loaded the
|
|
99
|
+
// running build again did not help, so the tab does not repeat it.
|
|
100
|
+
const reloadedForWorkerIds = getReloadedForWorkerIds();
|
|
101
|
+
// Versions a refusal already reloaded this tab for, read once, so every
|
|
102
|
+
// refusal before this page unloads reloads it.
|
|
103
|
+
const refusalReloadVersions = getRefusalReloadVersions();
|
|
104
|
+
|
|
105
|
+
let connectedWorkerId: SharedWorkerId | null = null;
|
|
106
|
+
let waitingWorkerId: SharedWorkerId | null = null;
|
|
107
|
+
// Workers announced before this tab connected. The lock can pass to this
|
|
108
|
+
// tab's worker first, and their tabs may not answer a request, for example
|
|
109
|
+
// when frozen, so the tab handles them once it connects.
|
|
110
|
+
const earlyAnnouncedWorkerIds = new Set<SharedWorkerId>();
|
|
111
|
+
// Checks whether the user left this tab while another build waits.
|
|
112
|
+
let focusCheckId: ReturnType<typeof setInterval> | null = null;
|
|
39
113
|
|
|
40
|
-
|
|
41
|
-
|
|
114
|
+
using disposer = new DisposableStack();
|
|
115
|
+
const buildsBroadcastChannel = disposer.use(
|
|
116
|
+
createBroadcastChannel<unknown>(buildsBroadcastChannelName),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const stopFocusCheck = (): void => {
|
|
120
|
+
if (focusCheckId === null) return;
|
|
121
|
+
clearInterval(focusCheckId);
|
|
122
|
+
focusCheckId = null;
|
|
123
|
+
};
|
|
124
|
+
disposer.defer(stopFocusCheck);
|
|
125
|
+
|
|
126
|
+
const announceWaitingBuild = (): void => {
|
|
127
|
+
if (waitingWorkerId === null || isAutomaticReload) return;
|
|
128
|
+
buildsBroadcastChannel.postMessage({
|
|
129
|
+
type: "BuildWaiting",
|
|
130
|
+
workerId: waitingWorkerId,
|
|
131
|
+
} satisfies BuildWaiting);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const reloadForWaitingBuild = (workerId: SharedWorkerId): void => {
|
|
135
|
+
stopFocusCheck();
|
|
136
|
+
// Without session storage, the reloaded page could announce in turn.
|
|
137
|
+
if (!markAutomaticReload(workerId)) return;
|
|
138
|
+
reloadThisApp();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const handleWaitingBuild = (workerId: SharedWorkerId): void => {
|
|
142
|
+
if (workerId === connectedWorkerId || reloadedForWorkerIds.has(workerId)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!document.hasFocus()) {
|
|
146
|
+
reloadForWaitingBuild(workerId);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// Focus can leave the page from a frame without a window event.
|
|
150
|
+
focusCheckId ??= setInterval(() => {
|
|
151
|
+
if (!document.hasFocus()) reloadForWaitingBuild(workerId);
|
|
152
|
+
}, 1000);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Builds of different releases share the channel, so messages are validated.
|
|
156
|
+
buildsBroadcastChannel.onMessage = (message) => {
|
|
157
|
+
if (BuildWaitingRequest.is(message)) {
|
|
158
|
+
announceWaitingBuild();
|
|
159
|
+
} else if (BuildWaiting.is(message)) {
|
|
160
|
+
if (connectedWorkerId === null) {
|
|
161
|
+
earlyAnnouncedWorkerIds.add(message.workerId);
|
|
162
|
+
} else handleWaitingBuild(message.workerId);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const handleSharedWorkerMessage = (
|
|
167
|
+
message: SharedWorkerOutput | SharedWorkerUnsupported,
|
|
168
|
+
forward: (message: SharedWorkerOutput) => void,
|
|
169
|
+
): void => {
|
|
170
|
+
// WebKit can relaunch a SharedWorker without its state when the process
|
|
171
|
+
// hosting it ends, and connect the tabs' existing ports to the new worker
|
|
172
|
+
// (https://bugs.webkit.org/show_bug.cgi?id=318873). A worker tells a tab
|
|
173
|
+
// at most once that it waits and once that it connected, both with its own
|
|
174
|
+
// id, so only then does a tab hear from a worker with another id. The
|
|
175
|
+
// tab's state, including the databases it asked for while it waited, ended
|
|
176
|
+
// with the old worker, so it reloads at once.
|
|
177
|
+
const knownWorkerId = connectedWorkerId ?? waitingWorkerId;
|
|
178
|
+
if (
|
|
179
|
+
(message.type === "Waiting" || message.type === "Connected") &&
|
|
180
|
+
knownWorkerId !== null &&
|
|
181
|
+
message.workerId !== knownWorkerId
|
|
182
|
+
) {
|
|
183
|
+
reloadThisApp();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
switch (message.type) {
|
|
188
|
+
case "DbWorkerInit": {
|
|
189
|
+
forward(message);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
case "Error": {
|
|
194
|
+
// The page is about to reload, so the error is not reported.
|
|
195
|
+
if (
|
|
196
|
+
message.error.type === "UnsupportedDbVersionError" &&
|
|
197
|
+
!refusalReloadVersions.has(String(message.error.storedVersion)) &&
|
|
198
|
+
markRefusalReload(message.error)
|
|
199
|
+
) {
|
|
200
|
+
reloadThisApp();
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
forward(message);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
case "Waiting": {
|
|
208
|
+
waitingWorkerId = message.workerId;
|
|
209
|
+
announceWaitingBuild();
|
|
210
|
+
forward(message);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
case "Connected": {
|
|
215
|
+
waitingWorkerId = null;
|
|
216
|
+
connectedWorkerId = message.workerId;
|
|
217
|
+
forward(message);
|
|
218
|
+
for (const workerId of earlyAnnouncedWorkerIds) {
|
|
219
|
+
handleWaitingBuild(workerId);
|
|
220
|
+
}
|
|
221
|
+
// An announcement may have come before this tab started.
|
|
222
|
+
buildsBroadcastChannel.postMessage({
|
|
223
|
+
type: "BuildWaitingRequest",
|
|
224
|
+
} satisfies BuildWaitingRequest);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
case "StorageUnavailable": {
|
|
229
|
+
deps.onStorageUnavailable?.();
|
|
230
|
+
forward(message);
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
case "SharedWorkerUnsupported": {
|
|
235
|
+
if (deps.onSharedWorkerUnsupported) {
|
|
236
|
+
deps.onSharedWorkerUnsupported();
|
|
237
|
+
} else {
|
|
238
|
+
alert(
|
|
239
|
+
"This browser supports Evolu in one tab only. Close this tab and use the already open tab.",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
default:
|
|
246
|
+
exhaustiveCheck(message);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Disposing the deps ends the DbWorkers this tab hosts, which releases their
|
|
251
|
+
// database locks for the tab that takes over.
|
|
252
|
+
const dbWorkers = new Set<Disposable>();
|
|
253
|
+
disposer.defer(() => {
|
|
254
|
+
for (const dbWorker of dbWorkers) dbWorker[Symbol.dispose]();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const createDbWorker: CreateDbWorker = () => {
|
|
258
|
+
const dbWorker = createWorker<DbWorkerInit, never>(
|
|
42
259
|
new Worker(new URL("Db.worker.js", import.meta.url), {
|
|
43
260
|
type: "module",
|
|
44
261
|
}),
|
|
45
262
|
);
|
|
263
|
+
dbWorkers.add(dbWorker);
|
|
264
|
+
return dbWorker;
|
|
265
|
+
};
|
|
46
266
|
|
|
47
267
|
const webSharedWorker = createSharedWorker<
|
|
48
268
|
SharedWorkerInput,
|
|
@@ -65,39 +285,100 @@ export const createEvoluDeps = (
|
|
|
65
285
|
onSharedWorkerMessage = fn;
|
|
66
286
|
webSharedWorker.port.onMessage = fn
|
|
67
287
|
? (message: SharedWorkerOutput | SharedWorkerUnsupported) => {
|
|
68
|
-
|
|
69
|
-
case "DbWorkerInit": {
|
|
70
|
-
fn(message);
|
|
71
|
-
break;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
case "SharedWorkerUnsupported": {
|
|
75
|
-
if (deps.onSharedWorkerUnsupported) {
|
|
76
|
-
deps.onSharedWorkerUnsupported();
|
|
77
|
-
} else {
|
|
78
|
-
alert(
|
|
79
|
-
"This browser supports Evolu in one tab only. Close this tab and use the already open tab.",
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
break;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
default:
|
|
86
|
-
exhaustiveCheck(message);
|
|
87
|
-
}
|
|
288
|
+
handleSharedWorkerMessage(message, fn);
|
|
88
289
|
}
|
|
89
290
|
: null;
|
|
90
291
|
},
|
|
91
292
|
},
|
|
92
293
|
};
|
|
93
294
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
295
|
+
const evoluDeps = disposer.use(
|
|
296
|
+
createCommonEvoluDeps({
|
|
297
|
+
...deps,
|
|
298
|
+
createDbWorker,
|
|
299
|
+
createBroadcastChannel,
|
|
300
|
+
createMessageChannel,
|
|
301
|
+
lockManager: navigator.locks,
|
|
302
|
+
reloadApp: reloadThisApp,
|
|
303
|
+
sharedWorker,
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// A page entering the back-forward cache is frozen with its DbWorkers, which
|
|
308
|
+
// keep their database locks, so the other tabs would stall. WebKit also
|
|
309
|
+
// releases the page's own locks, so a restored page would work with state
|
|
310
|
+
// the other tabs gave up on. The page therefore ends its part as if it
|
|
311
|
+
// closed, and reloads when it is shown again.
|
|
312
|
+
const reloadRestoredPage = (event: PageTransitionEvent): void => {
|
|
313
|
+
if (event.persisted) reloadThisApp();
|
|
314
|
+
};
|
|
315
|
+
const handlePageHide = (event: PageTransitionEvent): void => {
|
|
316
|
+
if (!event.persisted) return;
|
|
317
|
+
addEventListener("pageshow", reloadRestoredPage, { once: true });
|
|
318
|
+
disposables.dispose();
|
|
319
|
+
};
|
|
320
|
+
addEventListener("pagehide", handlePageHide);
|
|
321
|
+
disposer.defer(() => {
|
|
322
|
+
removeEventListener("pagehide", handlePageHide);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const disposables = disposer.move();
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
...evoluDeps,
|
|
329
|
+
[Symbol.dispose]: () => {
|
|
330
|
+
disposables.dispose();
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// Marks, for the tab's session, that Evolu reloaded the page for another build.
|
|
336
|
+
const automaticReloadKey = "evolu:automatic-reload";
|
|
337
|
+
// Holds, for the tab's session, every waiting worker Evolu reloaded it for.
|
|
338
|
+
const reloadedForKey = "evolu:reloaded-for";
|
|
339
|
+
|
|
340
|
+
// Removes the mark, so only the page the reload loaded sees it.
|
|
341
|
+
const takeAutomaticReload = (): boolean => {
|
|
342
|
+
const item = trySync(() => {
|
|
343
|
+
const value = sessionStorage.getItem(automaticReloadKey);
|
|
344
|
+
sessionStorage.removeItem(automaticReloadKey);
|
|
345
|
+
return value;
|
|
102
346
|
});
|
|
347
|
+
return item.ok && item.value !== null;
|
|
103
348
|
};
|
|
349
|
+
|
|
350
|
+
const getReloadedForWorkerIds = (): ReadonlySet<string> => {
|
|
351
|
+
const item = trySync(() => sessionStorage.getItem(reloadedForKey));
|
|
352
|
+
return new Set(item.ok && item.value !== null ? item.value.split(",") : []);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const markAutomaticReload = (workerId: SharedWorkerId): boolean =>
|
|
356
|
+
trySync(() => {
|
|
357
|
+
const workerIds = new Set(
|
|
358
|
+
sessionStorage.getItem(reloadedForKey)?.split(","),
|
|
359
|
+
);
|
|
360
|
+
workerIds.add(workerId);
|
|
361
|
+
sessionStorage.setItem(reloadedForKey, [...workerIds].join(","));
|
|
362
|
+
sessionStorage.setItem(automaticReloadKey, "1");
|
|
363
|
+
}).ok;
|
|
364
|
+
|
|
365
|
+
// Holds, for the tab's session, every stored database version a refusal
|
|
366
|
+
// reloaded the page for. Each version reloads it once, so refusals of several
|
|
367
|
+
// databases cannot keep reloading it, and a build that refuses them again
|
|
368
|
+
// reports the error instead.
|
|
369
|
+
const refusalReloadKey = "evolu:refusal-reloads";
|
|
370
|
+
|
|
371
|
+
const getRefusalReloadVersions = (): ReadonlySet<string> => {
|
|
372
|
+
const item = trySync(() => sessionStorage.getItem(refusalReloadKey));
|
|
373
|
+
return new Set(item.ok && item.value !== null ? item.value.split(",") : []);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// Adds to the stored versions, which include those marked earlier in this page.
|
|
377
|
+
const markRefusalReload = (error: UnsupportedDbVersionError): boolean =>
|
|
378
|
+
trySync(() => {
|
|
379
|
+
const versions = new Set(
|
|
380
|
+
sessionStorage.getItem(refusalReloadKey)?.split(","),
|
|
381
|
+
);
|
|
382
|
+
versions.add(String(error.storedVersion));
|
|
383
|
+
sessionStorage.setItem(refusalReloadKey, [...versions].join(","));
|
|
384
|
+
}).ok;
|
|
@@ -4,7 +4,7 @@ declare const self: DedicatedWorkerGlobalScope | SharedWorkerGlobalScope;
|
|
|
4
4
|
import { installPolyfills } from "@evolu/common/polyfills";
|
|
5
5
|
installPolyfills();
|
|
6
6
|
|
|
7
|
-
import { createWebSocket, ok, waitForAbort } from "@evolu/common";
|
|
7
|
+
import { createWebSocket, ok, tryAsync, waitForAbort } from "@evolu/common";
|
|
8
8
|
import type {
|
|
9
9
|
SharedWorkerInput,
|
|
10
10
|
SharedWorkerOutput,
|
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
const run = createRun({
|
|
22
22
|
...createWorkerDeps(),
|
|
23
23
|
createWebSocket,
|
|
24
|
+
// Safari's Private Browsing offers no OPFS; see Storage in the Shared module.
|
|
25
|
+
isPersistentStorageAvailable: async () =>
|
|
26
|
+
(await tryAsync(() => navigator.storage.getDirectory())).ok,
|
|
24
27
|
lockManager: navigator.locks,
|
|
25
28
|
});
|
|
26
29
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"LocalAuth.d.ts","sourceRoot":"","sources":["../../../src/local-first/LocalAuth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,cAAc,EACd,aAAa,EAEd,MAAM,eAAe,CAAC;AAiBvB,eAAO,MAAM,mBAAmB,SAAU,cAAc,KAAG,aA4GzD,CAAC"}
|
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
import { Base64Url, base64UrlToUint8Array, bytesToUtf8, createSlip21, decryptWithXChaCha20Poly1305, EncryptionKey, encryptWithXChaCha20Poly1305, Entropy24, uint8ArrayToBase64Url, utf8ToBytes, XChaCha20Poly1305Ciphertext, } from "@evolu/common";
|
|
2
|
-
import { clear, createStore, del, get, keys, set } from "idb-keyval";
|
|
3
|
-
export const createWebAuthnStore = (deps) => ({
|
|
4
|
-
setItem: async (key, value, options) => {
|
|
5
|
-
if (options?.accessControl === "none") {
|
|
6
|
-
const metadata = createMetadata(false);
|
|
7
|
-
await set(key, { value, metadata }, getStore(options.service));
|
|
8
|
-
return { metadata };
|
|
9
|
-
}
|
|
10
|
-
const seed = generateSeed(deps)();
|
|
11
|
-
const authResult = JSON.parse(value);
|
|
12
|
-
const credential = await createCredential(deps)(options?.webAuthnUsername ?? "Evolu User", seed, options?.relyingPartyID, options?.relyingPartyName, options?.webAuthnUserVerification, options?.webAuthnAuthenticatorAttachment);
|
|
13
|
-
const encryptionKey = deriveEncryptionKey(seed);
|
|
14
|
-
const encryptedData = encryptAuthResult(deps)(authResult, encryptionKey);
|
|
15
|
-
const credentialId = uint8ArrayToBase64Url(new Uint8Array(credential.rawId));
|
|
16
|
-
const metadata = createMetadata();
|
|
17
|
-
await set(key, { credentialId, ...encryptedData, metadata }, getStore(options?.service));
|
|
18
|
-
return { metadata };
|
|
19
|
-
},
|
|
20
|
-
getItem: async (key, options) => {
|
|
21
|
-
if (options?.accessControl === "none") {
|
|
22
|
-
const data = await get(key, getStore(options.service));
|
|
23
|
-
return data
|
|
24
|
-
? {
|
|
25
|
-
key,
|
|
26
|
-
value: data.value,
|
|
27
|
-
service: options.service ?? "default",
|
|
28
|
-
metadata: data.metadata,
|
|
29
|
-
}
|
|
30
|
-
: null;
|
|
31
|
-
}
|
|
32
|
-
const data = await get(key, getStore(options?.service));
|
|
33
|
-
if (!data) {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
try {
|
|
37
|
-
const credential = await getCredential(deps)(data.credentialId, options?.relyingPartyID, options?.webAuthnUserVerification);
|
|
38
|
-
const credentialSeed = extractSeedFromCredential(credential);
|
|
39
|
-
const encryptionKey = deriveEncryptionKey(credentialSeed);
|
|
40
|
-
const authResultVal = decryptAuthResult(data, encryptionKey);
|
|
41
|
-
if (!authResultVal) {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
return {
|
|
45
|
-
key,
|
|
46
|
-
service: options?.service ?? "default",
|
|
47
|
-
value: authResultVal,
|
|
48
|
-
metadata: data.metadata,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
catch (_error) {
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
deleteItem: async (key, options) => {
|
|
56
|
-
await del(key, getStore(options?.service));
|
|
57
|
-
return true;
|
|
58
|
-
},
|
|
59
|
-
getAllItems: async (options) => {
|
|
60
|
-
const service = options?.service ?? "default";
|
|
61
|
-
const itemKeys = await keys(getStore(service));
|
|
62
|
-
const items = await Promise.all(itemKeys.map(async (key) => {
|
|
63
|
-
const data = await get(key, getStore(service));
|
|
64
|
-
return {
|
|
65
|
-
key,
|
|
66
|
-
service,
|
|
67
|
-
metadata: data?.metadata ?? createMetadata(),
|
|
68
|
-
...(options?.includeValues && data?.value
|
|
69
|
-
? { value: data.value }
|
|
70
|
-
: {}),
|
|
71
|
-
};
|
|
72
|
-
}));
|
|
73
|
-
return items;
|
|
74
|
-
},
|
|
75
|
-
clearService: async (options) => {
|
|
76
|
-
await clear(getStore(options?.service));
|
|
77
|
-
},
|
|
78
|
-
});
|
|
79
|
-
/**
|
|
80
|
-
* Create default metadata for backwards compatibility with items that don't
|
|
81
|
-
* have stored metadata.
|
|
82
|
-
*/
|
|
83
|
-
const createMetadata = (isSecure = true) => ({
|
|
84
|
-
backend: "keychain",
|
|
85
|
-
accessControl: isSecure ? "biometryCurrentSet" : "none",
|
|
86
|
-
securityLevel: isSecure ? "biometry" : "software",
|
|
87
|
-
timestamp: Date.now(),
|
|
88
|
-
});
|
|
89
|
-
/** Get storage key for owner ID. (supports namespaces via prefix) */
|
|
90
|
-
const getStore = (prefix = "default") => createStore(prefix, "evolu-auth");
|
|
91
|
-
const createCredential = (deps) => async (username, seed, relyingPartyID, relyingPartyName, userVerification, authenticatorAttachment) => {
|
|
92
|
-
const options = createCredentialCreationOptions(deps)(username, seed, relyingPartyID, relyingPartyName, userVerification, authenticatorAttachment);
|
|
93
|
-
const credential = (await navigator.credentials.create(options));
|
|
94
|
-
if (!credential) {
|
|
95
|
-
throw new Error("Failed to create WebAuthn credential");
|
|
96
|
-
}
|
|
97
|
-
return credential;
|
|
98
|
-
};
|
|
99
|
-
const getCredential = (deps) => async (credentialId, relyingPartyID, userVerification) => {
|
|
100
|
-
const options = createCredentialRequestOptions(deps)(credentialId, relyingPartyID, userVerification);
|
|
101
|
-
const credential = (await navigator.credentials.get(options));
|
|
102
|
-
if (!credential?.response) {
|
|
103
|
-
throw new Error("Failed to get WebAuthn credential");
|
|
104
|
-
}
|
|
105
|
-
return credential;
|
|
106
|
-
};
|
|
107
|
-
const extractSeedFromCredential = (credential) => {
|
|
108
|
-
const response = credential.response;
|
|
109
|
-
if (!response.userHandle) {
|
|
110
|
-
throw new Error("No userHandle in credential response");
|
|
111
|
-
}
|
|
112
|
-
return new Uint8Array(response.userHandle);
|
|
113
|
-
};
|
|
114
|
-
const createCredentialCreationOptions = (deps) => (username, seed, relyingPartyID, relyingPartyName, userVerification, authenticatorAttachment) => ({
|
|
115
|
-
publicKey: {
|
|
116
|
-
challenge: generateSeed(deps)(),
|
|
117
|
-
rp: {
|
|
118
|
-
id: relyingPartyID ?? document.location.hostname,
|
|
119
|
-
name: relyingPartyName ?? "Evolu",
|
|
120
|
-
},
|
|
121
|
-
user: {
|
|
122
|
-
id: seed,
|
|
123
|
-
name: username,
|
|
124
|
-
displayName: username,
|
|
125
|
-
},
|
|
126
|
-
pubKeyCredParams: [
|
|
127
|
-
// Ed25519
|
|
128
|
-
{ type: "public-key", alg: -8 },
|
|
129
|
-
// ES256
|
|
130
|
-
{ type: "public-key", alg: -7 },
|
|
131
|
-
// RS256
|
|
132
|
-
{ type: "public-key", alg: -257 },
|
|
133
|
-
],
|
|
134
|
-
attestation: "none",
|
|
135
|
-
authenticatorSelection: {
|
|
136
|
-
// - "platform": Uses the platform's built-in authenticator.
|
|
137
|
-
// - "cross-platform": Uses a device specific authenticator (yubikey, fido2, etc.)
|
|
138
|
-
authenticatorAttachment: authenticatorAttachment ?? "platform",
|
|
139
|
-
// - "discouraged": Only User Presence is needed.
|
|
140
|
-
// - "preferred": User Verification is preferred but not required. Falls back to User Presence.
|
|
141
|
-
// - "required": User Verification MUST occur (biometrics/PIN). Clients may silently downgrade to User Presence only.
|
|
142
|
-
userVerification: userVerification ?? "required",
|
|
143
|
-
// - "discouraged": Server-side credential is preferable, but will accept client-side discoverable credential.
|
|
144
|
-
// - "preferred": Relying Party strongly prefers client-side discoverable credential but will accept server-side credential.
|
|
145
|
-
// - "required": Client-side discoverable credential MUST be created, error if it can't be created.
|
|
146
|
-
residentKey: "required",
|
|
147
|
-
// Included for backwards compatibility. Deprecated in favor of residentKey (true = "required")
|
|
148
|
-
requireResidentKey: true,
|
|
149
|
-
},
|
|
150
|
-
},
|
|
151
|
-
});
|
|
152
|
-
const createCredentialRequestOptions = (deps) => (credentialId, relyingPartyID, userVerification) => ({
|
|
153
|
-
publicKey: {
|
|
154
|
-
challenge: generateSeed(deps)(),
|
|
155
|
-
rpId: relyingPartyID ?? document.location.hostname,
|
|
156
|
-
userVerification: userVerification ?? "preferred",
|
|
157
|
-
allowCredentials: [
|
|
158
|
-
{
|
|
159
|
-
type: "public-key",
|
|
160
|
-
id: base64UrlToUint8Array(Base64Url.orThrow(credentialId)),
|
|
161
|
-
},
|
|
162
|
-
],
|
|
163
|
-
},
|
|
164
|
-
});
|
|
165
|
-
const deriveEncryptionKey = (seed) => {
|
|
166
|
-
const seed32 = seed.length === 32 ? seed : seed.slice(0, 32);
|
|
167
|
-
return EncryptionKey.orThrow(createSlip21(seed32, ["evolu", "auth"]));
|
|
168
|
-
};
|
|
169
|
-
const encryptAuthResult = (deps) => (authResult, encryptionKey) => {
|
|
170
|
-
const plaintext = utf8ToBytes(JSON.stringify(authResult));
|
|
171
|
-
const [ciphertext, nonce] = encryptWithXChaCha20Poly1305(deps)(plaintext, encryptionKey);
|
|
172
|
-
return {
|
|
173
|
-
nonce: uint8ArrayToBase64Url(nonce),
|
|
174
|
-
ciphertext: uint8ArrayToBase64Url(ciphertext),
|
|
175
|
-
};
|
|
176
|
-
};
|
|
177
|
-
const decryptAuthResult = (encryptedData, encryptionKey) => {
|
|
178
|
-
const nonce = base64UrlToUint8Array(encryptedData.nonce);
|
|
179
|
-
const ciphertext = base64UrlToUint8Array(encryptedData.ciphertext);
|
|
180
|
-
const result = decryptWithXChaCha20Poly1305(XChaCha20Poly1305Ciphertext.orThrow(ciphertext), Entropy24.orThrow(nonce), encryptionKey);
|
|
181
|
-
if (!result.ok)
|
|
182
|
-
return null;
|
|
183
|
-
return bytesToUtf8(result.value);
|
|
184
|
-
};
|
|
185
|
-
const generateSeed = (deps) => () => deps.randomBytes.create(32);
|