@peerbit/react 0.0.12 → 0.0.14

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
@@ -5,7 +5,7 @@ import { DirectSub } from "@peerbit/pubsub";
5
5
  import { yamux } from "@chainsafe/libp2p-yamux";
6
6
  import {
7
7
  getFreeKeypair,
8
- getTabId,
8
+ getClientId,
9
9
  inIframe,
10
10
  cookiesWhereClearedJustNow,
11
11
  } from "./utils.js";
@@ -21,21 +21,46 @@ import { ProgramClient } from "@peerbit/program";
21
21
  import { identify } from "@libp2p/identify";
22
22
  import { webSockets } from "@libp2p/websockets";
23
23
  import { circuitRelayTransport } from "@libp2p/circuit-relay-v2";
24
-
25
24
  import * as filters from "@libp2p/websockets/filters";
26
25
  import { detectIncognito } from "detectincognitojs";
27
26
 
27
+ export class ClientBusyError extends Error {
28
+ constructor(message: string) {
29
+ super(message);
30
+ this.name = "CreateClientError";
31
+ }
32
+ }
33
+
28
34
  export type ConnectionStatus =
29
35
  | "disconnected"
30
36
  | "connected"
31
37
  | "connecting"
32
38
  | "failed";
33
- interface IPeerContext {
39
+
40
+ /** Discriminated union for PeerContext */
41
+ export type IPeerContext = (ProxyPeerContext | NodePeerContext) & {
42
+ error?: Error;
43
+ };
44
+
45
+ export interface ProxyPeerContext {
46
+ type: "proxy";
34
47
  peer: ProgramClient | undefined;
35
48
  promise: Promise<void> | undefined;
36
49
  loading: boolean;
37
50
  status: ConnectionStatus;
38
51
  persisted: boolean | undefined;
52
+ /** Present only in proxy (iframe) mode */
53
+ targetOrigin: string;
54
+ }
55
+
56
+ export interface NodePeerContext {
57
+ type: "node";
58
+ peer: ProgramClient | undefined;
59
+ promise: Promise<void> | undefined;
60
+ loading: boolean;
61
+ status: ConnectionStatus;
62
+ persisted: boolean | undefined;
63
+ tabIndex: number;
39
64
  }
40
65
 
41
66
  if (!window.name) {
@@ -44,6 +69,7 @@ if (!window.name) {
44
69
 
45
70
  export const PeerContext = React.createContext<IPeerContext>({} as any);
46
71
  export const usePeer = () => useContext(PeerContext);
72
+
47
73
  type IFrameOptions = {
48
74
  type: "proxy";
49
75
  targetOrigin: string;
@@ -56,15 +82,14 @@ type NodeOptions = {
56
82
  keypair?: Ed25519Keypair;
57
83
  bootstrap?: (Multiaddr | string)[];
58
84
  host?: boolean;
85
+ singleton?: boolean;
59
86
  };
60
- type TopOptions = NodeOptions & WithMemory;
87
+
88
+ type TopOptions = NodeOptions & { inMemory?: boolean };
61
89
  type TopAndIframeOptions = {
62
90
  iframe: IFrameOptions | NodeOptions;
63
91
  top: TopOptions;
64
92
  };
65
- type WithMemory = {
66
- inMemory?: boolean;
67
- };
68
93
  type WithChildren = {
69
94
  children: JSX.Element;
70
95
  };
@@ -77,144 +102,161 @@ export const PeerProvider = (options: PeerOptions) => {
77
102
  const [promise, setPromise] = React.useState<Promise<void> | undefined>(
78
103
  undefined
79
104
  );
80
-
81
105
  const [persisted, setPersisted] = React.useState<boolean | undefined>(
82
106
  undefined
83
107
  );
84
-
85
108
  const [loading, setLoading] = React.useState<boolean>(true);
86
109
  const [connectionState, setConnectionState] =
87
110
  React.useState<ConnectionStatus>("disconnected");
88
- const memo = React.useMemo<IPeerContext>(
89
- () => ({
90
- peer,
91
- promise,
92
- loading,
93
- connectionState,
94
- status: connectionState,
95
- persisted: persisted,
96
- }),
97
- [
98
- loading,
99
- !!promise,
100
- connectionState,
101
- peer?.identity?.publicKey?.hashcode(),
102
- persisted,
103
- ]
104
- );
111
+
112
+ const [tabIndex, setTabIndex] = React.useState<number>(-1);
113
+
114
+ const [error, setError] = React.useState<Error | undefined>(undefined); // <-- error state
115
+
116
+ // Decide which options to use based on whether we're in an iframe.
117
+ // If options.top is defined, assume we have separate settings for iframe vs. host.
118
+ const nodeOptions: IFrameOptions | TopOptions = (
119
+ options as TopAndIframeOptions
120
+ ).top
121
+ ? inIframe()
122
+ ? (options as TopAndIframeOptions).iframe
123
+ : { ...options, ...(options as TopAndIframeOptions).top } // we merge root and top options, TODO should this be made in a different way to prevent confusion about top props?
124
+ : (options as TopOptions);
125
+
126
+ // If running as a proxy (iframe), expect a targetOrigin.
127
+ const computedTargetOrigin =
128
+ nodeOptions.type === "proxy"
129
+ ? (nodeOptions as IFrameOptions).targetOrigin
130
+ : undefined;
131
+
132
+ const memo = React.useMemo<IPeerContext>(() => {
133
+ if (nodeOptions.type === "proxy") {
134
+ return {
135
+ type: "proxy",
136
+ peer,
137
+ promise,
138
+ loading,
139
+ status: connectionState,
140
+ persisted,
141
+ targetOrigin: computedTargetOrigin as string,
142
+ error,
143
+ };
144
+ } else {
145
+ return {
146
+ type: "node",
147
+ peer,
148
+ promise,
149
+ loading,
150
+ status: connectionState,
151
+ persisted,
152
+ tabIndex,
153
+ error,
154
+ };
155
+ }
156
+ }, [
157
+ loading,
158
+ promise,
159
+ connectionState,
160
+ peer,
161
+ persisted,
162
+ tabIndex,
163
+ computedTargetOrigin,
164
+ error,
165
+ ]);
105
166
 
106
167
  useMount(() => {
107
168
  setLoading(true);
108
169
  const fn = async () => {
109
170
  await sodium.ready;
110
- if (peer) {
111
- await peer.stop();
112
- setPeer(undefined);
113
- }
114
-
115
171
  let newPeer: ProgramClient;
116
- const nodeOptions = (options as TopAndIframeOptions).top
117
- ? inIframe()
118
- ? (options as TopAndIframeOptions).iframe
119
- : (options as TopAndIframeOptions).top
120
- : (options as TopOptions);
121
172
 
122
173
  if (nodeOptions.type !== "proxy") {
123
174
  const releaseFirstLock = cookiesWhereClearedJustNow();
124
- const nodeId =
125
- nodeOptions.keypair ||
126
- (
127
- await getFreeKeypair(
128
- "",
129
- new FastMutex({
130
- clientId: getTabId(),
131
- timeout: 1000,
132
- }),
133
- undefined,
134
- {
135
- releaseFirstLock, // when clearing cookies sometimes the localStorage is not cleared immediately so we need to release the lock forcefully. TODO investigate why this is happening
136
- releaseLockIfSameId: true, // reuse keypairs from same tab, (force release)
137
- }
138
- )
139
- ).key;
175
+
176
+ const sessionId = getClientId("session");
177
+ const mutex = new FastMutex({
178
+ clientId: sessionId,
179
+ timeout: 1e3,
180
+ });
181
+ if (nodeOptions.singleton) {
182
+ const localId = getClientId("local");
183
+ try {
184
+ const lockKey = localId + "-singleton";
185
+ globalThis.onbeforeunload = function () {
186
+ mutex.release(lockKey);
187
+ };
188
+ await mutex.lock(lockKey, () => true, {
189
+ replaceIfSameClient: true,
190
+ });
191
+ } catch (error) {
192
+ console.error("Failed to lock singleton client", error);
193
+ throw new ClientBusyError(
194
+ "Failed to lock single client"
195
+ );
196
+ }
197
+ }
198
+
199
+ let nodeId: Ed25519Keypair;
200
+ if (nodeOptions.keypair) {
201
+ nodeId = nodeOptions.keypair;
202
+ } else {
203
+ const kp = await getFreeKeypair("", mutex, undefined, {
204
+ releaseFirstLock,
205
+ releaseLockIfSameId: true,
206
+ });
207
+ globalThis.onbeforeunload = function () {
208
+ mutex.release(kp.path);
209
+ };
210
+ nodeId = kp.key;
211
+ setTabIndex(kp.index);
212
+ }
140
213
  const peerId = nodeId.toPeerId();
141
214
 
142
215
  let directory: string | undefined = undefined;
143
216
  if (
144
- !(nodeOptions as WithMemory).inMemory &&
217
+ !(nodeOptions as TopOptions).inMemory &&
145
218
  !(await detectIncognito()).isPrivate
146
219
  ) {
147
220
  const persisted = await navigator.storage.persist();
148
221
  setPersisted(persisted);
149
222
  if (!persisted) {
150
223
  setPersisted(false);
151
- if (window["chrome"]) {
152
- console.error(
153
- "Request persistance but was not given permission by browser. Adding this site to your bookmarks or enabling push notifications might allow your chrome browser to persist data"
154
- );
155
- } else {
156
- console.error(
157
- "Request persistance but was not given permission by browser."
158
- );
159
- }
224
+ console.error(
225
+ "Request persistence but permission was not granted by browser."
226
+ );
160
227
  } else {
161
228
  directory = `./repo/${peerId.toString()}/`;
162
229
  }
163
230
  }
164
231
 
165
- // We create a new directrory to make tab to tab communication go smoothly
166
232
  console.log("Create client");
167
233
  newPeer = await Peerbit.create({
168
234
  libp2p: {
169
- addresses: {
170
- listen: [
171
- "/p2p-circuit",
172
- /* "/webrtc" */
173
- ], // TMP disable because flaky behaviour with libp2p 1.8.1
174
- },
235
+ addresses: { listen: ["/p2p-circuit"] },
175
236
  connectionEncrypters: [noise()],
176
- peerId, //, having the same peer accross broswers does not work, only one tab will be recognized by other peers
177
- connectionManager: {
178
- maxConnections: 100,
179
- },
180
-
237
+ peerId,
238
+ connectionManager: { maxConnections: 100 },
239
+ connectionMonitor: { enabled: false },
181
240
  streamMuxers: [yamux()],
182
241
  ...(nodeOptions.network === "local"
183
242
  ? {
184
243
  connectionGater: {
185
- denyDialMultiaddr: () => {
186
- // by default we refuse to dial local addresses from the browser since they
187
- // are usually sent by remote peers broadcasting undialable multiaddrs but
188
- // here we are explicitly connecting to a local node so do not deny dialing
189
- // any discovered address
190
- return false;
191
- },
244
+ denyDialMultiaddr: () => false,
192
245
  },
193
246
  transports: [
194
- // Add websocket impl so we can connect to "unsafe" ws (production only allows wss)
195
- webSockets({
196
- filter: filters.all,
197
- }),
247
+ webSockets({ filter: filters.all }),
198
248
  circuitRelayTransport(),
199
- /* webRTC(), */ // TMP disable because flaky behaviour with libp2p 1.8.1
200
249
  ],
201
250
  }
202
251
  : {
203
252
  transports: [
204
253
  webSockets({ filter: filters.wss }),
205
254
  circuitRelayTransport(),
206
- /* webRTC(), */ // TMP disable because flaky behaviour with libp2p 1.8.1
207
255
  ],
208
256
  }),
209
-
210
257
  services: {
211
258
  pubsub: (c) =>
212
- new DirectSub(c, {
213
- canRelayMessage: true,
214
- /* connectionManager: {
215
- autoDial: false,
216
- }, */
217
- }),
259
+ new DirectSub(c, { canRelayMessage: true }),
218
260
  identify: identify(),
219
261
  },
220
262
  },
@@ -229,7 +271,6 @@ export const PeerProvider = (options: PeerOptions) => {
229
271
 
230
272
  setConnectionState("connecting");
231
273
 
232
- // Resolve bootstrap nodes async (we want to return before this is done)
233
274
  const connectFn = async () => {
234
275
  try {
235
276
  if (nodeOptions.network === "local") {
@@ -242,7 +283,6 @@ export const PeerProvider = (options: PeerOptions) => {
242
283
  ).text())
243
284
  );
244
285
  } else {
245
- // TODO fix types. When proxy client this will not be available
246
286
  if (nodeOptions.bootstrap) {
247
287
  for (const addr of nodeOptions.bootstrap) {
248
288
  await newPeer.dial(addr);
@@ -269,19 +309,28 @@ export const PeerProvider = (options: PeerOptions) => {
269
309
  promise.then(() => {
270
310
  console.log("Bootstrap done");
271
311
  });
272
- // Make sure data flow as expected between tabs and windows locally (offline states)
273
-
274
312
  if (nodeOptions.waitForConnnected !== false) {
275
313
  await promise;
276
314
  }
277
315
  } else {
278
- newPeer = await createClient(nodeOptions.targetOrigin);
316
+ // When in proxy mode (iframe), use the provided targetOrigin.
317
+ newPeer = await createClient(
318
+ (nodeOptions as IFrameOptions).targetOrigin
319
+ );
279
320
  }
280
321
 
281
322
  setPeer(newPeer);
282
323
  setLoading(false);
283
324
  };
284
- setPromise(fn());
325
+ const fnWithErrorHandling = async () => {
326
+ try {
327
+ await fn();
328
+ } catch (error: any) {
329
+ setError(error);
330
+ setLoading(false);
331
+ }
332
+ };
333
+ setPromise(fnWithErrorHandling());
285
334
  });
286
335
 
287
336
  return (
@@ -1,17 +1,14 @@
1
1
  import { Program, OpenOptions, ProgramEvents } from "@peerbit/program";
2
2
  import { usePeer } from "./usePeer.js";
3
+ import { PublicSignKey } from "@peerbit/crypto";
3
4
  import { useEffect, useReducer, useRef, useState } from "react";
4
- const addressOrUndefined = <
5
- A,
6
- B extends ProgramEvents,
7
- P extends Program<A, B>
8
- >(
5
+ const addressOrDefined = <A, B extends ProgramEvents, P extends Program<A, B>>(
9
6
  p?: P
10
7
  ) => {
11
8
  try {
12
9
  return p?.address;
13
10
  } catch (error) {
14
- return undefined;
11
+ return !!p;
15
12
  }
16
13
  };
17
14
  type ExtractArgs<T> = T extends Program<infer Args> ? Args : never;
@@ -22,16 +19,20 @@ export const useProgram = <
22
19
  Program<any, ProgramEvents>
23
20
  >(
24
21
  addressOrOpen?: P | string,
25
- options?: OpenOptions<P>
22
+ options?: OpenOptions<P> & { id?: string; keepOpenOnUnmount?: boolean }
26
23
  ) => {
27
24
  const { peer } = usePeer();
28
25
  let [program, setProgram] = useState<P | undefined>();
26
+ const [id, setId] = useState<string | undefined>(options?.id);
29
27
  let [loading, setLoading] = useState(true);
30
28
  const [session, forceUpdate] = useReducer((x) => x + 1, 0);
31
29
  let programLoadingRef = useRef<Promise<P>>();
32
- const [peerCounter, setPeerCounter] = useState<number>(1);
33
- let closingRef = useRef<Promise<any>>(Promise.resolve());
30
+ const [peers, setPeers] = useState<PublicSignKey[]>([]);
34
31
 
32
+ let closingRef = useRef<Promise<any>>(Promise.resolve());
33
+ /* if (options?.debug) {
34
+ console.log("useProgram", addressOrOpen, options);
35
+ } */
35
36
  useEffect(() => {
36
37
  if (!peer || !addressOrOpen) {
37
38
  return;
@@ -45,19 +46,29 @@ export const useProgram = <
45
46
  .then((p) => {
46
47
  changeListener = () => {
47
48
  p.getReady().then((set) => {
48
- setPeerCounter(set.size);
49
+ setPeers([...set.values()]);
49
50
  });
50
51
  };
51
52
  p.events.addEventListener("join", changeListener);
52
53
  p.events.addEventListener("leave", changeListener);
53
- p.getReady().then((set) => {
54
- setPeerCounter(set.size);
55
- });
54
+ p.getReady()
55
+ .then((set) => {
56
+ setPeers([...set.values()]);
57
+ })
58
+ .catch((e) => {
59
+ console.log("Error getReady()", e);
60
+ });
56
61
  setProgram(p);
57
62
  forceUpdate();
58
-
63
+ if (options?.id) {
64
+ setId(p.address);
65
+ }
59
66
  return p;
60
67
  })
68
+ .catch((e) => {
69
+ console.error("failed to open", e);
70
+ throw e;
71
+ })
61
72
  .finally(() => {
62
73
  setLoading(false);
63
74
  });
@@ -70,8 +81,8 @@ export const useProgram = <
70
81
  // TODO don't close on reopen the same db?
71
82
  if (programLoadingRef.current) {
72
83
  closingRef.current =
73
- programLoadingRef.current.then((p) =>
74
- p.close().then(() => {
84
+ programLoadingRef.current.then((p) => {
85
+ const unsubscribe = () => {
75
86
  p.events.removeEventListener(
76
87
  "join",
77
88
  changeListener
@@ -85,21 +96,27 @@ export const useProgram = <
85
96
  setProgram(undefined);
86
97
  programLoadingRef.current = undefined;
87
98
  }
88
- })
89
- ) || Promise.resolve();
99
+ };
100
+ if (options?.keepOpenOnUnmount) {
101
+ return unsubscribe();
102
+ }
103
+ return p.close().then(unsubscribe);
104
+ }) || Promise.resolve();
90
105
  }
91
106
  };
92
107
  }, [
93
108
  peer?.identity.publicKey.hashcode(),
109
+ options?.id,
94
110
  typeof addressOrOpen === "string"
95
111
  ? addressOrOpen
96
- : addressOrUndefined(addressOrOpen),
112
+ : addressOrDefined(addressOrOpen),
97
113
  ]);
98
114
  return {
99
115
  program,
100
116
  session,
101
117
  loading,
102
118
  promise: programLoadingRef.current,
103
- peerCounter,
119
+ peers,
120
+ id,
104
121
  };
105
122
  };
package/src/utils.ts CHANGED
@@ -4,7 +4,7 @@ import { FastMutex } from "./lockstorage";
4
4
  import { v4 as uuid } from "uuid";
5
5
  import sodium from "libsodium-wrappers";
6
6
 
7
- const TAB_ID_KEY = "TAB_ID";
7
+ const CLIENT_ID_STORAGE_KEY = "CLIENT_ID";
8
8
  export const cookiesWhereClearedJustNow = () => {
9
9
  const lastPersistedAt = localStorage.getItem("lastPersistedAt");
10
10
  if (lastPersistedAt) {
@@ -14,13 +14,14 @@ export const cookiesWhereClearedJustNow = () => {
14
14
  return true;
15
15
  };
16
16
 
17
- export const getTabId = () => {
18
- const idFromStorage = sessionStorage.getItem(TAB_ID_KEY);
17
+ export const getClientId = (type: "session" | "local") => {
18
+ const storage = type === "session" ? sessionStorage : localStorage;
19
+ const idFromStorage = storage.getItem(CLIENT_ID_STORAGE_KEY);
19
20
  if (idFromStorage) {
20
21
  return idFromStorage;
21
22
  } else {
22
23
  const id = uuid(); // generate unique UUID
23
- sessionStorage.setItem(TAB_ID_KEY, id);
24
+ storage.setItem(CLIENT_ID_STORAGE_KEY, id);
24
25
  return id;
25
26
  }
26
27
  };
@@ -29,16 +30,13 @@ const ID_COUNTER_KEY = "idc/";
29
30
 
30
31
  const getKeyId = (prefix: string, id: number) => prefix + "/" + id;
31
32
 
32
- export const releaseKey = (
33
- path: string,
34
- lock: FastMutex = new FastMutex({ clientId: getTabId() })
35
- ) => {
33
+ export const releaseKey = (path: string, lock: FastMutex) => {
36
34
  lock.release(path);
37
35
  };
38
36
 
39
37
  export const getFreeKeypair = async (
40
38
  id: string = "",
41
- lock: FastMutex = new FastMutex({ clientId: getTabId() }),
39
+ lock: FastMutex,
42
40
  lockCondition: () => boolean = () => true,
43
41
  options?: {
44
42
  releaseLockIfSameId?: boolean;
@@ -52,16 +50,16 @@ export const getFreeKeypair = async (
52
50
  for (let i = 0; i < 10000; i++) {
53
51
  const key = getKeyId(id, i);
54
52
  let lockedInfo = lock.getLockedInfo(key);
55
- console.log(
56
- "KEY KEY AT",
57
- key,
58
- id,
59
- i,
60
- lockedInfo,
61
- lockedInfo === lock.clientId,
62
- options
63
- );
64
-
53
+ /* console.log(
54
+ "KEY KEY AT",
55
+ key,
56
+ id,
57
+ i,
58
+ lockedInfo,
59
+ lockedInfo === lock.clientId,
60
+ options
61
+ );
62
+ */
65
63
  if (lockedInfo) {
66
64
  if (
67
65
  (lockedInfo === lock.clientId &&
@@ -73,7 +71,7 @@ export const getFreeKeypair = async (
73
71
  continue;
74
72
  }
75
73
  }
76
- console.log("aquire id at", i);
74
+
77
75
  await lock.lock(key, lockCondition);
78
76
 
79
77
  localStorage.setItem(
@@ -82,6 +80,7 @@ export const getFreeKeypair = async (
82
80
  );
83
81
  await lock.release(idCounterKey);
84
82
  return {
83
+ index: i,
85
84
  path: key,
86
85
  key: await getKeypair(key),
87
86
  };
@@ -141,3 +140,59 @@ export const inIframe = () => {
141
140
  return true;
142
141
  }
143
142
  };
143
+
144
+ export function debounceLeadingTrailing<
145
+ T extends (this: any, ...args: any[]) => void
146
+ >(
147
+ func: T,
148
+ delay: number
149
+ ): ((this: ThisParameterType<T>, ...args: Parameters<T>) => void) & {
150
+ cancel: () => void;
151
+ } {
152
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
153
+ let lastArgs: Parameters<T> | null = null;
154
+ let lastThis: any;
155
+ let pendingTrailing = false;
156
+
157
+ const debounced = function (
158
+ this: ThisParameterType<T>,
159
+ ...args: Parameters<T>
160
+ ) {
161
+ if (!timeoutId) {
162
+ // Leading call: no timer means this is the first call in this period.
163
+ func.apply(this, args);
164
+ } else {
165
+ // Subsequent calls during the delay mark that a trailing call is needed.
166
+ pendingTrailing = true;
167
+ }
168
+ // Always update with the most recent context and arguments.
169
+ lastArgs = args;
170
+ lastThis = this;
171
+
172
+ // Reset the timer.
173
+ if (timeoutId) {
174
+ clearTimeout(timeoutId);
175
+ }
176
+ timeoutId = setTimeout(() => {
177
+ timeoutId = null;
178
+ // If there were any calls during the delay, call the function on the trailing edge.
179
+ if (pendingTrailing && lastArgs) {
180
+ func.apply(lastThis, lastArgs);
181
+ }
182
+ // Reset the trailing flag after the trailing call.
183
+ pendingTrailing = false;
184
+ }, delay);
185
+ } as ((this: ThisParameterType<T>, ...args: Parameters<T>) => void) & {
186
+ cancel: () => void;
187
+ };
188
+
189
+ debounced.cancel = () => {
190
+ if (timeoutId) {
191
+ clearTimeout(timeoutId);
192
+ timeoutId = null;
193
+ }
194
+ pendingTrailing = false;
195
+ };
196
+
197
+ return debounced;
198
+ }