@peerbit/react 1.1.30 → 1.1.32

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/src/usePeer.tsx CHANGED
@@ -20,8 +20,10 @@ import { v4 as uuid } from "uuid";
20
20
  import { FastMutex } from "./lockstorage.ts";
21
21
  import {
22
22
  cookiesWhereClearedJustNow,
23
+ createWebLockClientId,
23
24
  getClientId,
24
25
  getFreeKeypair,
26
+ getFreeKeypairWithWebLock,
25
27
  inIframe,
26
28
  } from "./utils.ts";
27
29
 
@@ -245,8 +247,16 @@ export const PeerProvider = ({ config, children }: PeerProviderProps) => {
245
247
  React.useEffect(() => {
246
248
  let unmounted = false;
247
249
  let unloadUnsubscribe: (() => void) | undefined;
250
+ let identityUnloadUnsubscribe: (() => void) | undefined;
248
251
  let stopKeepAlive: (() => void) | undefined;
249
252
  let closePeer: (() => void) | undefined;
253
+ let releaseIdentityLock: (() => void) | undefined;
254
+ const startupAbortController = new AbortController();
255
+ const releaseIdentity = () => {
256
+ const release = releaseIdentityLock;
257
+ releaseIdentityLock = undefined;
258
+ release?.();
259
+ };
250
260
 
251
261
  const selected = resolveConfig(config);
252
262
  setRuntime(selected.runtime);
@@ -344,32 +354,44 @@ export const PeerProvider = ({ config, children }: PeerProviderProps) => {
344
354
  const keepAliveRef = { current: true } as { current: boolean };
345
355
 
346
356
  const releaseFirstLock = cookiesWhereClearedJustNow();
357
+ const webLockManager =
358
+ typeof navigator.locks?.request === "function"
359
+ ? navigator.locks
360
+ : undefined;
347
361
  const sessionId = getClientId("session");
348
- const mutex = new FastMutex({ clientId: sessionId, timeout: 1e3 });
362
+ const mutex = new FastMutex({
363
+ clientId: webLockManager
364
+ ? createWebLockClientId()
365
+ : sessionId,
366
+ timeout: 1e3,
367
+ });
349
368
 
350
369
  if (nodeOptions.singleton) {
370
+ const singletonMutex = webLockManager
371
+ ? new FastMutex({ clientId: sessionId, timeout: 1e3 })
372
+ : mutex;
351
373
  singletonLog("acquiring lock");
352
374
  const localId = getClientId("local");
353
375
  try {
354
376
  const lockKey = localId + "-singleton";
355
377
  const unsubscribeUnload = subscribeToUnload(() => {
356
378
  keepAliveRef.current = false;
357
- mutex.release(lockKey);
379
+ singletonMutex.release(lockKey);
358
380
  });
359
381
  const onVisibility = () => {
360
382
  if (document.visibilityState === "hidden") {
361
383
  keepAliveRef.current = false;
362
384
  try {
363
- mutex.release(lockKey);
385
+ singletonMutex.release(lockKey);
364
386
  } catch {}
365
387
  }
366
388
  };
367
389
  document.addEventListener("visibilitychange", onVisibility);
368
390
  if (isInStandaloneMode()) {
369
391
  keepAliveRef.current = false;
370
- mutex.release(lockKey);
392
+ singletonMutex.release(lockKey);
371
393
  }
372
- await mutex.lock(lockKey, () => keepAliveRef.current, {
394
+ await singletonMutex.lock(lockKey, () => keepAliveRef.current, {
373
395
  replaceIfSameClient: true,
374
396
  });
375
397
  singletonLog("lock acquired");
@@ -385,14 +407,44 @@ export const PeerProvider = ({ config, children }: PeerProviderProps) => {
385
407
  nodeId = nodeOptions.keypair;
386
408
  } else {
387
409
  keypairLog("acquiring lock");
388
- const kp = await getFreeKeypair("", mutex, () => keepAliveRef.current, {
389
- releaseFirstLock,
390
- releaseLockIfSameId: true,
391
- });
410
+ const kp = webLockManager
411
+ ? await getFreeKeypairWithWebLock(
412
+ "",
413
+ mutex,
414
+ webLockManager,
415
+ () => keepAliveRef.current,
416
+ startupAbortController.signal,
417
+ )
418
+ : await getFreeKeypair(
419
+ "",
420
+ mutex,
421
+ () => keepAliveRef.current,
422
+ {
423
+ releaseFirstLock,
424
+ releaseLockIfSameId: true,
425
+ },
426
+ );
392
427
  keypairLog("lock acquired", { index: kp.index });
393
- subscribeToUnload(() => {
428
+ const webLockRelease = (kp as { release?: () => void }).release;
429
+ releaseIdentityLock = webLockRelease
430
+ ? webLockRelease
431
+ : () => mutex.release(kp.path);
432
+ if (unmounted) {
433
+ releaseIdentity();
434
+ return;
435
+ }
436
+ identityUnloadUnsubscribe = subscribeToUnload(() => {
394
437
  keepAliveRef.current = false;
395
- mutex.release(kp.path);
438
+ if (!webLockManager) {
439
+ releaseIdentity();
440
+ }
441
+ if (closePeer) {
442
+ closePeer();
443
+ } else {
444
+ startupAbortController.abort(
445
+ new DOMException("Page unloaded", "AbortError"),
446
+ );
447
+ }
396
448
  });
397
449
  nodeId = kp.key;
398
450
  setTabIndex(kp.index);
@@ -403,16 +455,42 @@ export const PeerProvider = ({ config, children }: PeerProviderProps) => {
403
455
 
404
456
  let directory: string | undefined;
405
457
  if (!nodeOptions.inMemory && !(await detectIncognito()).isPrivate) {
406
- storageLog("requesting persist");
407
- const persistedValue = await navigator.storage.persist();
458
+ const storage = navigator.storage as StorageManager & {
459
+ getDirectory?: () => Promise<FileSystemDirectoryHandle>;
460
+ };
461
+ let persistedValue = false;
462
+ if (typeof storage?.persist === "function") {
463
+ storageLog("requesting persist");
464
+ try {
465
+ persistedValue = await storage.persist();
466
+ } catch (error) {
467
+ console.warn(
468
+ "Failed to request protection from browser storage eviction.",
469
+ error,
470
+ );
471
+ }
472
+ }
408
473
  setPersisted(persistedValue);
409
474
  persistedResolved = persistedValue;
410
- if (!persistedValue) {
475
+ if (typeof storage?.getDirectory === "function") {
476
+ try {
477
+ await storage.getDirectory();
478
+ directory = `./repo/${peerId.toString()}/`;
479
+ if (!persistedValue) {
480
+ console.warn(
481
+ "Browser storage is not protected from eviction; continuing with OPFS-backed storage.",
482
+ );
483
+ }
484
+ } catch (error) {
485
+ console.error(
486
+ "Origin-private file system storage could not be opened; falling back to in-memory storage.",
487
+ error,
488
+ );
489
+ }
490
+ } else {
411
491
  console.error(
412
- "Request persistence but permission was not granted by browser.",
492
+ "Origin-private file system storage is unavailable; falling back to in-memory storage.",
413
493
  );
414
- } else {
415
- directory = `./repo/${peerId.toString()}/`;
416
494
  }
417
495
  }
418
496
 
@@ -449,9 +527,18 @@ export const PeerProvider = ({ config, children }: PeerProviderProps) => {
449
527
  });
450
528
 
451
529
  newPeer = created as unknown as PeerbitLike;
530
+ let stopping: Promise<void> | undefined;
452
531
  closePeer = () => {
453
- keepAliveRef.current = false;
454
- void (created as any)?.stop?.().catch(() => {});
532
+ stopping ??= Promise.resolve()
533
+ .then(() => (created as any)?.stop?.())
534
+ .then(() => {
535
+ keepAliveRef.current = false;
536
+ releaseIdentity();
537
+ })
538
+ .catch((error) => {
539
+ stopping = undefined;
540
+ clientLog("stop failed; retaining identity lock", error);
541
+ });
455
542
  };
456
543
 
457
544
  (window as any).__peerInfo = {
@@ -584,9 +671,7 @@ export const PeerProvider = ({ config, children }: PeerProviderProps) => {
584
671
  }
585
672
 
586
673
  if (unmounted) {
587
- try {
588
- await (created as any)?.stop?.();
589
- } catch {}
674
+ closePeer();
590
675
  return;
591
676
  }
592
677
  setPeer(newPeer);
@@ -597,6 +682,14 @@ export const PeerProvider = ({ config, children }: PeerProviderProps) => {
597
682
  try {
598
683
  await fn();
599
684
  } catch (error: any) {
685
+ if (closePeer) {
686
+ closePeer();
687
+ } else {
688
+ releaseIdentity();
689
+ }
690
+ if (unmounted) {
691
+ return;
692
+ }
600
693
  setError(error);
601
694
  setConnectionState("failed");
602
695
  setLoading(false);
@@ -607,7 +700,11 @@ export const PeerProvider = ({ config, children }: PeerProviderProps) => {
607
700
  setPromise(p);
608
701
  return () => {
609
702
  unmounted = true;
703
+ startupAbortController.abort(
704
+ new DOMException("PeerProvider unmounted", "AbortError"),
705
+ );
610
706
  unloadUnsubscribe?.();
707
+ identityUnloadUnsubscribe?.();
611
708
  closePeer?.();
612
709
  };
613
710
  }, [config]);
package/src/utils.ts CHANGED
@@ -2,10 +2,12 @@ import { deserialize, serialize } from "@dao-xyz/borsh";
2
2
  import { Ed25519Keypair, fromBase64, toBase64 } from "@peerbit/crypto";
3
3
  import sodium from "libsodium-wrappers";
4
4
  import { v4 as uuid } from "uuid";
5
- import { FastMutex } from "./lockstorage.ts";
5
+ import { acquireWebLock, FastMutex, type WebLockLease } from "./lockstorage.ts";
6
6
 
7
7
  const CLIENT_ID_STORAGE_KEY = "CLIENT_ID";
8
8
  const ID_COUNTER_KEY = "idc/";
9
+ export const WEB_LOCK_CLIENT_ID_PREFIX = "@peerbit/react:web-lock/v1:";
10
+ const WEB_LOCK_NAME_PREFIX = "@peerbit/react:";
9
11
 
10
12
  const getKeyId = (prefix: string, id: number) => `${prefix}/${id}`;
11
13
 
@@ -33,6 +35,121 @@ export const releaseKey = (path: string, lock: FastMutex) => {
33
35
  lock.release(path);
34
36
  };
35
37
 
38
+ export const createWebLockClientId = () =>
39
+ `${WEB_LOCK_CLIENT_ID_PREFIX}${uuid()}`;
40
+
41
+ const isWebLockClientId = (clientId: string) =>
42
+ clientId.startsWith(WEB_LOCK_CLIENT_ID_PREFIX);
43
+
44
+ const getSelectionLockName = (id: string) =>
45
+ `${WEB_LOCK_NAME_PREFIX}keypair-selection:${JSON.stringify(id)}`;
46
+
47
+ const getKeypairLockName = (id: string, index: number) =>
48
+ `${WEB_LOCK_NAME_PREFIX}keypair:${JSON.stringify([id, index])}`;
49
+
50
+ const getAbortReason = (signal: AbortSignal) =>
51
+ signal.reason ?? new DOMException("Aborted", "AbortError");
52
+
53
+ const throwIfAborted = (signal?: AbortSignal) => {
54
+ if (signal?.aborted) {
55
+ throw getAbortReason(signal);
56
+ }
57
+ };
58
+
59
+ const waitForLegacyCounter = async (
60
+ key: string,
61
+ lock: FastMutex,
62
+ signal?: AbortSignal,
63
+ ) => {
64
+ const startedAt = Date.now();
65
+ while (true) {
66
+ throwIfAborted(signal);
67
+ const owners = lock.getLockedOwners(key);
68
+ if (owners.length === 0) {
69
+ return;
70
+ }
71
+ if (owners.every(isWebLockClientId)) {
72
+ lock.releaseIfOwnedBy(key, isWebLockClientId);
73
+ return;
74
+ }
75
+ if (Date.now() - startedAt >= lock.timeout * 2 + 50) {
76
+ throw new Error("Timed out waiting for the legacy key counter lock");
77
+ }
78
+ await new Promise((resolve) => setTimeout(resolve, 10));
79
+ }
80
+ };
81
+
82
+ const acquirePinnedKeypairLock = async ({
83
+ id,
84
+ index,
85
+ lock,
86
+ lockManager,
87
+ lockCondition,
88
+ signal,
89
+ }: {
90
+ id: string;
91
+ index: number;
92
+ lock: FastMutex;
93
+ lockManager: LockManager;
94
+ lockCondition: () => boolean;
95
+ signal?: AbortSignal;
96
+ }): Promise<WebLockLease | undefined> => {
97
+ const webLock = await acquireWebLock(
98
+ lockManager,
99
+ getKeypairLockName(id, index),
100
+ { ifAvailable: true, signal },
101
+ );
102
+ if (!webLock) {
103
+ return;
104
+ }
105
+
106
+ const key = getKeyId(id, index);
107
+ const owners = lock.getLockedOwners(key);
108
+ if (owners.some((owner) => !isWebLockClientId(owner))) {
109
+ webLock.release();
110
+ return;
111
+ }
112
+ lock.releaseIfOwnedBy(key, isWebLockClientId);
113
+
114
+ try {
115
+ await lock.lock(key, lockCondition);
116
+ throwIfAborted(signal);
117
+ lock.pin(key);
118
+ } catch (error) {
119
+ try {
120
+ lock.release(key);
121
+ } finally {
122
+ webLock.release();
123
+ }
124
+ throw error;
125
+ }
126
+
127
+ let released = false;
128
+ const release = () => {
129
+ if (released) return;
130
+ released = true;
131
+ signal?.removeEventListener("abort", release);
132
+ try {
133
+ lock.release(key);
134
+ } finally {
135
+ webLock.release();
136
+ }
137
+ };
138
+ signal?.addEventListener("abort", release, { once: true });
139
+ if (signal?.aborted) {
140
+ release();
141
+ throw getAbortReason(signal);
142
+ }
143
+
144
+ return {
145
+ release,
146
+ detachAbort: () => {
147
+ signal?.removeEventListener("abort", release);
148
+ webLock.detachAbort();
149
+ },
150
+ };
151
+ };
152
+
36
153
  export const getFreeKeypair = async (
37
154
  id = "",
38
155
  lock: FastMutex,
@@ -45,34 +162,112 @@ export const getFreeKeypair = async (
45
162
  await sodium.ready;
46
163
  const idCounterKey = ID_COUNTER_KEY + id;
47
164
  await lock.lock(idCounterKey, () => true);
48
- let idCounter = JSON.parse(localStorage.getItem(idCounterKey) || "0");
49
- for (let i = 0; i < 10000; i++) {
50
- const key = getKeyId(id, i);
51
- const lockedInfo = lock.getLockedInfo(key);
52
- if (lockedInfo) {
53
- if (
54
- (lockedInfo === lock.clientId && options?.releaseLockIfSameId) ||
55
- options?.releaseFirstLock
56
- ) {
57
- await lock.release(key);
58
- } else {
59
- continue;
165
+ try {
166
+ const idCounter = JSON.parse(localStorage.getItem(idCounterKey) || "0");
167
+ for (let i = 0; i < 10000; i++) {
168
+ const key = getKeyId(id, i);
169
+ const lockedInfo = lock.getLockedInfo(key);
170
+ if (lockedInfo) {
171
+ if (
172
+ lockedInfo === lock.clientId &&
173
+ options?.releaseLockIfSameId
174
+ ) {
175
+ lock.release(key);
176
+ } else if (options?.releaseFirstLock) {
177
+ lock.releaseIfOwnedBy(key, () => true);
178
+ } else {
179
+ continue;
180
+ }
60
181
  }
182
+
183
+ await lock.lock(key, lockCondition);
184
+ localStorage.setItem(
185
+ idCounterKey,
186
+ JSON.stringify(Math.max(idCounter, i + 1)),
187
+ );
188
+ return {
189
+ index: i,
190
+ path: key,
191
+ key: await getKeypair(key),
192
+ };
61
193
  }
194
+ throw new Error("Failed to resolve key");
195
+ } finally {
196
+ lock.release(idCounterKey);
197
+ }
198
+ };
62
199
 
63
- await lock.lock(key, lockCondition);
64
- localStorage.setItem(
65
- idCounterKey,
66
- JSON.stringify(Math.max(idCounter, i + 1)),
200
+ export const getFreeKeypairWithWebLock = async (
201
+ id: string,
202
+ lock: FastMutex,
203
+ lockManager: LockManager,
204
+ lockCondition: () => boolean = () => true,
205
+ signal?: AbortSignal,
206
+ ) => {
207
+ if (!isWebLockClientId(lock.clientId)) {
208
+ throw new Error(
209
+ "Web Lock key allocation requires a tagged mutex client ID",
67
210
  );
68
- await lock.release(idCounterKey);
69
- return {
70
- index: i,
71
- path: key,
72
- key: await getKeypair(key),
73
- };
74
211
  }
75
- throw new Error("Failed to resolve key");
212
+ await sodium.ready;
213
+ throwIfAborted(signal);
214
+
215
+ const selectionLock = await acquireWebLock(
216
+ lockManager,
217
+ getSelectionLockName(id),
218
+ { signal },
219
+ );
220
+ if (!selectionLock) {
221
+ throw new Error("Failed to acquire the keypair selection lock");
222
+ }
223
+
224
+ const idCounterKey = ID_COUNTER_KEY + id;
225
+ let counterLocked = false;
226
+ try {
227
+ await waitForLegacyCounter(idCounterKey, lock, signal);
228
+ await lock.lock(idCounterKey, () => true);
229
+ counterLocked = true;
230
+ const idCounter = JSON.parse(localStorage.getItem(idCounterKey) || "0");
231
+
232
+ for (let i = 0; i < 10000; i++) {
233
+ throwIfAborted(signal);
234
+ const selected = await acquirePinnedKeypairLock({
235
+ id,
236
+ index: i,
237
+ lock,
238
+ lockManager,
239
+ lockCondition,
240
+ signal,
241
+ });
242
+ if (!selected) {
243
+ continue;
244
+ }
245
+
246
+ const path = getKeyId(id, i);
247
+ try {
248
+ const key = await getKeypair(path);
249
+ throwIfAborted(signal);
250
+ localStorage.setItem(
251
+ idCounterKey,
252
+ JSON.stringify(Math.max(idCounter, i + 1)),
253
+ );
254
+ selected.detachAbort();
255
+ return { index: i, path, key, release: selected.release };
256
+ } catch (error) {
257
+ selected.release();
258
+ throw error;
259
+ }
260
+ }
261
+ throw new Error("Failed to resolve key");
262
+ } finally {
263
+ try {
264
+ if (counterLocked) {
265
+ lock.release(idCounterKey);
266
+ }
267
+ } finally {
268
+ selectionLock.release();
269
+ }
270
+ }
76
271
  };
77
272
 
78
273
  export const getAllKeyPairs = async (id = "") => {