@ruvector/edge-net 0.1.6 → 0.2.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/signaling.js ADDED
@@ -0,0 +1,732 @@
1
+ /**
2
+ * @ruvector/edge-net WebRTC Signaling Server
3
+ *
4
+ * Real signaling server for WebRTC peer connections
5
+ * Enables true P2P connections between nodes
6
+ *
7
+ * @module @ruvector/edge-net/signaling
8
+ */
9
+
10
+ import { EventEmitter } from 'events';
11
+ import { createServer } from 'http';
12
+ import { randomBytes, createHash } from 'crypto';
13
+
14
+ // ============================================
15
+ // SIGNALING SERVER
16
+ // ============================================
17
+
18
+ /**
19
+ * WebRTC Signaling Server
20
+ * Routes offers, answers, and ICE candidates between peers
21
+ */
22
+ export class SignalingServer extends EventEmitter {
23
+ constructor(options = {}) {
24
+ super();
25
+ this.port = options.port || 8765;
26
+ this.server = null;
27
+ this.wss = null;
28
+
29
+ this.peers = new Map(); // peerId -> { ws, info, rooms }
30
+ this.rooms = new Map(); // roomId -> Set<peerId>
31
+ this.pendingOffers = new Map(); // offerId -> { from, to, offer }
32
+
33
+ this.stats = {
34
+ connections: 0,
35
+ messages: 0,
36
+ offers: 0,
37
+ answers: 0,
38
+ iceCandidates: 0,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Start the signaling server
44
+ */
45
+ async start() {
46
+ return new Promise(async (resolve, reject) => {
47
+ try {
48
+ // Create HTTP server
49
+ this.server = createServer((req, res) => {
50
+ if (req.url === '/health') {
51
+ res.writeHead(200, { 'Content-Type': 'application/json' });
52
+ res.end(JSON.stringify({ status: 'ok', peers: this.peers.size }));
53
+ } else if (req.url === '/stats') {
54
+ res.writeHead(200, { 'Content-Type': 'application/json' });
55
+ res.end(JSON.stringify(this.getStats()));
56
+ } else {
57
+ res.writeHead(404);
58
+ res.end('Not found');
59
+ }
60
+ });
61
+
62
+ // Create WebSocket server
63
+ const { WebSocketServer } = await import('ws');
64
+ this.wss = new WebSocketServer({ server: this.server });
65
+
66
+ this.wss.on('connection', (ws, req) => {
67
+ this.handleConnection(ws, req);
68
+ });
69
+
70
+ this.server.listen(this.port, () => {
71
+ console.log(`[Signaling] Server running on port ${this.port}`);
72
+ this.emit('ready', { port: this.port });
73
+ resolve(this);
74
+ });
75
+
76
+ } catch (error) {
77
+ reject(error);
78
+ }
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Handle new WebSocket connection
84
+ */
85
+ handleConnection(ws, req) {
86
+ const peerId = `peer-${randomBytes(8).toString('hex')}`;
87
+
88
+ const peerInfo = {
89
+ id: peerId,
90
+ ws,
91
+ info: {},
92
+ rooms: new Set(),
93
+ connectedAt: Date.now(),
94
+ lastSeen: Date.now(),
95
+ };
96
+
97
+ this.peers.set(peerId, peerInfo);
98
+ this.stats.connections++;
99
+
100
+ // Send welcome message
101
+ this.sendTo(peerId, {
102
+ type: 'welcome',
103
+ peerId,
104
+ serverTime: Date.now(),
105
+ });
106
+
107
+ ws.on('message', (data) => {
108
+ try {
109
+ const message = JSON.parse(data.toString());
110
+ this.handleMessage(peerId, message);
111
+ } catch (error) {
112
+ console.error('[Signaling] Invalid message:', error.message);
113
+ }
114
+ });
115
+
116
+ ws.on('close', () => {
117
+ this.handleDisconnect(peerId);
118
+ });
119
+
120
+ ws.on('error', (error) => {
121
+ console.error(`[Signaling] Peer ${peerId} error:`, error.message);
122
+ });
123
+
124
+ this.emit('peer-connected', { peerId });
125
+ }
126
+
127
+ /**
128
+ * Handle incoming message from peer
129
+ */
130
+ handleMessage(peerId, message) {
131
+ const peer = this.peers.get(peerId);
132
+ if (!peer) return;
133
+
134
+ peer.lastSeen = Date.now();
135
+ this.stats.messages++;
136
+
137
+ switch (message.type) {
138
+ case 'register':
139
+ this.handleRegister(peerId, message);
140
+ break;
141
+
142
+ case 'join-room':
143
+ this.handleJoinRoom(peerId, message);
144
+ break;
145
+
146
+ case 'leave-room':
147
+ this.handleLeaveRoom(peerId, message);
148
+ break;
149
+
150
+ case 'offer':
151
+ this.handleOffer(peerId, message);
152
+ break;
153
+
154
+ case 'answer':
155
+ this.handleAnswer(peerId, message);
156
+ break;
157
+
158
+ case 'ice-candidate':
159
+ this.handleIceCandidate(peerId, message);
160
+ break;
161
+
162
+ case 'discover':
163
+ this.handleDiscover(peerId, message);
164
+ break;
165
+
166
+ case 'broadcast':
167
+ this.handleBroadcast(peerId, message);
168
+ break;
169
+
170
+ case 'ping':
171
+ this.sendTo(peerId, { type: 'pong', timestamp: Date.now() });
172
+ break;
173
+
174
+ default:
175
+ console.log(`[Signaling] Unknown message type: ${message.type}`);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Handle peer registration
181
+ */
182
+ handleRegister(peerId, message) {
183
+ const peer = this.peers.get(peerId);
184
+ if (!peer) return;
185
+
186
+ peer.info = {
187
+ nodeId: message.nodeId,
188
+ capabilities: message.capabilities || [],
189
+ publicKey: message.publicKey,
190
+ region: message.region,
191
+ };
192
+
193
+ this.sendTo(peerId, {
194
+ type: 'registered',
195
+ peerId,
196
+ info: peer.info,
197
+ });
198
+
199
+ this.emit('peer-registered', { peerId, info: peer.info });
200
+ }
201
+
202
+ /**
203
+ * Handle room join
204
+ */
205
+ handleJoinRoom(peerId, message) {
206
+ const roomId = message.roomId || 'default';
207
+ const peer = this.peers.get(peerId);
208
+ if (!peer) return;
209
+
210
+ // Create room if doesn't exist
211
+ if (!this.rooms.has(roomId)) {
212
+ this.rooms.set(roomId, new Set());
213
+ }
214
+
215
+ const room = this.rooms.get(roomId);
216
+ room.add(peerId);
217
+ peer.rooms.add(roomId);
218
+
219
+ // Get existing peers in room
220
+ const existingPeers = Array.from(room)
221
+ .filter(id => id !== peerId)
222
+ .map(id => {
223
+ const p = this.peers.get(id);
224
+ return { peerId: id, info: p?.info };
225
+ });
226
+
227
+ // Notify joining peer of existing peers
228
+ this.sendTo(peerId, {
229
+ type: 'room-joined',
230
+ roomId,
231
+ peers: existingPeers,
232
+ });
233
+
234
+ // Notify existing peers of new peer
235
+ for (const otherPeerId of room) {
236
+ if (otherPeerId !== peerId) {
237
+ this.sendTo(otherPeerId, {
238
+ type: 'peer-joined',
239
+ roomId,
240
+ peerId,
241
+ info: peer.info,
242
+ });
243
+ }
244
+ }
245
+
246
+ this.emit('room-join', { roomId, peerId });
247
+ }
248
+
249
+ /**
250
+ * Handle room leave
251
+ */
252
+ handleLeaveRoom(peerId, message) {
253
+ const roomId = message.roomId;
254
+ const peer = this.peers.get(peerId);
255
+ if (!peer) return;
256
+
257
+ const room = this.rooms.get(roomId);
258
+ if (!room) return;
259
+
260
+ room.delete(peerId);
261
+ peer.rooms.delete(roomId);
262
+
263
+ // Notify other peers
264
+ for (const otherPeerId of room) {
265
+ this.sendTo(otherPeerId, {
266
+ type: 'peer-left',
267
+ roomId,
268
+ peerId,
269
+ });
270
+ }
271
+
272
+ // Clean up empty room
273
+ if (room.size === 0) {
274
+ this.rooms.delete(roomId);
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Handle WebRTC offer
280
+ */
281
+ handleOffer(peerId, message) {
282
+ this.stats.offers++;
283
+
284
+ const targetPeerId = message.to;
285
+ const target = this.peers.get(targetPeerId);
286
+
287
+ if (!target) {
288
+ this.sendTo(peerId, {
289
+ type: 'error',
290
+ error: 'Peer not found',
291
+ targetPeerId,
292
+ });
293
+ return;
294
+ }
295
+
296
+ // Forward offer to target
297
+ this.sendTo(targetPeerId, {
298
+ type: 'offer',
299
+ from: peerId,
300
+ offer: message.offer,
301
+ connectionId: message.connectionId,
302
+ });
303
+
304
+ this.emit('offer', { from: peerId, to: targetPeerId });
305
+ }
306
+
307
+ /**
308
+ * Handle WebRTC answer
309
+ */
310
+ handleAnswer(peerId, message) {
311
+ this.stats.answers++;
312
+
313
+ const targetPeerId = message.to;
314
+ const target = this.peers.get(targetPeerId);
315
+
316
+ if (!target) return;
317
+
318
+ // Forward answer to target
319
+ this.sendTo(targetPeerId, {
320
+ type: 'answer',
321
+ from: peerId,
322
+ answer: message.answer,
323
+ connectionId: message.connectionId,
324
+ });
325
+
326
+ this.emit('answer', { from: peerId, to: targetPeerId });
327
+ }
328
+
329
+ /**
330
+ * Handle ICE candidate
331
+ */
332
+ handleIceCandidate(peerId, message) {
333
+ this.stats.iceCandidates++;
334
+
335
+ const targetPeerId = message.to;
336
+ const target = this.peers.get(targetPeerId);
337
+
338
+ if (!target) return;
339
+
340
+ // Forward ICE candidate to target
341
+ this.sendTo(targetPeerId, {
342
+ type: 'ice-candidate',
343
+ from: peerId,
344
+ candidate: message.candidate,
345
+ connectionId: message.connectionId,
346
+ });
347
+ }
348
+
349
+ /**
350
+ * Handle peer discovery request
351
+ */
352
+ handleDiscover(peerId, message) {
353
+ const capabilities = message.capabilities || [];
354
+ const limit = message.limit || 10;
355
+
356
+ const matches = [];
357
+
358
+ for (const [id, peer] of this.peers) {
359
+ if (id === peerId) continue;
360
+
361
+ // Check capability match
362
+ if (capabilities.length > 0) {
363
+ const peerCaps = peer.info.capabilities || [];
364
+ const hasMatch = capabilities.some(cap => peerCaps.includes(cap));
365
+ if (!hasMatch) continue;
366
+ }
367
+
368
+ matches.push({
369
+ peerId: id,
370
+ info: peer.info,
371
+ lastSeen: peer.lastSeen,
372
+ });
373
+
374
+ if (matches.length >= limit) break;
375
+ }
376
+
377
+ this.sendTo(peerId, {
378
+ type: 'discover-result',
379
+ peers: matches,
380
+ total: this.peers.size - 1,
381
+ });
382
+ }
383
+
384
+ /**
385
+ * Handle broadcast to room
386
+ */
387
+ handleBroadcast(peerId, message) {
388
+ const roomId = message.roomId;
389
+ const room = this.rooms.get(roomId);
390
+
391
+ if (!room) return;
392
+
393
+ for (const otherPeerId of room) {
394
+ if (otherPeerId !== peerId) {
395
+ this.sendTo(otherPeerId, {
396
+ type: 'broadcast',
397
+ from: peerId,
398
+ roomId,
399
+ data: message.data,
400
+ });
401
+ }
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Handle peer disconnect
407
+ */
408
+ handleDisconnect(peerId) {
409
+ const peer = this.peers.get(peerId);
410
+ if (!peer) return;
411
+
412
+ // Leave all rooms
413
+ for (const roomId of peer.rooms) {
414
+ const room = this.rooms.get(roomId);
415
+ if (room) {
416
+ room.delete(peerId);
417
+
418
+ // Notify other peers
419
+ for (const otherPeerId of room) {
420
+ this.sendTo(otherPeerId, {
421
+ type: 'peer-left',
422
+ roomId,
423
+ peerId,
424
+ });
425
+ }
426
+
427
+ // Clean up empty room
428
+ if (room.size === 0) {
429
+ this.rooms.delete(roomId);
430
+ }
431
+ }
432
+ }
433
+
434
+ this.peers.delete(peerId);
435
+ this.emit('peer-disconnected', { peerId });
436
+ }
437
+
438
+ /**
439
+ * Send message to peer
440
+ */
441
+ sendTo(peerId, message) {
442
+ const peer = this.peers.get(peerId);
443
+ if (peer && peer.ws.readyState === 1) {
444
+ peer.ws.send(JSON.stringify(message));
445
+ return true;
446
+ }
447
+ return false;
448
+ }
449
+
450
+ /**
451
+ * Get server stats
452
+ */
453
+ getStats() {
454
+ return {
455
+ peers: this.peers.size,
456
+ rooms: this.rooms.size,
457
+ ...this.stats,
458
+ uptime: Date.now() - (this.startTime || Date.now()),
459
+ };
460
+ }
461
+
462
+ /**
463
+ * Stop the server
464
+ */
465
+ async stop() {
466
+ return new Promise((resolve) => {
467
+ // Close all peer connections
468
+ for (const [peerId, peer] of this.peers) {
469
+ peer.ws.close();
470
+ }
471
+
472
+ this.peers.clear();
473
+ this.rooms.clear();
474
+
475
+ if (this.wss) {
476
+ this.wss.close();
477
+ }
478
+
479
+ if (this.server) {
480
+ this.server.close(() => {
481
+ console.log('[Signaling] Server stopped');
482
+ resolve();
483
+ });
484
+ } else {
485
+ resolve();
486
+ }
487
+ });
488
+ }
489
+ }
490
+
491
+ // ============================================
492
+ // SIGNALING CLIENT
493
+ // ============================================
494
+
495
+ /**
496
+ * WebRTC Signaling Client
497
+ * Connects to signaling server for peer discovery and connection setup
498
+ */
499
+ export class SignalingClient extends EventEmitter {
500
+ constructor(options = {}) {
501
+ super();
502
+ this.serverUrl = options.serverUrl || 'ws://localhost:8765';
503
+ this.nodeId = options.nodeId || `node-${randomBytes(8).toString('hex')}`;
504
+ this.capabilities = options.capabilities || [];
505
+
506
+ this.ws = null;
507
+ this.peerId = null;
508
+ this.connected = false;
509
+ this.rooms = new Set();
510
+
511
+ this.pendingConnections = new Map();
512
+ this.peerConnections = new Map();
513
+ }
514
+
515
+ /**
516
+ * Connect to signaling server
517
+ */
518
+ async connect() {
519
+ return new Promise(async (resolve, reject) => {
520
+ try {
521
+ let WebSocket;
522
+ if (typeof globalThis.WebSocket !== 'undefined') {
523
+ WebSocket = globalThis.WebSocket;
524
+ } else {
525
+ const ws = await import('ws');
526
+ WebSocket = ws.default || ws.WebSocket;
527
+ }
528
+
529
+ this.ws = new WebSocket(this.serverUrl);
530
+
531
+ const timeout = setTimeout(() => {
532
+ reject(new Error('Connection timeout'));
533
+ }, 10000);
534
+
535
+ this.ws.onopen = () => {
536
+ clearTimeout(timeout);
537
+ this.connected = true;
538
+ this.emit('connected');
539
+ };
540
+
541
+ this.ws.onmessage = (event) => {
542
+ const message = JSON.parse(event.data);
543
+ this.handleMessage(message);
544
+
545
+ if (message.type === 'registered') {
546
+ resolve(this);
547
+ }
548
+ };
549
+
550
+ this.ws.onclose = () => {
551
+ this.connected = false;
552
+ this.emit('disconnected');
553
+ };
554
+
555
+ this.ws.onerror = (error) => {
556
+ clearTimeout(timeout);
557
+ reject(error);
558
+ };
559
+
560
+ } catch (error) {
561
+ reject(error);
562
+ }
563
+ });
564
+ }
565
+
566
+ /**
567
+ * Handle incoming message
568
+ */
569
+ handleMessage(message) {
570
+ switch (message.type) {
571
+ case 'welcome':
572
+ this.peerId = message.peerId;
573
+ // Register with capabilities
574
+ this.send({
575
+ type: 'register',
576
+ nodeId: this.nodeId,
577
+ capabilities: this.capabilities,
578
+ });
579
+ break;
580
+
581
+ case 'registered':
582
+ this.emit('registered', message);
583
+ break;
584
+
585
+ case 'room-joined':
586
+ this.rooms.add(message.roomId);
587
+ this.emit('room-joined', message);
588
+ break;
589
+
590
+ case 'peer-joined':
591
+ this.emit('peer-joined', message);
592
+ break;
593
+
594
+ case 'peer-left':
595
+ this.emit('peer-left', message);
596
+ break;
597
+
598
+ case 'offer':
599
+ this.emit('offer', message);
600
+ break;
601
+
602
+ case 'answer':
603
+ this.emit('answer', message);
604
+ break;
605
+
606
+ case 'ice-candidate':
607
+ this.emit('ice-candidate', message);
608
+ break;
609
+
610
+ case 'discover-result':
611
+ this.emit('discover-result', message);
612
+ break;
613
+
614
+ case 'broadcast':
615
+ this.emit('broadcast', message);
616
+ break;
617
+
618
+ case 'pong':
619
+ this.emit('pong', message);
620
+ break;
621
+
622
+ default:
623
+ this.emit('message', message);
624
+ }
625
+ }
626
+
627
+ /**
628
+ * Send message to server
629
+ */
630
+ send(message) {
631
+ if (this.connected && this.ws?.readyState === 1) {
632
+ this.ws.send(JSON.stringify(message));
633
+ return true;
634
+ }
635
+ return false;
636
+ }
637
+
638
+ /**
639
+ * Join a room
640
+ */
641
+ joinRoom(roomId) {
642
+ return this.send({ type: 'join-room', roomId });
643
+ }
644
+
645
+ /**
646
+ * Leave a room
647
+ */
648
+ leaveRoom(roomId) {
649
+ this.rooms.delete(roomId);
650
+ return this.send({ type: 'leave-room', roomId });
651
+ }
652
+
653
+ /**
654
+ * Send WebRTC offer to peer
655
+ */
656
+ sendOffer(targetPeerId, offer, connectionId) {
657
+ return this.send({
658
+ type: 'offer',
659
+ to: targetPeerId,
660
+ offer,
661
+ connectionId,
662
+ });
663
+ }
664
+
665
+ /**
666
+ * Send WebRTC answer to peer
667
+ */
668
+ sendAnswer(targetPeerId, answer, connectionId) {
669
+ return this.send({
670
+ type: 'answer',
671
+ to: targetPeerId,
672
+ answer,
673
+ connectionId,
674
+ });
675
+ }
676
+
677
+ /**
678
+ * Send ICE candidate to peer
679
+ */
680
+ sendIceCandidate(targetPeerId, candidate, connectionId) {
681
+ return this.send({
682
+ type: 'ice-candidate',
683
+ to: targetPeerId,
684
+ candidate,
685
+ connectionId,
686
+ });
687
+ }
688
+
689
+ /**
690
+ * Discover peers with capabilities
691
+ */
692
+ discover(capabilities = [], limit = 10) {
693
+ return this.send({
694
+ type: 'discover',
695
+ capabilities,
696
+ limit,
697
+ });
698
+ }
699
+
700
+ /**
701
+ * Broadcast to room
702
+ */
703
+ broadcast(roomId, data) {
704
+ return this.send({
705
+ type: 'broadcast',
706
+ roomId,
707
+ data,
708
+ });
709
+ }
710
+
711
+ /**
712
+ * Ping server
713
+ */
714
+ ping() {
715
+ return this.send({ type: 'ping' });
716
+ }
717
+
718
+ /**
719
+ * Close connection
720
+ */
721
+ close() {
722
+ if (this.ws) {
723
+ this.ws.close();
724
+ }
725
+ }
726
+ }
727
+
728
+ // ============================================
729
+ // EXPORTS
730
+ // ============================================
731
+
732
+ export default SignalingServer;