@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.
- package/core/arbiter.mjs +125 -0
- package/core/config.mjs +226 -0
- package/core/crypto-codex.mjs +257 -0
- package/core/gossip.mjs +159 -0
- package/core/ice-offer-manager.mjs +181 -0
- package/core/node-services.mjs +129 -0
- package/core/node.mjs +177 -0
- package/core/peer-store.mjs +252 -0
- package/core/route-builder.mjs +176 -0
- package/core/topologist.mjs +254 -0
- package/core/unicast.mjs +155 -0
- package/index.mjs +3 -3
- package/libs/xxhash32.mjs +65 -0
- package/package.json +5 -5
- package/rendering/NetworkRenderer.mjs +734 -0
- package/rendering/renderer-options.mjs +85 -0
- package/rendering/renderer-stores.mjs +234 -0
- package/rendering/visualizer.css +138 -0
- package/rendering/visualizer.html +60 -0
- package/rendering/visualizer.mjs +254 -0
- package/services/clock-v2.mjs +196 -0
- package/services/clock.mjs +144 -0
- package/services/converter.mjs +64 -0
- package/services/cryptos.mjs +83 -0
|
@@ -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
|
+
}
|