@ruvector/edge-net 0.3.0 → 0.4.1

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