@ruvector/edge-net 0.3.0 → 0.4.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.
@@ -0,0 +1,788 @@
1
+ /**
2
+ * @ruvector/edge-net Firebase Signaling
3
+ *
4
+ * Uses Google Firebase as bootstrap infrastructure for WebRTC signaling
5
+ * with migration path to full P2P DHT network.
6
+ *
7
+ * Architecture:
8
+ * 1. Firebase Firestore for signaling (offer/answer/ICE)
9
+ * 2. Firebase Realtime DB for presence (who's online)
10
+ * 3. Gradual migration to DHT as network grows
11
+ *
12
+ * @module @ruvector/edge-net/firebase-signaling
13
+ */
14
+
15
+ import { EventEmitter } from 'events';
16
+
17
+ // ============================================
18
+ // FIREBASE CONFIGURATION
19
+ // ============================================
20
+
21
+ /**
22
+ * Get Firebase config from environment variables or saved config
23
+ *
24
+ * Configuration sources (in order of priority):
25
+ * 1. Environment variables: FIREBASE_API_KEY, FIREBASE_PROJECT_ID
26
+ * 2. Saved config from: ~/.edge-net/firebase.json (via firebase-setup.js)
27
+ * 3. Application Default Credentials (for server-side via gcloud)
28
+ *
29
+ * SECURITY:
30
+ * - Never hardcode API keys
31
+ * - Use `gcloud auth application-default login` for server-side
32
+ * - Restrict API keys by domain in Google Cloud Console for browser-side
33
+ *
34
+ * Setup:
35
+ * npx edge-net-firebase-setup --project YOUR_PROJECT_ID
36
+ */
37
+ export function getFirebaseConfig() {
38
+ // Try environment variables first (highest priority)
39
+ const apiKey = process.env.FIREBASE_API_KEY;
40
+ const projectId = process.env.FIREBASE_PROJECT_ID;
41
+
42
+ if (apiKey && projectId) {
43
+ return {
44
+ apiKey,
45
+ projectId,
46
+ authDomain: process.env.FIREBASE_AUTH_DOMAIN || `${projectId}.firebaseapp.com`,
47
+ databaseURL: process.env.FIREBASE_DATABASE_URL || `https://${projectId}-default-rtdb.firebaseio.com`,
48
+ storageBucket: process.env.FIREBASE_STORAGE_BUCKET || `${projectId}.appspot.com`,
49
+ };
50
+ }
51
+
52
+ // Sync version only uses env vars - use getFirebaseConfigAsync for file config
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Async version that can load from config file
58
+ */
59
+ export async function getFirebaseConfigAsync() {
60
+ // Try environment variables first
61
+ const apiKey = process.env.FIREBASE_API_KEY;
62
+ const projectId = process.env.FIREBASE_PROJECT_ID;
63
+
64
+ if (apiKey && projectId) {
65
+ return {
66
+ apiKey,
67
+ projectId,
68
+ authDomain: process.env.FIREBASE_AUTH_DOMAIN || `${projectId}.firebaseapp.com`,
69
+ databaseURL: process.env.FIREBASE_DATABASE_URL || `https://${projectId}-default-rtdb.firebaseio.com`,
70
+ storageBucket: process.env.FIREBASE_STORAGE_BUCKET || `${projectId}.appspot.com`,
71
+ };
72
+ }
73
+
74
+ // Try loading saved config
75
+ try {
76
+ const { loadConfig } = await import('./firebase-setup.js');
77
+ const savedConfig = loadConfig();
78
+ if (savedConfig && apiKey) {
79
+ return { apiKey, ...savedConfig };
80
+ }
81
+ // Can work with just project config for server-side with ADC
82
+ if (savedConfig) {
83
+ return savedConfig; // No API key, but has project info (use ADC)
84
+ }
85
+ } catch {
86
+ // firebase-setup.js not available
87
+ }
88
+
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * Default returns null - must be configured via environment or setup
94
+ */
95
+ export const DEFAULT_FIREBASE_CONFIG = null;
96
+
97
+ /**
98
+ * Signaling paths in Firestore
99
+ */
100
+ export const SIGNALING_PATHS = {
101
+ peers: 'edge-net/peers',
102
+ signals: 'edge-net/signals',
103
+ rooms: 'edge-net/rooms',
104
+ ledger: 'edge-net/ledger',
105
+ };
106
+
107
+ // ============================================
108
+ // FIREBASE SIGNALING CLIENT
109
+ // ============================================
110
+
111
+ /**
112
+ * Firebase-based WebRTC Signaling
113
+ *
114
+ * Provides:
115
+ * - Peer discovery via Firestore
116
+ * - WebRTC signaling (offer/answer/ICE)
117
+ * - Presence tracking
118
+ * - Graceful fallback to local/DHT
119
+ */
120
+ export class FirebaseSignaling extends EventEmitter {
121
+ constructor(options = {}) {
122
+ super();
123
+
124
+ // SECURITY: Config must come from options or environment variables
125
+ // Will be loaded async if not provided
126
+ this._configPromise = null;
127
+ this._providedConfig = options.firebaseConfig;
128
+ this.peerId = options.peerId;
129
+ this.room = options.room || 'default';
130
+
131
+ // Initial sync config check (env vars only)
132
+ this.config = options.firebaseConfig || getFirebaseConfig();
133
+
134
+ // Firebase instances (lazy loaded)
135
+ this.app = null;
136
+ this.db = null;
137
+ this.rtdb = null;
138
+
139
+ // State
140
+ this.isConnected = false;
141
+ this.peers = new Map();
142
+ this.pendingSignals = new Map();
143
+
144
+ // Listeners for cleanup
145
+ this.unsubscribers = [];
146
+
147
+ // Migration tracking
148
+ this.stats = {
149
+ firebaseSignals: 0,
150
+ dhtSignals: 0,
151
+ p2pSignals: 0,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Initialize Firebase connection
157
+ */
158
+ async connect() {
159
+ // SECURITY: Require valid config
160
+ if (!this.config || !this.config.apiKey || !this.config.projectId) {
161
+ console.log(' ⚠️ Firebase not configured (no credentials)');
162
+ console.log(' 💡 Set environment variables: FIREBASE_API_KEY, FIREBASE_PROJECT_ID');
163
+ this.emit('not-configured');
164
+ return false;
165
+ }
166
+
167
+ try {
168
+ // Dynamic import Firebase (tree-shakeable)
169
+ const { initializeApp, getApps } = await import('firebase/app');
170
+ const { getFirestore, collection, doc, setDoc, onSnapshot, deleteDoc, query, where, orderBy, limit } = await import('firebase/firestore');
171
+ const { getDatabase, ref, set, onValue, onDisconnect, serverTimestamp } = await import('firebase/database');
172
+
173
+ // Store Firebase methods for later use
174
+ this.firebase = {
175
+ collection, doc, setDoc, onSnapshot, deleteDoc, query, where, orderBy, limit,
176
+ ref, set, onValue, onDisconnect, serverTimestamp
177
+ };
178
+
179
+ // Initialize or reuse existing app
180
+ const apps = getApps();
181
+ this.app = apps.length ? apps[0] : initializeApp(this.config);
182
+
183
+ // Initialize Firestore (for signaling)
184
+ this.db = getFirestore(this.app);
185
+
186
+ // Initialize Realtime Database (for presence)
187
+ this.rtdb = getDatabase(this.app);
188
+
189
+ // Register presence
190
+ await this.registerPresence();
191
+
192
+ // Listen for peers
193
+ this.subscribeToPeers();
194
+
195
+ // Listen for signals
196
+ this.subscribeToSignals();
197
+
198
+ this.isConnected = true;
199
+ console.log(' ✅ Firebase signaling connected');
200
+
201
+ this.emit('connected');
202
+ return true;
203
+
204
+ } catch (error) {
205
+ console.log(' ⚠️ Firebase unavailable:', error.message);
206
+ this.emit('error', error);
207
+ return false;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Register this peer's presence in Firebase
213
+ */
214
+ async registerPresence() {
215
+ const { ref, set, onDisconnect, serverTimestamp } = this.firebase;
216
+
217
+ const presenceRef = ref(this.rtdb, `presence/${this.room}/${this.peerId}`);
218
+
219
+ // Set online status
220
+ await set(presenceRef, {
221
+ peerId: this.peerId,
222
+ room: this.room,
223
+ online: true,
224
+ lastSeen: serverTimestamp(),
225
+ capabilities: ['compute', 'storage', 'verify'],
226
+ });
227
+
228
+ // Remove on disconnect
229
+ onDisconnect(presenceRef).remove();
230
+
231
+ console.log(` 📡 Registered presence: ${this.peerId.slice(0, 8)}...`);
232
+ }
233
+
234
+ /**
235
+ * Subscribe to peer presence updates
236
+ */
237
+ subscribeToPeers() {
238
+ const { ref, onValue } = this.firebase;
239
+
240
+ const roomRef = ref(this.rtdb, `presence/${this.room}`);
241
+
242
+ const unsubscribe = onValue(roomRef, (snapshot) => {
243
+ const peers = snapshot.val() || {};
244
+
245
+ // Track new peers
246
+ for (const [peerId, data] of Object.entries(peers)) {
247
+ if (peerId !== this.peerId && !this.peers.has(peerId)) {
248
+ this.peers.set(peerId, data);
249
+ this.emit('peer-discovered', { peerId, ...data });
250
+ }
251
+ }
252
+
253
+ // Track disconnected peers
254
+ for (const peerId of this.peers.keys()) {
255
+ if (!peers[peerId]) {
256
+ this.peers.delete(peerId);
257
+ this.emit('peer-left', { peerId });
258
+ }
259
+ }
260
+ });
261
+
262
+ this.unsubscribers.push(unsubscribe);
263
+ }
264
+
265
+ /**
266
+ * Subscribe to WebRTC signaling messages
267
+ */
268
+ subscribeToSignals() {
269
+ const { collection, query, where, onSnapshot } = this.firebase;
270
+
271
+ // Listen for signals addressed to this peer
272
+ const signalsRef = collection(this.db, SIGNALING_PATHS.signals);
273
+ const q = query(signalsRef, where('to', '==', this.peerId));
274
+
275
+ const unsubscribe = onSnapshot(q, (snapshot) => {
276
+ snapshot.docChanges().forEach(async (change) => {
277
+ if (change.type === 'added') {
278
+ const signal = change.doc.data();
279
+ this.handleSignal(signal, change.doc.id);
280
+ }
281
+ });
282
+ });
283
+
284
+ this.unsubscribers.push(unsubscribe);
285
+ }
286
+
287
+ /**
288
+ * Handle incoming signal
289
+ */
290
+ async handleSignal(signal, docId) {
291
+ this.stats.firebaseSignals++;
292
+
293
+ // Delete processed signal
294
+ const { doc, deleteDoc } = this.firebase;
295
+ await deleteDoc(doc(this.db, SIGNALING_PATHS.signals, docId));
296
+
297
+ // Emit appropriate event
298
+ switch (signal.type) {
299
+ case 'offer':
300
+ this.emit('offer', { from: signal.from, offer: signal.data });
301
+ break;
302
+ case 'answer':
303
+ this.emit('answer', { from: signal.from, answer: signal.data });
304
+ break;
305
+ case 'ice-candidate':
306
+ this.emit('ice-candidate', { from: signal.from, candidate: signal.data });
307
+ break;
308
+ default:
309
+ this.emit('signal', signal);
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Send WebRTC offer to peer
315
+ */
316
+ async sendOffer(toPeerId, offer) {
317
+ return this.sendSignal(toPeerId, 'offer', offer);
318
+ }
319
+
320
+ /**
321
+ * Send WebRTC answer to peer
322
+ */
323
+ async sendAnswer(toPeerId, answer) {
324
+ return this.sendSignal(toPeerId, 'answer', answer);
325
+ }
326
+
327
+ /**
328
+ * Send ICE candidate to peer
329
+ */
330
+ async sendIceCandidate(toPeerId, candidate) {
331
+ return this.sendSignal(toPeerId, 'ice-candidate', candidate);
332
+ }
333
+
334
+ /**
335
+ * Send signal via Firebase
336
+ */
337
+ async sendSignal(toPeerId, type, data) {
338
+ if (!this.isConnected) {
339
+ throw new Error('Firebase not connected');
340
+ }
341
+
342
+ const { collection, doc, setDoc } = this.firebase;
343
+
344
+ const signalId = `${this.peerId}-${toPeerId}-${Date.now()}`;
345
+ const signalRef = doc(this.db, SIGNALING_PATHS.signals, signalId);
346
+
347
+ await setDoc(signalRef, {
348
+ from: this.peerId,
349
+ to: toPeerId,
350
+ type,
351
+ data,
352
+ timestamp: Date.now(),
353
+ room: this.room,
354
+ });
355
+
356
+ return true;
357
+ }
358
+
359
+ /**
360
+ * Get list of online peers
361
+ */
362
+ getOnlinePeers() {
363
+ return Array.from(this.peers.entries()).map(([id, data]) => ({
364
+ id,
365
+ ...data,
366
+ }));
367
+ }
368
+
369
+ /**
370
+ * Disconnect and cleanup
371
+ */
372
+ async disconnect() {
373
+ // Unsubscribe from all listeners
374
+ for (const unsub of this.unsubscribers) {
375
+ if (typeof unsub === 'function') unsub();
376
+ }
377
+ this.unsubscribers = [];
378
+
379
+ // Remove presence
380
+ if (this.rtdb && this.firebase) {
381
+ const { ref, set } = this.firebase;
382
+ const presenceRef = ref(this.rtdb, `presence/${this.room}/${this.peerId}`);
383
+ await set(presenceRef, null);
384
+ }
385
+
386
+ this.isConnected = false;
387
+ this.peers.clear();
388
+
389
+ this.emit('disconnected');
390
+ }
391
+ }
392
+
393
+ // ============================================
394
+ // FIREBASE LEDGER SYNC
395
+ // ============================================
396
+
397
+ /**
398
+ * Firebase-based Ledger Synchronization
399
+ *
400
+ * Syncs CRDT ledger state across peers using Firestore
401
+ * with automatic CRDT merge on conflicts.
402
+ */
403
+ export class FirebaseLedgerSync extends EventEmitter {
404
+ constructor(ledger, options = {}) {
405
+ super();
406
+
407
+ this.ledger = ledger;
408
+ this.peerId = options.peerId;
409
+ // SECURITY: Config must come from options or environment variables
410
+ this.config = options.firebaseConfig || getFirebaseConfig();
411
+
412
+ // Firebase instances
413
+ this.app = null;
414
+ this.db = null;
415
+
416
+ // Sync state
417
+ this.lastSyncedVersion = 0;
418
+ this.syncInterval = options.syncInterval || 30000;
419
+ this.syncTimer = null;
420
+
421
+ this.unsubscribers = [];
422
+ }
423
+
424
+ /**
425
+ * Start ledger sync
426
+ */
427
+ async start() {
428
+ // SECURITY: Require valid config
429
+ if (!this.config || !this.config.apiKey || !this.config.projectId) {
430
+ console.log(' ⚠️ Firebase ledger sync disabled (no credentials)');
431
+ return false;
432
+ }
433
+
434
+ try {
435
+ const { initializeApp, getApps } = await import('firebase/app');
436
+ const { getFirestore, doc, setDoc, onSnapshot, getDoc } = await import('firebase/firestore');
437
+
438
+ this.firebase = { doc, setDoc, onSnapshot, getDoc };
439
+
440
+ const apps = getApps();
441
+ this.app = apps.length ? apps[0] : initializeApp(this.config);
442
+ this.db = getFirestore(this.app);
443
+
444
+ // Initial sync from Firebase
445
+ await this.pullLedger();
446
+
447
+ // Subscribe to ledger updates
448
+ this.subscribeLedger();
449
+
450
+ // Periodic push
451
+ this.syncTimer = setInterval(() => this.pushLedger(), this.syncInterval);
452
+
453
+ console.log(' ✅ Firebase ledger sync started');
454
+ return true;
455
+
456
+ } catch (error) {
457
+ console.log(' ⚠️ Firebase ledger sync unavailable:', error.message);
458
+ return false;
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Pull ledger state from Firebase
464
+ */
465
+ async pullLedger() {
466
+ const { doc, getDoc } = this.firebase;
467
+
468
+ const ledgerRef = doc(this.db, SIGNALING_PATHS.ledger, this.peerId);
469
+ const snapshot = await getDoc(ledgerRef);
470
+
471
+ if (snapshot.exists()) {
472
+ const remoteState = snapshot.data();
473
+ this.mergeLedger(remoteState);
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Push ledger state to Firebase
479
+ */
480
+ async pushLedger() {
481
+ if (!this.ledger) return;
482
+
483
+ const { doc, setDoc } = this.firebase;
484
+
485
+ const state = this.ledger.export();
486
+ const ledgerRef = doc(this.db, SIGNALING_PATHS.ledger, this.peerId);
487
+
488
+ await setDoc(ledgerRef, {
489
+ ...state,
490
+ peerId: this.peerId,
491
+ updatedAt: Date.now(),
492
+ }, { merge: true });
493
+ }
494
+
495
+ /**
496
+ * Subscribe to ledger updates from other peers
497
+ */
498
+ subscribeLedger() {
499
+ const { doc, onSnapshot } = this.firebase;
500
+
501
+ // For now, just sync own ledger
502
+ // Full multi-peer sync would subscribe to all peers
503
+ const ledgerRef = doc(this.db, SIGNALING_PATHS.ledger, this.peerId);
504
+
505
+ const unsubscribe = onSnapshot(ledgerRef, (snapshot) => {
506
+ if (snapshot.exists()) {
507
+ const remoteState = snapshot.data();
508
+ if (remoteState.updatedAt > this.lastSyncedVersion) {
509
+ this.mergeLedger(remoteState);
510
+ this.lastSyncedVersion = remoteState.updatedAt;
511
+ }
512
+ }
513
+ });
514
+
515
+ this.unsubscribers.push(unsubscribe);
516
+ }
517
+
518
+ /**
519
+ * Merge remote ledger state using CRDT rules
520
+ */
521
+ mergeLedger(remoteState) {
522
+ if (!this.ledger || !remoteState) return;
523
+
524
+ // CRDT merge: take max of counters
525
+ if (remoteState.credits !== undefined) {
526
+ const localCredits = this.ledger.getBalance?.() || 0;
527
+ if (remoteState.credits > localCredits) {
528
+ // Remote has more - need to import
529
+ this.ledger.import?.(remoteState);
530
+ this.emit('synced', { source: 'firebase', credits: remoteState.credits });
531
+ }
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Stop sync
537
+ */
538
+ stop() {
539
+ if (this.syncTimer) {
540
+ clearInterval(this.syncTimer);
541
+ this.syncTimer = null;
542
+ }
543
+
544
+ for (const unsub of this.unsubscribers) {
545
+ if (typeof unsub === 'function') unsub();
546
+ }
547
+ this.unsubscribers = [];
548
+ }
549
+ }
550
+
551
+ // ============================================
552
+ // HYBRID BOOTSTRAP MANAGER
553
+ // ============================================
554
+
555
+ /**
556
+ * Hybrid Bootstrap Manager
557
+ *
558
+ * Manages the migration from Firebase bootstrap to full P2P:
559
+ * 1. Start with Firebase for discovery and signaling
560
+ * 2. Establish WebRTC connections to peers
561
+ * 3. Build DHT routing table from connected peers
562
+ * 4. Gradually reduce Firebase dependency
563
+ * 5. Eventually operate fully P2P
564
+ */
565
+ export class HybridBootstrap extends EventEmitter {
566
+ constructor(options = {}) {
567
+ super();
568
+
569
+ this.peerId = options.peerId;
570
+ this.config = options.firebaseConfig || DEFAULT_FIREBASE_CONFIG;
571
+
572
+ // Components
573
+ this.firebase = null;
574
+ this.dht = null;
575
+ this.webrtc = null;
576
+
577
+ // Migration state
578
+ this.mode = 'firebase'; // firebase -> hybrid -> p2p
579
+ this.dhtPeerThreshold = options.dhtPeerThreshold || 5;
580
+ this.p2pPeerThreshold = options.p2pPeerThreshold || 10;
581
+
582
+ // Stats for migration decisions
583
+ this.stats = {
584
+ firebaseDiscoveries: 0,
585
+ dhtDiscoveries: 0,
586
+ directConnections: 0,
587
+ firebaseSignals: 0,
588
+ p2pSignals: 0,
589
+ };
590
+ }
591
+
592
+ /**
593
+ * Start hybrid bootstrap
594
+ */
595
+ async start(webrtc, dht) {
596
+ this.webrtc = webrtc;
597
+ this.dht = dht;
598
+
599
+ // Start with Firebase
600
+ this.firebase = new FirebaseSignaling({
601
+ peerId: this.peerId,
602
+ firebaseConfig: this.config,
603
+ });
604
+
605
+ // Wire up events
606
+ this.setupFirebaseEvents();
607
+
608
+ // Connect to Firebase
609
+ const connected = await this.firebase.connect();
610
+
611
+ if (connected) {
612
+ console.log(' 🔄 Hybrid bootstrap: Firebase mode');
613
+ this.mode = 'firebase';
614
+ } else {
615
+ console.log(' 🔄 Hybrid bootstrap: DHT-only mode');
616
+ this.mode = 'p2p';
617
+ }
618
+
619
+ // Start migration checker
620
+ this.startMigrationChecker();
621
+
622
+ return connected;
623
+ }
624
+
625
+ /**
626
+ * Setup Firebase event handlers
627
+ */
628
+ setupFirebaseEvents() {
629
+ this.firebase.on('peer-discovered', async ({ peerId }) => {
630
+ this.stats.firebaseDiscoveries++;
631
+
632
+ // Try to connect via WebRTC
633
+ if (this.webrtc) {
634
+ await this.connectToPeer(peerId);
635
+ }
636
+
637
+ this.emit('peer-discovered', { peerId, source: 'firebase' });
638
+ });
639
+
640
+ this.firebase.on('offer', async ({ from, offer }) => {
641
+ this.stats.firebaseSignals++;
642
+ if (this.webrtc) {
643
+ await this.webrtc.handleOffer({ from, offer });
644
+ }
645
+ });
646
+
647
+ this.firebase.on('answer', async ({ from, answer }) => {
648
+ this.stats.firebaseSignals++;
649
+ if (this.webrtc) {
650
+ await this.webrtc.handleAnswer({ from, answer });
651
+ }
652
+ });
653
+
654
+ this.firebase.on('ice-candidate', async ({ from, candidate }) => {
655
+ if (this.webrtc) {
656
+ await this.webrtc.handleIceCandidate({ from, candidate });
657
+ }
658
+ });
659
+ }
660
+
661
+ /**
662
+ * Connect to peer with signaling fallback
663
+ */
664
+ async connectToPeer(peerId) {
665
+ if (!this.webrtc) return;
666
+
667
+ try {
668
+ // Create offer
669
+ const offer = await this.webrtc.createOffer(peerId);
670
+
671
+ // Try P2P signaling first (if peer is directly connected)
672
+ if (this.mode === 'p2p' && this.webrtc.isConnected(peerId)) {
673
+ this.webrtc.sendToPeer(peerId, { type: 'offer', offer });
674
+ this.stats.p2pSignals++;
675
+ } else {
676
+ // Fall back to Firebase
677
+ await this.firebase.sendOffer(peerId, offer);
678
+ this.stats.firebaseSignals++;
679
+ }
680
+
681
+ } catch (error) {
682
+ console.warn(`[HybridBootstrap] Connect to ${peerId.slice(0, 8)} failed:`, error.message);
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Send signaling message with automatic routing
688
+ */
689
+ async signal(toPeerId, type, data) {
690
+ // Prefer P2P if available
691
+ if (this.webrtc?.isConnected(toPeerId)) {
692
+ this.webrtc.sendToPeer(toPeerId, { type, data });
693
+ this.stats.p2pSignals++;
694
+ return;
695
+ }
696
+
697
+ // Fall back to Firebase
698
+ if (this.firebase?.isConnected) {
699
+ await this.firebase.sendSignal(toPeerId, type, data);
700
+ this.stats.firebaseSignals++;
701
+ return;
702
+ }
703
+
704
+ throw new Error('No signaling path available');
705
+ }
706
+
707
+ /**
708
+ * Start migration checker
709
+ * Monitors network health and decides when to reduce Firebase dependency
710
+ */
711
+ startMigrationChecker() {
712
+ setInterval(() => {
713
+ this.checkMigration();
714
+ }, 30000);
715
+ }
716
+
717
+ /**
718
+ * Check if we should migrate modes
719
+ */
720
+ checkMigration() {
721
+ const connectedPeers = this.webrtc?.peers?.size || 0;
722
+ const dhtPeers = this.dht?.getPeers?.()?.length || 0;
723
+
724
+ const previousMode = this.mode;
725
+
726
+ // Migration logic
727
+ if (this.mode === 'firebase') {
728
+ // Migrate to hybrid when we have enough DHT peers
729
+ if (dhtPeers >= this.dhtPeerThreshold) {
730
+ this.mode = 'hybrid';
731
+ console.log(` 🔄 Migration: firebase → hybrid (${dhtPeers} DHT peers)`);
732
+ }
733
+ } else if (this.mode === 'hybrid') {
734
+ // Migrate to full P2P when we have strong peer connectivity
735
+ if (connectedPeers >= this.p2pPeerThreshold) {
736
+ this.mode = 'p2p';
737
+ console.log(` 🔄 Migration: hybrid → p2p (${connectedPeers} direct peers)`);
738
+
739
+ // Could disconnect Firebase here to save resources
740
+ // this.firebase.disconnect();
741
+ }
742
+ // Fall back to Firebase if DHT shrinks
743
+ else if (dhtPeers < this.dhtPeerThreshold / 2) {
744
+ this.mode = 'firebase';
745
+ console.log(` 🔄 Migration: hybrid → firebase (DHT peers dropped)`);
746
+ }
747
+ } else if (this.mode === 'p2p') {
748
+ // Fall back to hybrid if peers drop
749
+ if (connectedPeers < this.p2pPeerThreshold / 2) {
750
+ this.mode = 'hybrid';
751
+ console.log(` 🔄 Migration: p2p → hybrid (peers dropped)`);
752
+ }
753
+ }
754
+
755
+ if (this.mode !== previousMode) {
756
+ this.emit('mode-changed', { from: previousMode, to: this.mode });
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Get current bootstrap stats
762
+ */
763
+ getStats() {
764
+ return {
765
+ mode: this.mode,
766
+ ...this.stats,
767
+ firebaseConnected: this.firebase?.isConnected || false,
768
+ firebasePeers: this.firebase?.peers?.size || 0,
769
+ dhtPeers: this.dht?.getPeers?.()?.length || 0,
770
+ directPeers: this.webrtc?.peers?.size || 0,
771
+ };
772
+ }
773
+
774
+ /**
775
+ * Stop bootstrap
776
+ */
777
+ async stop() {
778
+ if (this.firebase) {
779
+ await this.firebase.disconnect();
780
+ }
781
+ }
782
+ }
783
+
784
+ // ============================================
785
+ // EXPORTS
786
+ // ============================================
787
+
788
+ export default FirebaseSignaling;