@hive-p2p/server 1.0.19 → 1.0.21

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.
@@ -0,0 +1,252 @@
1
+ import { CLOCK, SIMULATION, NODE, DISCOVERY, LOG_CSS } from './config.mjs';
2
+ const { SANDBOX, ICE_CANDIDATE_EMITTER, TEST_WS_EVENT_MANAGER } = SIMULATION.ENABLED ? await import('../simulation/test-transports.mjs') : {};
3
+
4
+ export class KnownPeer { // known peer, not necessarily connected
5
+ neighbors; connectionsCount;
6
+
7
+ /** CAUTION: Call this one only in PeerStore.unlinkPeers() @param {Record<string, number>} neighbors key: peerId, value: timestamp */
8
+ constructor(neighbors = {}) { this.neighbors = neighbors; this.connectionsCount = Object.keys(neighbors).length; }
9
+
10
+ /** Set or update neighbor @param {string} peerId @param {number} [timestamp] */
11
+ setNeighbor(peerId, timestamp = CLOCK.time) {
12
+ if (!this.neighbors[peerId]) this.connectionsCount++;
13
+ this.neighbors[peerId] = timestamp;
14
+ }
15
+ /** Unset neighbor @param {string} peerId */
16
+ unsetNeighbor(peerId) {
17
+ if (this.neighbors[peerId]) this.connectionsCount--;
18
+ delete this.neighbors[peerId];
19
+ }
20
+ }
21
+
22
+ export class PeerConnection { // WebSocket or WebRTC connection wrapper
23
+ peerId; transportInstance; isWebSocket; direction; pendingUntil; connStartTime;
24
+
25
+ /** Connection to a peer, can be WebSocket or WebRTC, can be connecting or connected
26
+ * @param {string} peerId
27
+ * @param {import('simple-peer').Instance | import('ws').WebSocket} transportInstance
28
+ * @param {'in' | 'out'} direction @param {boolean} [isWebSocket] default: false */
29
+ constructor(peerId, transportInstance, direction, isWebSocket = false) {
30
+ this.peerId = peerId; this.transportInstance = transportInstance;
31
+ this.isWebSocket = isWebSocket; this.direction = direction;
32
+ this.pendingUntil = CLOCK.time + NODE.CONNECTION_UPGRADE_TIMEOUT;
33
+ }
34
+ setConnected() { this.connStartTime = CLOCK.time; this.pendingUntil = 0; }
35
+ getConnectionDuration() { return this.connStartTime ? CLOCK.time - this.connStartTime : 0; }
36
+ close() { this.isWebSocket ? this.transportInstance?.close() : this.transportInstance?.destroy(); }
37
+ }
38
+
39
+ /** @typedef {{ in: PeerConnection, out: PeerConnection }} PeerConnecting */
40
+ export class PeerStore { // Manages all peers informations and connections (WebSocket and WebRTC)
41
+ id; cryptoCodex; offerManager; arbiter; verbose; isDestroy = false;
42
+
43
+ /** @type {string[]} The neighbors IDs */ neighborsList = []; // faster access
44
+ /** @type {Record<string, PeerConnecting>} */ connecting = {};
45
+ /** @type {Record<string, PeerConnection>} */ connected = {};
46
+ /** @type {Record<string, KnownPeer>} */ known = {}; // known peers store
47
+ /** @type {number} */ knownCount = 0;
48
+ /** @type {Record<string, number>} */ kick = {}; // peerId, timestamp until kick expires
49
+ /** @type {Record<string, Function[]>} */ callbacks = {
50
+ 'connect': [(peerId, direction) => this.#handleConnect(peerId, direction)],
51
+ 'disconnect': [(peerId, direction) => this.#handleDisconnect(peerId, direction)],
52
+ 'signal': [],
53
+ 'data': []
54
+ };
55
+
56
+ /** @param {string} selfId @param {import('./crypto-codex.mjs').CryptoCodex} cryptoCodex @param {import('./ice-offer-manager.mjs').OfferManager} offerManager @param {import('./arbiter.mjs').Arbiter} arbiter @param {number} [verbose] default: 0 */
57
+ constructor(selfId, cryptoCodex, offerManager, arbiter, verbose = 0) { // SETUP SDP_OFFER_MANAGER CALLBACKS
58
+ this.id = selfId;
59
+ this.cryptoCodex = cryptoCodex;
60
+ this.offerManager = offerManager;
61
+ this.arbiter = arbiter;
62
+ this.verbose = verbose;
63
+
64
+ /** @param {string} remoteId @param {any} signalData @param {string} [offerHash] answer only */
65
+ this.offerManager.onSignalAnswer = (remoteId, signalData, offerHash) => { // answer only
66
+ if (this.isDestroy || this.isKicked(remoteId) || this.arbiter.isBanished(remoteId)) return; // not accepted
67
+ for (const cb of this.callbacks.signal) cb(remoteId, { signal: signalData, offerHash });
68
+ };
69
+ /** @param {string | undefined} remoteId @param {import('simple-peer').Instance} instance */
70
+ this.offerManager.onConnect = (remoteId, instance) => {
71
+ if (this.isDestroy) return instance?.destroy();
72
+ if (remoteId === this.id) throw new Error(`Refusing to connect to self (${this.id}).`);
73
+
74
+ let peerId = remoteId;
75
+ instance.on('close', () => { if (peerId) for (const cb of this.callbacks.disconnect) cb(peerId, instance.initiator ? 'out' : 'in'); });
76
+ instance.on('data', data => {
77
+ if (peerId) for (const cb of this.callbacks.data) cb(peerId, data);
78
+ else { // FIRST MESSAGE SHOULD BE HANDSHAKE WITH ID
79
+ const d = new Uint8Array(data); if (d[0] > 127) return; // not unicast, ignore
80
+ const { route, type, neighborsList } = cryptoCodex.readUnicastMessage(d) || {};
81
+ if (type !== 'handshake' || route.length !== 2 || route[1] !== this.id) return;
82
+
83
+ peerId = route[0];
84
+ this.digestPeerNeighbors(peerId, neighborsList); // Update known store
85
+ for (const cb of this.callbacks.connect) cb(peerId, 'out');
86
+ }
87
+ });
88
+ // IF WE KNOW PEER ID, WE CAN LINK IT (SHOULD BE IN CONNECTING)
89
+ if (remoteId) for (const cb of this.callbacks.connect) cb(remoteId, 'in');
90
+ };
91
+ }
92
+
93
+ // GETTERS
94
+ get publicNeighborsList() { return this.neighborsList.filter(id => this.cryptoCodex.isPublicNode(id)); }
95
+ get standardNeighborsList() { return this.neighborsList.filter(id => !this.cryptoCodex.isPublicNode(id)); }
96
+
97
+ // PRIVATE METHODS
98
+ /** @param {string} peerId @param {'in' | 'out'} direction */
99
+ #handleConnect(peerId, direction) { // First callback assigned in constructor
100
+ if (!this.connecting[peerId]?.[direction]) return this.verbose >= 3 ? console.info(`%cPeer with ID ${peerId} is not connecting.`, LOG_CSS.PEER_STORE) : null;
101
+
102
+ const peerConn = this.connecting[peerId][direction];
103
+ this.#removePeer(peerId, 'connecting', direction); // remove from connecting now, we are connected or will fail
104
+ if (this.isKicked(peerId)) {
105
+ if (this.verbose >= 3) console.info(`%c(${this.id}) Connect => Peer with ID ${peerId} is kicked. => close()`, LOG_CSS.PEER_STORE);
106
+ return peerConn.close();
107
+ }
108
+
109
+ if (this.connected[peerId]) {
110
+ if (this.verbose > 1) console.warn(`%c(${this.id}) Connect => Peer with ID ${peerId} is already connected. => close()`, LOG_CSS.PEER_STORE);
111
+ return peerConn.close();
112
+ }
113
+
114
+ peerConn.setConnected(); // set connStartTime
115
+ this.connected[peerId] = peerConn;
116
+ this.neighborsList.push(peerId);
117
+ this.#linkPeers(this.id, peerId); // Add link in self store
118
+ //if (this.verbose > (this.cryptoCodex.isPublicNode(peerId) ? 3 : 2)) console.log(`(${this.id}) ${direction === 'in' ? 'Incoming' : 'Outgoing'} ${peerConn.isWebSocket ? 'WebSocket' : 'WRTC'} connection established with peer ${peerId}`);
119
+ }
120
+ #handleDisconnect(peerId, direction) { // First callback assigned in constructor
121
+ this.#removePeer(peerId, 'connected', direction);
122
+ }
123
+ /** Remove a peer from our connections, and unlink from known store
124
+ * @param {string} remoteId @param {'connected' | 'connecting' | 'both'} [status] default: both @param {'in' | 'out' | 'both'} [direction] default: both */
125
+ #removePeer(remoteId, status = 'both', direction = 'both') {
126
+ if (!remoteId && remoteId === this.id) return;
127
+
128
+ const [ connectingConns, connectedConn ] = [ this.connecting[remoteId], this.connected[remoteId] ];
129
+ //if (connectingConns && connectedConn) throw new Error(`Peer ${remoteId} is both connecting and connected.`);
130
+ if (!connectingConns && !connectedConn) return;
131
+
132
+ // use negation to apply to 'both' too
133
+ if (status !== 'connecting' && (connectedConn?.direction === direction || direction === 'both')) {
134
+ connectedConn?.close();
135
+ delete this.connected[remoteId];
136
+ this.neighborsList = Object.keys(this.connected);
137
+ this.#unlinkPeers(this.id, remoteId); // Remove link in self known store
138
+ }
139
+ if (status === 'connected') return; // only remove connected
140
+
141
+ const directionToRemove = direction === 'both' ? ['out', 'in'] : [direction];
142
+ for (const dir of directionToRemove) delete connectingConns?.[dir];
143
+
144
+ if (this.connecting[remoteId]?.['in'] || this.connecting[remoteId]?.['out']) return;
145
+ delete this.connecting[remoteId]; // no more connection direction => remove entirely
146
+ }
147
+ /** Associate two peers as neighbors in known store */
148
+ #linkPeers(peerId1 = 'toto', peerId2 = 'tutu') {
149
+ for (const pid of [peerId1, peerId2]) {
150
+ if (!this.known[pid]) { this.known[pid] = new KnownPeer(); this.knownCount++; }
151
+ this.known[pid].setNeighbor(pid === peerId1 ? peerId2 : peerId1); // set/update neighbor
152
+ }
153
+ }
154
+ /** Unassociate two peers and remove them from known store if they have no more connections */
155
+ #unlinkPeers(peerId1 = 'toto', peerId2 = 'tutu') {
156
+ for (const pid of [peerId1, peerId2]) {
157
+ if (!this.known[pid]) continue;
158
+ this.known[pid].unsetNeighbor(pid === peerId1 ? peerId2 : peerId1);
159
+ if (this.known[pid].connectionsCount > 0) continue;
160
+ delete this.known[pid];
161
+ this.knownCount--;
162
+ }
163
+ }
164
+ cleanupExpired(andUpdateKnownBasedOnNeighbors = true) { // Clean up expired pending connections and pending links
165
+ const now = CLOCK.time;
166
+ for (const dir of ['in', 'out'])
167
+ for (const peerId in this.connecting) {
168
+ if (!this.connecting[peerId][dir]) continue;
169
+ const bonusTime = this.connected[peerId] ? 10000 : 0; // give some extra time if we are already connected to this peer
170
+ if (this.connecting[peerId][dir].pendingUntil + bonusTime > now) continue;
171
+ if (this.verbose >= 4 && !this.connected[peerId]) console.info(`%c(${this.id}) Pending ${dir} connection to peer ${peerId} expired.`, LOG_CSS.PEER_STORE);
172
+ if (this.verbose > 0 && this.connected[peerId]?.direction === dir) console.info(`%c(${this.id}) Pending ${dir} connection to peer ${peerId} expired (already connected WARNING!).`, 'color: white;');
173
+ //if (!this.connecting[peerId]?.in?.isWebSocket) this.connecting[peerId]?.in?.close(); // close only in connection => out conn can be used by others answers
174
+ //else this.connecting[peerId]?.close();
175
+ if (this.connecting[peerId]?.out?.isWebSocket) this.connecting[peerId].out.close();
176
+ this.connecting[peerId]?.in?.close();
177
+ this.#removePeer(peerId, 'connecting', dir);
178
+ }
179
+
180
+ if (!andUpdateKnownBasedOnNeighbors) return;
181
+ this.neighborsList = Object.keys(this.connected);
182
+ this.digestPeerNeighbors(this.id, this.neighborsList); // Update self known store
183
+ }
184
+
185
+ // API
186
+ /** @param {string} callbackType @param {Function} callback */
187
+ on(callbackType, callback) {
188
+ if (!this.callbacks[callbackType]) throw new Error(`Unknown callback type: ${callbackType}`);
189
+ this.callbacks[callbackType].push(callback);
190
+ }
191
+ /** Cleanup expired neighbors and return the updated connections count @param {string} peerId */
192
+ getUpdatedPeerConnectionsCount(peerId, includesPublic = true) {
193
+ const time = CLOCK.time; let count = 0;
194
+ const peerNeighbors = this.known[peerId]?.neighbors || {};
195
+ for (const id in peerNeighbors) {// clean expired links (except self and non-expired)
196
+ if (id !== this.id && time - peerNeighbors[id] > DISCOVERY.PEER_LINK_EXPIRATION) {
197
+ this.#unlinkPeers(peerId, id);
198
+ continue;
199
+ }
200
+ if (includesPublic) count++;
201
+ else if (!this.cryptoCodex.isPublicNode(id)) count++;
202
+ }
203
+ return count;
204
+ }
205
+ /** Initialize/Get a connecting peer WebRTC connection (SimplePeer Instance)
206
+ * @param {string} remoteId @param {{type: 'offer' | 'answer', sdp: Record<string, string>}} signal
207
+ * @param {string} [offerHash] offer only */
208
+ addConnectingPeer(remoteId, signal, offerHash) {
209
+ if (remoteId === this.id) throw new Error(`Refusing to connect to self (${this.id}).`);
210
+
211
+ const direction = signal.type === 'offer' ? 'in' : 'out';
212
+ if (this.connecting[remoteId]?.[direction]) return; // already connecting out (should not happen)
213
+
214
+ const instance = this.offerManager.getTransportInstanceForSignal(remoteId, signal, offerHash);
215
+ if (!instance) return this.verbose > 3 ? console.info(`%cFailed to get transport instance for signal from ${remoteId}.`, LOG_CSS.PEER_STORE) : null;
216
+
217
+ if (!this.connecting[remoteId]) this.connecting[remoteId] = {};
218
+ this.connecting[remoteId][direction] = new PeerConnection(remoteId, instance, direction);
219
+ return true;
220
+ }
221
+ /** @param {string} remoteId @param {{type: 'offer' | 'answer', sdp: Record<string, string>}} signal @param {string} [offerHash] answer only @param {number} timestamp Answer reception timestamp */
222
+ assignSignal(remoteId, signal, offerHash, timestamp) {
223
+ const peerConn = this.connecting[remoteId]?.[signal.type === 'offer' ? 'in' : 'out'];
224
+ try {
225
+ if (peerConn?.isWebSocket) throw new Error(`Cannot assign signal for ID ${remoteId}. (WebSocket)`);
226
+ if (signal.type === 'answer') this.offerManager.addSignalAnswer(remoteId, signal, offerHash, timestamp);
227
+ else peerConn.transportInstance.signal(signal);
228
+ } catch (error) { console.error(`Error signaling ${signal?.type} for ${remoteId}:`, error.stack); }
229
+ }
230
+ /** Avoid peer connection @param {string} peerId @param {number} duration default: 60_000ms @param {string} [reason] */
231
+ kickPeer(peerId, duration = 60_000, reason) {
232
+ if (duration) this.kick[peerId] = CLOCK.time + duration;
233
+ this.#removePeer(peerId, 'both');
234
+ if (this.verbose > 1) console.log(`%c(${this.id}) Kicked peer ${peerId} for ${duration / 1000}s. ${reason ? '| Reason: ' + reason : ''}`, 'color: green;');
235
+ }
236
+ isKicked(peerId) { return this.kick[peerId] && this.kick[peerId] > CLOCK.time; }
237
+ /** Improve discovery by considering used route as peer links @param {string[]} route */
238
+ digestValidRoute(route = []) { for (let i = 1; i < route.length; i++) this.#linkPeers(route[i - 1], route[i]); }
239
+ /** @param {string} peerId @param {string[]} neighbors */
240
+ digestPeerNeighbors(peerId, neighbors = []) { // Update known neighbors
241
+ if (!peerId || !Array.isArray(neighbors)) return;
242
+ for (const id in this.known[peerId]?.neighbors || {}) // remove old links
243
+ if (!neighbors.includes(id)) this.#unlinkPeers(peerId, id);
244
+ for (const p of neighbors) this.#linkPeers(peerId, p);
245
+ }
246
+ destroy() {
247
+ this.isDestroy = true;
248
+ for (const [peerId, conn] of Object.entries(this.connected)) { this.#removePeer(peerId); conn.close(); }
249
+ for (const [peerId, connObj] of Object.entries(this.connecting)) { this.#removePeer(peerId); connObj['in']?.close(); connObj['out']?.close(); }
250
+ this.offerManager.destroy();
251
+ }
252
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * @typedef {import('./peer-store.mjs').PeerStore} PeerStore
3
+ *
4
+ * @typedef {Object} RouteInfo
5
+ * @property {string[]} path - Array of peer IDs forming the route [from, ..., remoteId]
6
+ * @property {number} hops - Number of hops/relays in the route (path.length - 1)
7
+ *
8
+ * @typedef {Object} RouteResult
9
+ * @property {RouteInfo[]} routes - Array of found routes, sorted by quality (best first)
10
+ * @property {boolean | 'blind'} success - Whether at least one route was found
11
+ * @property {number} nodesExplored - Number of nodes visited during search
12
+ */
13
+
14
+ /** Optimized route finder using bidirectional BFS and early stopping
15
+ * Much more efficient than V1 for longer paths by searching from both ends */
16
+ export class RouteBuilder_V2 {
17
+ /** @type {Record<string, RouteInfo[]>} */
18
+ cache = {}; // key: remoteId, value: paths
19
+ peerStore;
20
+
21
+ /** @param {string} selfId @param {PeerStore} peerStore */
22
+ constructor(selfId, peerStore) {
23
+ this.id = selfId;
24
+ this.peerStore = peerStore;
25
+ }
26
+
27
+ /** Find routes using bidirectional BFS with early stopping
28
+ * @param {string} remoteId - Destination peer ID
29
+ * @param {number} maxRoutes - Maximum number of routes to return (default: 5)
30
+ * @param {number} maxHops - Maximum relays allowed (default: 3)
31
+ * @param {number} maxNodes - Maximum nodes to explore (default: 1728)
32
+ * @param {boolean} sortByScore - Whether to sort routes by score (default: true)
33
+ * @param {number} goodEnoughHops - Early stop threshold (default: 3 hops)
34
+ * @returns {RouteResult} Result containing found routes and metadata */
35
+ buildRoutes(remoteId, maxRoutes = 5, maxHops = 3, maxNodes = 1728, sortByHops = true, goodEnoughHops = 3) {
36
+ if (this.id === remoteId) throw new Error('Cannot build route to self');
37
+ if (this.peerStore.connected[remoteId]) return { routes: [{ path: [this.id, remoteId]}], success: true, nodesExplored: 1 };
38
+ if (!this.peerStore.known[remoteId]) return this.#buildBlindRoutes(remoteId);
39
+
40
+ if (this.cache[remoteId] && this.#verifyRoutesStillValid(this.cache[remoteId]))
41
+ return { routes: this.cache[remoteId], success: true, nodesExplored: 0 };
42
+
43
+ const result = this.#bidirectionalSearch(remoteId, maxHops, maxNodes, goodEnoughHops);
44
+ if (!result.success) return { routes: [], success: false, nodesExplored: result.nodesExplored };
45
+
46
+ const routes = this.#buildRoutesFromPathsAndSortByHops(result.paths, sortByHops);
47
+ const selectedRoutes = routes.slice(0, maxRoutes);
48
+ this.cache[remoteId] = selectedRoutes;
49
+ return { routes: selectedRoutes, success: true, nodesExplored: result.nodesExplored };
50
+ }
51
+ /** @param {RouteInfo[]} routes */
52
+ #verifyRoutesStillValid(routes) {
53
+ // Check if all nodes are still known and connections still exist
54
+ for (const { path } of routes) {
55
+ for (let i = 1; i < path.length; i++) {
56
+ const from = path[i - 1];
57
+ const to = path[i];
58
+ if (from === this.id) {
59
+ if (!this.peerStore.connected[to]) return false;
60
+ } else {
61
+ if (!this.peerStore.known[from]?.neighbors?.[to]) return false;
62
+ }
63
+ }
64
+ }
65
+ return true;
66
+ }
67
+ #buildRoutesFromPathsAndSortByHops(paths, sortByHops) {
68
+ const routes = [];
69
+ for (const path of paths) routes.push({ path, hops: path.length - 1 });
70
+ if (sortByHops) routes.sort((a, b) => a.hops - b.hops);
71
+ return routes;
72
+ }
73
+ // Build blind routes via all connected peers in shuffled order
74
+ #buildBlindRoutes(remoteId) {
75
+ const routes = [];
76
+ const connected = this.peerStore.neighborsList;
77
+ const shuffledIndexes = [...Array(connected.length).keys()].sort(() => Math.random() - 0.5);
78
+ for (const i of shuffledIndexes) routes.push({ path: [this.id, connected[i], remoteId], hops: 0, score: 0 });
79
+
80
+ if (routes.length === 0) return { routes: [], success: false, nodesExplored: 0 };
81
+ else return { routes, success: 'blind', nodesExplored: routes.length };
82
+ }
83
+
84
+ /** Bidirectional BFS: search from both ends until they meet
85
+ * @param {string} remoteId - Target peer
86
+ * @param {number} maxHops - Max hops allowed
87
+ * @param {number} maxNodes - Max nodes to explore
88
+ * @param {number} goodEnoughHops - Early stop threshold
89
+ * @returns {{success: boolean, paths: string[][], nodesExplored: number}} */
90
+ #bidirectionalSearch(remoteId, maxHops, maxNodes, goodEnoughHops) {
91
+ const foundPaths = [];
92
+ let nodesExplored = 0;
93
+
94
+ // Forward: from id outward
95
+ const forwardQueue = [{ node: this.id, path: [this.id], pathSet: new Set([this.id]), depth: 0 }];
96
+ const forwardVisited = new Map(); // node -> path from id
97
+ forwardVisited.set(this.id, [this.id]);
98
+
99
+ // Backward: from remoteId outward
100
+ const backwardQueue = [{ node: remoteId, path: [remoteId], pathSet: new Set([remoteId]), depth: 0 }];
101
+ const backwardVisited = new Map(); // node -> path from remoteId
102
+ backwardVisited.set(remoteId, [remoteId]);
103
+
104
+ const maxDepthPerSide = Math.ceil(maxHops / 2);
105
+ while ((forwardQueue.length > 0 || backwardQueue.length > 0) && nodesExplored < maxNodes) {
106
+ if (forwardQueue.length > 0) { // Expand forward search
107
+ const meetings = this.#expandOneSide(forwardQueue, forwardVisited, backwardVisited, maxDepthPerSide);
108
+ for (const meetingNode of meetings) {
109
+ const completePath = this.#buildCompletePath(forwardVisited.get(meetingNode), backwardVisited.get(meetingNode));
110
+ foundPaths.push(completePath);
111
+ if (completePath.length - 1 > goodEnoughHops) continue;
112
+ return { success: true, paths: foundPaths, nodesExplored };
113
+ }
114
+ nodesExplored++;
115
+ }
116
+
117
+ if (backwardQueue.length > 0) { // Expand backward search
118
+ const meetings = this.#expandOneSide(backwardQueue, backwardVisited, forwardVisited, maxDepthPerSide);
119
+ for (const meetingNode of meetings) {
120
+ const completePath = this.#buildCompletePath(forwardVisited.get(meetingNode), backwardVisited.get(meetingNode));
121
+ foundPaths.push(completePath);
122
+ if (completePath.length - 1 > goodEnoughHops) continue;
123
+ return { success: true, paths: foundPaths, nodesExplored };
124
+ }
125
+ nodesExplored++;
126
+ }
127
+ }
128
+
129
+ return { success: foundPaths.length > 0, paths: foundPaths, nodesExplored };
130
+ }
131
+
132
+ /** Expand one side of the search and return meeting nodes
133
+ * @param {Array} queue - Search queue for this side
134
+ * @param {Map} visited - This side's visited nodes (node -> path)
135
+ * @param {Map} otherVisited - Other side's visited nodes
136
+ * @param {number} maxDepth - Max depth for this side
137
+ * @returns {string[]} Array of meeting node IDs found this iteration */
138
+ #expandOneSide(queue, visited, otherVisited, maxDepth) {
139
+ if (queue.length === 0) return [];
140
+
141
+ const { node: current, path, pathSet, depth } = queue.shift();
142
+ if (depth >= maxDepth) return [];
143
+
144
+ const meetings = [];
145
+ function processNeighbor(neighborId) {
146
+ if (pathSet.has(neighborId)) return;
147
+
148
+ const newDepth = depth + 1;
149
+ if (otherVisited.has(neighborId)) meetings.push(neighborId);
150
+ if (visited.has(neighborId)) return;
151
+
152
+ const newPath = [...path, neighborId];
153
+ const newPathSet = new Set(pathSet).add(neighborId);
154
+ visited.set(neighborId, newPath);
155
+ queue.push({ node: neighborId, path: newPath, pathSet: newPathSet, depth: newDepth });
156
+ }
157
+
158
+ if (current === this.id) for (const neighborId of this.peerStore.neighborsList) processNeighbor(neighborId);
159
+ else for (const neighborId in this.peerStore.known[current]?.neighbors || {}) processNeighbor(neighborId);
160
+
161
+ return meetings;
162
+ }
163
+
164
+ /** Build complete path from meeting point
165
+ * @param {string[]} forwardPath - Path from selfId to meeting point
166
+ * @param {string[]} backwardPath - Path from remoteId to meeting point
167
+ * @returns {string[]} Complete path from selfId to remoteId */
168
+ #buildCompletePath(forwardPath, backwardPath) {
169
+ const totalLength = forwardPath.length + backwardPath.length - 1;
170
+ const result = new Array(totalLength);
171
+ let index = 0;
172
+ for (const node of forwardPath) result[index++] = node;
173
+ for (let i = backwardPath.length - 2; i >= 0; i--) result[index++] = backwardPath[i];
174
+ return result;
175
+ }
176
+ }