@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.
Files changed (5) hide show
  1. package/dht.js +790 -0
  2. package/genesis.js +858 -0
  3. package/p2p.js +577 -0
  4. package/package.json +24 -3
  5. 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;