@peerbit/react 0.0.13 → 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";
47
+ peer: ProgramClient | undefined;
48
+ promise: Promise<void> | undefined;
49
+ loading: boolean;
50
+ status: ConnectionStatus;
51
+ persisted: boolean | undefined;
52
+ /** Present only in proxy (iframe) mode */
53
+ targetOrigin: string;
54
+ }
55
+
56
+ export interface NodePeerContext {
57
+ type: "node";
34
58
  peer: ProgramClient | undefined;
35
59
  promise: Promise<void> | undefined;
36
60
  loading: boolean;
37
61
  status: ConnectionStatus;
38
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,147 +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
- connectionMonitor: {
181
- enabled: false,
182
- },
183
-
237
+ peerId,
238
+ connectionManager: { maxConnections: 100 },
239
+ connectionMonitor: { enabled: false },
184
240
  streamMuxers: [yamux()],
185
241
  ...(nodeOptions.network === "local"
186
242
  ? {
187
243
  connectionGater: {
188
- denyDialMultiaddr: () => {
189
- // by default we refuse to dial local addresses from the browser since they
190
- // are usually sent by remote peers broadcasting undialable multiaddrs but
191
- // here we are explicitly connecting to a local node so do not deny dialing
192
- // any discovered address
193
- return false;
194
- },
244
+ denyDialMultiaddr: () => false,
195
245
  },
196
246
  transports: [
197
- // Add websocket impl so we can connect to "unsafe" ws (production only allows wss)
198
- webSockets({
199
- filter: filters.all,
200
- }),
247
+ webSockets({ filter: filters.all }),
201
248
  circuitRelayTransport(),
202
- /* webRTC(), */ // TMP disable because flaky behaviour with libp2p 1.8.1
203
249
  ],
204
250
  }
205
251
  : {
206
252
  transports: [
207
253
  webSockets({ filter: filters.wss }),
208
254
  circuitRelayTransport(),
209
- /* webRTC(), */ // TMP disable because flaky behaviour with libp2p 1.8.1
210
255
  ],
211
256
  }),
212
-
213
257
  services: {
214
258
  pubsub: (c) =>
215
- new DirectSub(c, {
216
- canRelayMessage: true,
217
- /* connectionManager: {
218
- autoDial: false,
219
- }, */
220
- }),
259
+ new DirectSub(c, { canRelayMessage: true }),
221
260
  identify: identify(),
222
261
  },
223
262
  },
@@ -232,7 +271,6 @@ export const PeerProvider = (options: PeerOptions) => {
232
271
 
233
272
  setConnectionState("connecting");
234
273
 
235
- // Resolve bootstrap nodes async (we want to return before this is done)
236
274
  const connectFn = async () => {
237
275
  try {
238
276
  if (nodeOptions.network === "local") {
@@ -245,7 +283,6 @@ export const PeerProvider = (options: PeerOptions) => {
245
283
  ).text())
246
284
  );
247
285
  } else {
248
- // TODO fix types. When proxy client this will not be available
249
286
  if (nodeOptions.bootstrap) {
250
287
  for (const addr of nodeOptions.bootstrap) {
251
288
  await newPeer.dial(addr);
@@ -272,19 +309,28 @@ export const PeerProvider = (options: PeerOptions) => {
272
309
  promise.then(() => {
273
310
  console.log("Bootstrap done");
274
311
  });
275
- // Make sure data flow as expected between tabs and windows locally (offline states)
276
-
277
312
  if (nodeOptions.waitForConnnected !== false) {
278
313
  await promise;
279
314
  }
280
315
  } else {
281
- 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
+ );
282
320
  }
283
321
 
284
322
  setPeer(newPeer);
285
323
  setLoading(false);
286
324
  };
287
- 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());
288
334
  });
289
335
 
290
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
+ }