@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.
- package/cli.js +113 -0
- package/firebase-setup.js +435 -0
- package/firebase-signaling.js +788 -0
- package/p2p.js +129 -4
- package/package.json +28 -4
|
@@ -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;
|