@ruvector/edge-net 0.2.1 → 0.3.0
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/dht.js +790 -0
- package/genesis.js +858 -0
- package/p2p.js +577 -0
- package/package.json +24 -3
- package/webrtc.js +37 -5
package/dht.js
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ruvector/edge-net DHT (Distributed Hash Table)
|
|
3
|
+
*
|
|
4
|
+
* Kademlia-style DHT for decentralized peer discovery.
|
|
5
|
+
* Works without central signaling servers.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - XOR distance-based routing
|
|
9
|
+
* - K-bucket peer organization
|
|
10
|
+
* - Iterative node lookup
|
|
11
|
+
* - Value storage and retrieval
|
|
12
|
+
* - Peer discovery protocol
|
|
13
|
+
*
|
|
14
|
+
* @module @ruvector/edge-net/dht
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { EventEmitter } from 'events';
|
|
18
|
+
import { createHash, randomBytes } from 'crypto';
|
|
19
|
+
|
|
20
|
+
// DHT Constants
|
|
21
|
+
const K = 20; // K-bucket size (max peers per bucket)
|
|
22
|
+
const ALPHA = 3; // Parallel lookup concurrency
|
|
23
|
+
const ID_BITS = 160; // SHA-1 hash bits
|
|
24
|
+
const REFRESH_INTERVAL = 60000;
|
|
25
|
+
const PEER_TIMEOUT = 300000; // 5 minutes
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Calculate XOR distance between two node IDs
|
|
29
|
+
*/
|
|
30
|
+
export function xorDistance(id1, id2) {
|
|
31
|
+
const buf1 = Buffer.from(id1, 'hex');
|
|
32
|
+
const buf2 = Buffer.from(id2, 'hex');
|
|
33
|
+
const result = Buffer.alloc(Math.max(buf1.length, buf2.length));
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < result.length; i++) {
|
|
36
|
+
result[i] = (buf1[i] || 0) ^ (buf2[i] || 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return result.toString('hex');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the bucket index for a given distance
|
|
44
|
+
*/
|
|
45
|
+
export function getBucketIndex(distance) {
|
|
46
|
+
const buf = Buffer.from(distance, 'hex');
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < buf.length; i++) {
|
|
49
|
+
if (buf[i] !== 0) {
|
|
50
|
+
// Find the first set bit
|
|
51
|
+
for (let j = 7; j >= 0; j--) {
|
|
52
|
+
if (buf[i] & (1 << j)) {
|
|
53
|
+
return (buf.length - i - 1) * 8 + j;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generate a random node ID
|
|
64
|
+
*/
|
|
65
|
+
export function generateNodeId() {
|
|
66
|
+
return createHash('sha1').update(randomBytes(32)).digest('hex');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* K-Bucket: Stores peers at similar XOR distance
|
|
71
|
+
*/
|
|
72
|
+
export class KBucket {
|
|
73
|
+
constructor(index, k = K) {
|
|
74
|
+
this.index = index;
|
|
75
|
+
this.k = k;
|
|
76
|
+
this.peers = [];
|
|
77
|
+
this.replacementCache = [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Add a peer to the bucket
|
|
82
|
+
*/
|
|
83
|
+
add(peer) {
|
|
84
|
+
// Check if peer already exists
|
|
85
|
+
const existingIndex = this.peers.findIndex(p => p.id === peer.id);
|
|
86
|
+
|
|
87
|
+
if (existingIndex !== -1) {
|
|
88
|
+
// Move to end (most recently seen)
|
|
89
|
+
this.peers.splice(existingIndex, 1);
|
|
90
|
+
this.peers.push({ ...peer, lastSeen: Date.now() });
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (this.peers.length < this.k) {
|
|
95
|
+
this.peers.push({ ...peer, lastSeen: Date.now() });
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Bucket full, add to replacement cache
|
|
100
|
+
this.replacementCache.push({ ...peer, lastSeen: Date.now() });
|
|
101
|
+
if (this.replacementCache.length > this.k) {
|
|
102
|
+
this.replacementCache.shift();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Remove a peer from the bucket
|
|
110
|
+
*/
|
|
111
|
+
remove(peerId) {
|
|
112
|
+
const index = this.peers.findIndex(p => p.id === peerId);
|
|
113
|
+
if (index !== -1) {
|
|
114
|
+
this.peers.splice(index, 1);
|
|
115
|
+
|
|
116
|
+
// Promote from replacement cache
|
|
117
|
+
if (this.replacementCache.length > 0) {
|
|
118
|
+
this.peers.push(this.replacementCache.shift());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get a peer by ID
|
|
128
|
+
*/
|
|
129
|
+
get(peerId) {
|
|
130
|
+
return this.peers.find(p => p.id === peerId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get all peers
|
|
135
|
+
*/
|
|
136
|
+
getAll() {
|
|
137
|
+
return [...this.peers];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get closest peers to a target ID
|
|
142
|
+
*/
|
|
143
|
+
getClosest(targetId, count = K) {
|
|
144
|
+
return this.peers
|
|
145
|
+
.map(p => ({
|
|
146
|
+
...p,
|
|
147
|
+
distance: xorDistance(p.id, targetId),
|
|
148
|
+
}))
|
|
149
|
+
.sort((a, b) => a.distance.localeCompare(b.distance))
|
|
150
|
+
.slice(0, count);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Remove stale peers
|
|
155
|
+
*/
|
|
156
|
+
prune() {
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
this.peers = this.peers.filter(p =>
|
|
159
|
+
now - p.lastSeen < PEER_TIMEOUT
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get size() {
|
|
164
|
+
return this.peers.length;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Routing Table: Manages all K-buckets
|
|
170
|
+
*/
|
|
171
|
+
export class RoutingTable {
|
|
172
|
+
constructor(localId) {
|
|
173
|
+
this.localId = localId;
|
|
174
|
+
this.buckets = new Array(ID_BITS).fill(null).map((_, i) => new KBucket(i));
|
|
175
|
+
this.allPeers = new Map();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Add a peer to the routing table
|
|
180
|
+
*/
|
|
181
|
+
add(peer) {
|
|
182
|
+
if (peer.id === this.localId) return false;
|
|
183
|
+
|
|
184
|
+
const distance = xorDistance(this.localId, peer.id);
|
|
185
|
+
const bucketIndex = getBucketIndex(distance);
|
|
186
|
+
const added = this.buckets[bucketIndex].add(peer);
|
|
187
|
+
|
|
188
|
+
if (added) {
|
|
189
|
+
this.allPeers.set(peer.id, peer);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return added;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Remove a peer from the routing table
|
|
197
|
+
*/
|
|
198
|
+
remove(peerId) {
|
|
199
|
+
const peer = this.allPeers.get(peerId);
|
|
200
|
+
if (!peer) return false;
|
|
201
|
+
|
|
202
|
+
const distance = xorDistance(this.localId, peerId);
|
|
203
|
+
const bucketIndex = getBucketIndex(distance);
|
|
204
|
+
this.buckets[bucketIndex].remove(peerId);
|
|
205
|
+
this.allPeers.delete(peerId);
|
|
206
|
+
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Get a peer by ID
|
|
212
|
+
*/
|
|
213
|
+
get(peerId) {
|
|
214
|
+
return this.allPeers.get(peerId);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Find the closest peers to a target ID
|
|
219
|
+
*/
|
|
220
|
+
findClosest(targetId, count = K) {
|
|
221
|
+
const candidates = [];
|
|
222
|
+
|
|
223
|
+
for (const bucket of this.buckets) {
|
|
224
|
+
candidates.push(...bucket.getAll());
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return candidates
|
|
228
|
+
.map(p => ({
|
|
229
|
+
...p,
|
|
230
|
+
distance: xorDistance(p.id, targetId),
|
|
231
|
+
}))
|
|
232
|
+
.sort((a, b) => a.distance.localeCompare(b.distance))
|
|
233
|
+
.slice(0, count);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Get all peers
|
|
238
|
+
*/
|
|
239
|
+
getAllPeers() {
|
|
240
|
+
return Array.from(this.allPeers.values());
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Prune stale peers from all buckets
|
|
245
|
+
*/
|
|
246
|
+
prune() {
|
|
247
|
+
for (const bucket of this.buckets) {
|
|
248
|
+
bucket.prune();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Update allPeers map
|
|
252
|
+
this.allPeers.clear();
|
|
253
|
+
for (const bucket of this.buckets) {
|
|
254
|
+
for (const peer of bucket.getAll()) {
|
|
255
|
+
this.allPeers.set(peer.id, peer);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Get routing table stats
|
|
262
|
+
*/
|
|
263
|
+
getStats() {
|
|
264
|
+
let totalPeers = 0;
|
|
265
|
+
let bucketsUsed = 0;
|
|
266
|
+
|
|
267
|
+
for (const bucket of this.buckets) {
|
|
268
|
+
if (bucket.size > 0) {
|
|
269
|
+
totalPeers += bucket.size;
|
|
270
|
+
bucketsUsed++;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
totalPeers,
|
|
276
|
+
bucketsUsed,
|
|
277
|
+
bucketCount: this.buckets.length,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* DHT Node: Full DHT implementation
|
|
284
|
+
*/
|
|
285
|
+
export class DHTNode extends EventEmitter {
|
|
286
|
+
constructor(options = {}) {
|
|
287
|
+
super();
|
|
288
|
+
this.id = options.id || generateNodeId();
|
|
289
|
+
this.routingTable = new RoutingTable(this.id);
|
|
290
|
+
this.storage = new Map(); // DHT value storage
|
|
291
|
+
this.pendingLookups = new Map();
|
|
292
|
+
this.transport = options.transport || null;
|
|
293
|
+
this.bootstrapNodes = options.bootstrapNodes || [];
|
|
294
|
+
|
|
295
|
+
this.stats = {
|
|
296
|
+
lookups: 0,
|
|
297
|
+
stores: 0,
|
|
298
|
+
finds: 0,
|
|
299
|
+
messagesReceived: 0,
|
|
300
|
+
messagesSent: 0,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// Refresh timer
|
|
304
|
+
this.refreshTimer = null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Start the DHT node
|
|
309
|
+
*/
|
|
310
|
+
async start() {
|
|
311
|
+
console.log(`\n🌐 Starting DHT Node: ${this.id.slice(0, 8)}...`);
|
|
312
|
+
|
|
313
|
+
// Bootstrap from known nodes
|
|
314
|
+
if (this.bootstrapNodes.length > 0) {
|
|
315
|
+
await this.bootstrap();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Start periodic refresh
|
|
319
|
+
this.refreshTimer = setInterval(() => {
|
|
320
|
+
this.refresh();
|
|
321
|
+
}, REFRESH_INTERVAL);
|
|
322
|
+
|
|
323
|
+
this.emit('started', { id: this.id });
|
|
324
|
+
|
|
325
|
+
return this;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Stop the DHT node
|
|
330
|
+
*/
|
|
331
|
+
stop() {
|
|
332
|
+
if (this.refreshTimer) {
|
|
333
|
+
clearInterval(this.refreshTimer);
|
|
334
|
+
}
|
|
335
|
+
this.emit('stopped');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Bootstrap from known nodes
|
|
340
|
+
*/
|
|
341
|
+
async bootstrap() {
|
|
342
|
+
console.log(` 📡 Bootstrapping from ${this.bootstrapNodes.length} nodes...`);
|
|
343
|
+
|
|
344
|
+
for (const node of this.bootstrapNodes) {
|
|
345
|
+
try {
|
|
346
|
+
// Add bootstrap node to routing table
|
|
347
|
+
this.routingTable.add({
|
|
348
|
+
id: node.id,
|
|
349
|
+
address: node.address,
|
|
350
|
+
port: node.port,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Perform lookup for our own ID to populate routing table
|
|
354
|
+
await this.lookup(this.id);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
console.warn(` ⚠️ Bootstrap node ${node.id.slice(0, 8)} unreachable`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Add a peer to the routing table
|
|
363
|
+
*/
|
|
364
|
+
addPeer(peer) {
|
|
365
|
+
const added = this.routingTable.add(peer);
|
|
366
|
+
if (added) {
|
|
367
|
+
this.emit('peer-added', peer);
|
|
368
|
+
}
|
|
369
|
+
return added;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Remove a peer from the routing table
|
|
374
|
+
*/
|
|
375
|
+
removePeer(peerId) {
|
|
376
|
+
const removed = this.routingTable.remove(peerId);
|
|
377
|
+
if (removed) {
|
|
378
|
+
this.emit('peer-removed', peerId);
|
|
379
|
+
}
|
|
380
|
+
return removed;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Iterative node lookup (Kademlia FIND_NODE)
|
|
385
|
+
*/
|
|
386
|
+
async lookup(targetId) {
|
|
387
|
+
this.stats.lookups++;
|
|
388
|
+
|
|
389
|
+
// Get initial closest nodes
|
|
390
|
+
let closest = this.routingTable.findClosest(targetId, ALPHA);
|
|
391
|
+
const queried = new Set([this.id]);
|
|
392
|
+
const results = new Map();
|
|
393
|
+
|
|
394
|
+
// Add initial closest to results
|
|
395
|
+
for (const node of closest) {
|
|
396
|
+
results.set(node.id, node);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Iterative lookup
|
|
400
|
+
while (closest.length > 0) {
|
|
401
|
+
const toQuery = closest.filter(n => !queried.has(n.id)).slice(0, ALPHA);
|
|
402
|
+
|
|
403
|
+
if (toQuery.length === 0) break;
|
|
404
|
+
|
|
405
|
+
// Query nodes in parallel
|
|
406
|
+
const responses = await Promise.all(
|
|
407
|
+
toQuery.map(async (node) => {
|
|
408
|
+
queried.add(node.id);
|
|
409
|
+
try {
|
|
410
|
+
return await this.sendFindNode(node, targetId);
|
|
411
|
+
} catch (err) {
|
|
412
|
+
return [];
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// Process responses
|
|
418
|
+
let foundCloser = false;
|
|
419
|
+
for (const nodes of responses) {
|
|
420
|
+
for (const node of nodes) {
|
|
421
|
+
if (!results.has(node.id)) {
|
|
422
|
+
results.set(node.id, node);
|
|
423
|
+
this.routingTable.add(node);
|
|
424
|
+
foundCloser = true;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!foundCloser) break;
|
|
430
|
+
|
|
431
|
+
// Get new closest
|
|
432
|
+
closest = Array.from(results.values())
|
|
433
|
+
.filter(n => !queried.has(n.id))
|
|
434
|
+
.sort((a, b) => {
|
|
435
|
+
const distA = xorDistance(a.id, targetId);
|
|
436
|
+
const distB = xorDistance(b.id, targetId);
|
|
437
|
+
return distA.localeCompare(distB);
|
|
438
|
+
})
|
|
439
|
+
.slice(0, K);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return Array.from(results.values())
|
|
443
|
+
.sort((a, b) => {
|
|
444
|
+
const distA = xorDistance(a.id, targetId);
|
|
445
|
+
const distB = xorDistance(b.id, targetId);
|
|
446
|
+
return distA.localeCompare(distB);
|
|
447
|
+
})
|
|
448
|
+
.slice(0, K);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Store a value in the DHT
|
|
453
|
+
*/
|
|
454
|
+
async store(key, value) {
|
|
455
|
+
this.stats.stores++;
|
|
456
|
+
|
|
457
|
+
const keyHash = createHash('sha1').update(key).digest('hex');
|
|
458
|
+
|
|
459
|
+
// Store locally
|
|
460
|
+
this.storage.set(keyHash, {
|
|
461
|
+
key,
|
|
462
|
+
value,
|
|
463
|
+
timestamp: Date.now(),
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// Find closest nodes to the key
|
|
467
|
+
const closest = await this.lookup(keyHash);
|
|
468
|
+
|
|
469
|
+
// Store on closest nodes
|
|
470
|
+
await Promise.all(
|
|
471
|
+
closest.map(node => this.sendStore(node, keyHash, value))
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
this.emit('stored', { key, keyHash });
|
|
475
|
+
|
|
476
|
+
return keyHash;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Find a value in the DHT
|
|
481
|
+
*/
|
|
482
|
+
async find(key) {
|
|
483
|
+
this.stats.finds++;
|
|
484
|
+
|
|
485
|
+
const keyHash = createHash('sha1').update(key).digest('hex');
|
|
486
|
+
|
|
487
|
+
// Check local storage first
|
|
488
|
+
const local = this.storage.get(keyHash);
|
|
489
|
+
if (local) {
|
|
490
|
+
return local.value;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Query closest nodes
|
|
494
|
+
const closest = await this.lookup(keyHash);
|
|
495
|
+
|
|
496
|
+
for (const node of closest) {
|
|
497
|
+
try {
|
|
498
|
+
const value = await this.sendFindValue(node, keyHash);
|
|
499
|
+
if (value) {
|
|
500
|
+
// Cache locally
|
|
501
|
+
this.storage.set(keyHash, {
|
|
502
|
+
key,
|
|
503
|
+
value,
|
|
504
|
+
timestamp: Date.now(),
|
|
505
|
+
});
|
|
506
|
+
return value;
|
|
507
|
+
}
|
|
508
|
+
} catch (err) {
|
|
509
|
+
// Node didn't have value
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Send FIND_NODE request
|
|
518
|
+
*/
|
|
519
|
+
async sendFindNode(node, targetId) {
|
|
520
|
+
this.stats.messagesSent++;
|
|
521
|
+
|
|
522
|
+
if (this.transport) {
|
|
523
|
+
return await this.transport.send(node, {
|
|
524
|
+
type: 'FIND_NODE',
|
|
525
|
+
sender: this.id,
|
|
526
|
+
target: targetId,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Simulated response for local testing
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Send STORE request
|
|
536
|
+
*/
|
|
537
|
+
async sendStore(node, keyHash, value) {
|
|
538
|
+
this.stats.messagesSent++;
|
|
539
|
+
|
|
540
|
+
if (this.transport) {
|
|
541
|
+
return await this.transport.send(node, {
|
|
542
|
+
type: 'STORE',
|
|
543
|
+
sender: this.id,
|
|
544
|
+
key: keyHash,
|
|
545
|
+
value,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Send FIND_VALUE request
|
|
552
|
+
*/
|
|
553
|
+
async sendFindValue(node, keyHash) {
|
|
554
|
+
this.stats.messagesSent++;
|
|
555
|
+
|
|
556
|
+
if (this.transport) {
|
|
557
|
+
const response = await this.transport.send(node, {
|
|
558
|
+
type: 'FIND_VALUE',
|
|
559
|
+
sender: this.id,
|
|
560
|
+
key: keyHash,
|
|
561
|
+
});
|
|
562
|
+
return response?.value;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Handle incoming DHT message
|
|
570
|
+
*/
|
|
571
|
+
async handleMessage(message, sender) {
|
|
572
|
+
this.stats.messagesReceived++;
|
|
573
|
+
|
|
574
|
+
// Add sender to routing table
|
|
575
|
+
this.routingTable.add(sender);
|
|
576
|
+
|
|
577
|
+
switch (message.type) {
|
|
578
|
+
case 'PING':
|
|
579
|
+
return { type: 'PONG', sender: this.id };
|
|
580
|
+
|
|
581
|
+
case 'FIND_NODE':
|
|
582
|
+
return {
|
|
583
|
+
type: 'FIND_NODE_RESPONSE',
|
|
584
|
+
sender: this.id,
|
|
585
|
+
nodes: this.routingTable.findClosest(message.target, K),
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
case 'STORE':
|
|
589
|
+
this.storage.set(message.key, {
|
|
590
|
+
value: message.value,
|
|
591
|
+
timestamp: Date.now(),
|
|
592
|
+
});
|
|
593
|
+
return { type: 'STORE_ACK', sender: this.id };
|
|
594
|
+
|
|
595
|
+
case 'FIND_VALUE':
|
|
596
|
+
const stored = this.storage.get(message.key);
|
|
597
|
+
if (stored) {
|
|
598
|
+
return {
|
|
599
|
+
type: 'FIND_VALUE_RESPONSE',
|
|
600
|
+
sender: this.id,
|
|
601
|
+
value: stored.value,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
type: 'FIND_VALUE_RESPONSE',
|
|
606
|
+
sender: this.id,
|
|
607
|
+
nodes: this.routingTable.findClosest(message.key, K),
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
default:
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Refresh buckets by looking up random IDs
|
|
617
|
+
*/
|
|
618
|
+
refresh() {
|
|
619
|
+
this.routingTable.prune();
|
|
620
|
+
|
|
621
|
+
// Lookup random ID in each bucket that hasn't been updated recently
|
|
622
|
+
for (let i = 0; i < ID_BITS; i++) {
|
|
623
|
+
const bucket = this.routingTable.buckets[i];
|
|
624
|
+
if (bucket.size > 0) {
|
|
625
|
+
const randomTarget = generateNodeId();
|
|
626
|
+
this.lookup(randomTarget).catch(() => {});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Get DHT statistics
|
|
633
|
+
*/
|
|
634
|
+
getStats() {
|
|
635
|
+
return {
|
|
636
|
+
...this.stats,
|
|
637
|
+
...this.routingTable.getStats(),
|
|
638
|
+
storageSize: this.storage.size,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Get all known peers
|
|
644
|
+
*/
|
|
645
|
+
getPeers() {
|
|
646
|
+
return this.routingTable.getAllPeers();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Find peers providing a service
|
|
651
|
+
*/
|
|
652
|
+
async findProviders(service) {
|
|
653
|
+
const serviceKey = `service:${service}`;
|
|
654
|
+
return await this.find(serviceKey);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Announce as a provider of a service
|
|
659
|
+
*/
|
|
660
|
+
async announce(service) {
|
|
661
|
+
const serviceKey = `service:${service}`;
|
|
662
|
+
|
|
663
|
+
// Get existing providers
|
|
664
|
+
let providers = await this.find(serviceKey);
|
|
665
|
+
if (!providers) {
|
|
666
|
+
providers = [];
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Add ourselves
|
|
670
|
+
if (!providers.some(p => p.id === this.id)) {
|
|
671
|
+
providers.push({
|
|
672
|
+
id: this.id,
|
|
673
|
+
timestamp: Date.now(),
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Store updated providers list
|
|
678
|
+
await this.store(serviceKey, providers);
|
|
679
|
+
|
|
680
|
+
this.emit('announced', { service });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* WebRTC Transport for DHT
|
|
686
|
+
*/
|
|
687
|
+
export class DHTWebRTCTransport extends EventEmitter {
|
|
688
|
+
constructor(peerManager) {
|
|
689
|
+
super();
|
|
690
|
+
this.peerManager = peerManager;
|
|
691
|
+
this.pendingRequests = new Map();
|
|
692
|
+
this.requestId = 0;
|
|
693
|
+
|
|
694
|
+
// Listen for DHT messages from peers
|
|
695
|
+
this.peerManager.on('message', ({ from, message }) => {
|
|
696
|
+
if (message.type?.startsWith('DHT_')) {
|
|
697
|
+
this.handleResponse(from, message);
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Send DHT message to a peer
|
|
704
|
+
*/
|
|
705
|
+
async send(node, message) {
|
|
706
|
+
return new Promise((resolve, reject) => {
|
|
707
|
+
const requestId = ++this.requestId;
|
|
708
|
+
|
|
709
|
+
// Set timeout
|
|
710
|
+
const timeout = setTimeout(() => {
|
|
711
|
+
this.pendingRequests.delete(requestId);
|
|
712
|
+
reject(new Error('DHT request timeout'));
|
|
713
|
+
}, 10000);
|
|
714
|
+
|
|
715
|
+
this.pendingRequests.set(requestId, { resolve, reject, timeout });
|
|
716
|
+
|
|
717
|
+
// Send via WebRTC
|
|
718
|
+
const sent = this.peerManager.sendToPeer(node.id, {
|
|
719
|
+
...message,
|
|
720
|
+
type: `DHT_${message.type}`,
|
|
721
|
+
requestId,
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
if (!sent) {
|
|
725
|
+
clearTimeout(timeout);
|
|
726
|
+
this.pendingRequests.delete(requestId);
|
|
727
|
+
reject(new Error('Peer not connected'));
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Handle DHT response
|
|
734
|
+
*/
|
|
735
|
+
handleResponse(from, message) {
|
|
736
|
+
const pending = this.pendingRequests.get(message.requestId);
|
|
737
|
+
if (pending) {
|
|
738
|
+
clearTimeout(pending.timeout);
|
|
739
|
+
this.pendingRequests.delete(message.requestId);
|
|
740
|
+
pending.resolve(message);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Create and configure a DHT node with WebRTC transport
|
|
747
|
+
*/
|
|
748
|
+
export async function createDHTNode(peerManager, options = {}) {
|
|
749
|
+
const transport = new DHTWebRTCTransport(peerManager);
|
|
750
|
+
|
|
751
|
+
const dht = new DHTNode({
|
|
752
|
+
...options,
|
|
753
|
+
transport,
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// Forward DHT messages from peers
|
|
757
|
+
peerManager.on('message', ({ from, message }) => {
|
|
758
|
+
if (message.type?.startsWith('DHT_')) {
|
|
759
|
+
const dhtMessage = {
|
|
760
|
+
...message,
|
|
761
|
+
type: message.type.replace('DHT_', ''),
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
const sender = {
|
|
765
|
+
id: from,
|
|
766
|
+
lastSeen: Date.now(),
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
dht.handleMessage(dhtMessage, sender).then(response => {
|
|
770
|
+
if (response) {
|
|
771
|
+
peerManager.sendToPeer(from, {
|
|
772
|
+
...response,
|
|
773
|
+
type: `DHT_${response.type}`,
|
|
774
|
+
requestId: message.requestId,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
await dht.start();
|
|
782
|
+
|
|
783
|
+
return dht;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ============================================
|
|
787
|
+
// EXPORTS
|
|
788
|
+
// ============================================
|
|
789
|
+
|
|
790
|
+
export default DHTNode;
|