@ruvector/edge-net 0.4.2 → 0.4.3

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/webrtc.js CHANGED
@@ -12,48 +12,152 @@
12
12
  * - Connection quality monitoring
13
13
  * - Automatic reconnection
14
14
  * - QDAG synchronization over data channels
15
+ * - Configurable TURN/STUN via environment variables
16
+ * - ICE connection diagnostics
15
17
  */
16
18
 
17
19
  import { EventEmitter } from 'events';
18
20
  import { createHash, randomBytes } from 'crypto';
19
21
 
20
- // WebRTC Configuration
21
- export const WEBRTC_CONFIG = {
22
- iceServers: [
23
- // STUN servers (free, for NAT discovery)
22
+ /**
23
+ * Environment variable configuration for ICE servers
24
+ *
25
+ * Environment Variables:
26
+ * - EDGE_NET_STUN_SERVERS: Comma-separated STUN server URLs
27
+ * - EDGE_NET_TURN_URL: TURN server URL
28
+ * - EDGE_NET_TURN_USERNAME: TURN username
29
+ * - EDGE_NET_TURN_CREDENTIAL: TURN password/credential
30
+ * - EDGE_NET_TURN_URL_TCP: TURN server URL for TCP transport
31
+ * - EDGE_NET_ICE_TRANSPORT_POLICY: 'all' or 'relay' (force TURN)
32
+ * - EDGE_NET_SIGNALING_SERVERS: Comma-separated signaling server URLs
33
+ */
34
+
35
+ // Get environment safely (works in Node.js, browser with bundler support)
36
+ const getEnv = (key, defaultValue = '') => {
37
+ if (typeof process !== 'undefined' && process.env) {
38
+ return process.env[key] || defaultValue;
39
+ }
40
+ if (typeof globalThis !== 'undefined' && globalThis.__EDGE_NET_ENV__) {
41
+ return globalThis.__EDGE_NET_ENV__[key] || defaultValue;
42
+ }
43
+ return defaultValue;
44
+ };
45
+
46
+ /**
47
+ * Parse STUN servers from environment or use defaults
48
+ */
49
+ function getStunServers() {
50
+ const envStun = getEnv('EDGE_NET_STUN_SERVERS');
51
+ if (envStun) {
52
+ return envStun.split(',').map(url => ({ urls: url.trim() }));
53
+ }
54
+ // Default STUN servers (free, reliable)
55
+ return [
24
56
  { urls: 'stun:stun.l.google.com:19302' },
25
57
  { urls: 'stun:stun1.l.google.com:19302' },
58
+ { urls: 'stun:stun2.l.google.com:19302' },
26
59
  { urls: 'stun:stun.cloudflare.com:3478' },
27
60
  { urls: 'stun:stun.services.mozilla.com:3478' },
28
- // Free TURN servers for NAT traversal (relay)
29
- {
30
- urls: 'turn:openrelay.metered.ca:80',
31
- username: 'openrelayproject',
32
- credential: 'openrelayproject',
33
- },
34
- {
35
- urls: 'turn:openrelay.metered.ca:443',
36
- username: 'openrelayproject',
37
- credential: 'openrelayproject',
38
- },
39
- {
40
- urls: 'turn:openrelay.metered.ca:443?transport=tcp',
41
- username: 'openrelayproject',
42
- credential: 'openrelayproject',
43
- },
44
- // Alternative free TURN
45
- {
46
- urls: 'turn:relay.metered.ca:80',
47
- username: 'e8a437a4c4d4e5f6a7b8c9d0',
48
- credential: 'freePublicTurnServer',
49
- },
50
- ],
51
- // Signaling server endpoints (priority order)
52
- signalingServers: [
61
+ { urls: 'stun:stun.stunprotocol.org:3478' },
62
+ ];
63
+ }
64
+
65
+ /**
66
+ * Parse TURN servers from environment or use defaults
67
+ */
68
+ function getTurnServers() {
69
+ const turnUrl = getEnv('EDGE_NET_TURN_URL');
70
+ const turnUsername = getEnv('EDGE_NET_TURN_USERNAME');
71
+ const turnCredential = getEnv('EDGE_NET_TURN_CREDENTIAL');
72
+ const turnUrlTcp = getEnv('EDGE_NET_TURN_URL_TCP');
73
+
74
+ const servers = [];
75
+
76
+ // Custom TURN from environment
77
+ if (turnUrl && turnUsername && turnCredential) {
78
+ servers.push({
79
+ urls: turnUrl,
80
+ username: turnUsername,
81
+ credential: turnCredential,
82
+ });
83
+
84
+ // Add TCP transport variant if specified
85
+ if (turnUrlTcp) {
86
+ servers.push({
87
+ urls: turnUrlTcp,
88
+ username: turnUsername,
89
+ credential: turnCredential,
90
+ });
91
+ }
92
+ }
93
+
94
+ // Add default free TURN servers if no custom ones
95
+ if (servers.length === 0) {
96
+ servers.push(
97
+ // Metered.ca free tier (limited bandwidth, good for testing)
98
+ {
99
+ urls: 'turn:openrelay.metered.ca:80',
100
+ username: 'openrelayproject',
101
+ credential: 'openrelayproject',
102
+ },
103
+ {
104
+ urls: 'turn:openrelay.metered.ca:443',
105
+ username: 'openrelayproject',
106
+ credential: 'openrelayproject',
107
+ },
108
+ {
109
+ urls: 'turn:openrelay.metered.ca:443?transport=tcp',
110
+ username: 'openrelayproject',
111
+ credential: 'openrelayproject',
112
+ },
113
+ // Alternative relay
114
+ {
115
+ urls: 'turn:relay.metered.ca:80',
116
+ username: 'e8a437a4c4d4e5f6a7b8c9d0',
117
+ credential: 'freePublicTurnServer',
118
+ }
119
+ );
120
+ }
121
+
122
+ return servers;
123
+ }
124
+
125
+ /**
126
+ * Get signaling servers from environment or use defaults
127
+ */
128
+ function getSignalingServers() {
129
+ const envSignaling = getEnv('EDGE_NET_SIGNALING_SERVERS');
130
+ if (envSignaling) {
131
+ return envSignaling.split(',').map(url => url.trim());
132
+ }
133
+ return [
53
134
  'ws://localhost:8787', // Local signaling server first
54
135
  'ws://127.0.0.1:8787', // Local alternative
55
136
  'wss://edge-net-signal.ruvector.dev', // Production (when deployed)
56
- ],
137
+ ];
138
+ }
139
+
140
+ /**
141
+ * Build complete ICE configuration
142
+ */
143
+ function buildIceConfig() {
144
+ const iceTransportPolicy = getEnv('EDGE_NET_ICE_TRANSPORT_POLICY', 'all');
145
+
146
+ return {
147
+ iceServers: [...getStunServers(), ...getTurnServers()],
148
+ iceTransportPolicy, // 'all' = STUN+TURN, 'relay' = TURN only
149
+ iceCandidatePoolSize: 10, // Pre-gather candidates for faster connection
150
+ bundlePolicy: 'max-bundle',
151
+ rtcpMuxPolicy: 'require',
152
+ };
153
+ }
154
+
155
+ // WebRTC Configuration
156
+ export const WEBRTC_CONFIG = {
157
+ // Dynamic ICE configuration
158
+ ...buildIceConfig(),
159
+ // Signaling server endpoints (priority order)
160
+ signalingServers: getSignalingServers(),
57
161
  // Fallback to local DHT if no signaling available
58
162
  fallbackToSimulation: false,
59
163
  fallbackToDHT: true,
@@ -78,6 +182,91 @@ export const WEBRTC_CONFIG = {
78
182
  },
79
183
  };
80
184
 
185
+ /**
186
+ * ICE Server Providers Configuration
187
+ *
188
+ * Popular TURN/STUN providers with their configuration patterns.
189
+ * Use these as reference for configuring your production environment.
190
+ */
191
+ export const ICE_PROVIDERS = {
192
+ // Self-hosted coturn (recommended for production)
193
+ coturn: {
194
+ name: 'Self-hosted coturn',
195
+ config: (host, username, credential) => ([
196
+ { urls: `stun:${host}:3478` },
197
+ { urls: `turn:${host}:3478`, username, credential },
198
+ { urls: `turn:${host}:3478?transport=tcp`, username, credential },
199
+ { urls: `turns:${host}:5349`, username, credential }, // TLS
200
+ ]),
201
+ docs: 'https://github.com/coturn/coturn',
202
+ },
203
+
204
+ // Metered.ca (free tier available)
205
+ metered: {
206
+ name: 'Metered TURN',
207
+ config: (apiKey) => ([
208
+ { urls: 'stun:stun.relay.metered.ca:80' },
209
+ {
210
+ urls: 'turn:a.relay.metered.ca:80',
211
+ username: apiKey,
212
+ credential: apiKey,
213
+ },
214
+ {
215
+ urls: 'turn:a.relay.metered.ca:443',
216
+ username: apiKey,
217
+ credential: apiKey,
218
+ },
219
+ {
220
+ urls: 'turn:a.relay.metered.ca:443?transport=tcp',
221
+ username: apiKey,
222
+ credential: apiKey,
223
+ },
224
+ ]),
225
+ pricing: 'Free: 500MB/month, Paid: from $0.40/GB',
226
+ docs: 'https://www.metered.ca/stun-turn',
227
+ },
228
+
229
+ // Twilio (pay-as-you-go)
230
+ twilio: {
231
+ name: 'Twilio Network Traversal',
232
+ // Note: Twilio requires fetching ephemeral credentials via API
233
+ config: (username, credential) => ([
234
+ { urls: 'stun:global.stun.twilio.com:3478' },
235
+ {
236
+ urls: 'turn:global.turn.twilio.com:3478?transport=udp',
237
+ username,
238
+ credential,
239
+ },
240
+ {
241
+ urls: 'turn:global.turn.twilio.com:3478?transport=tcp',
242
+ username,
243
+ credential,
244
+ },
245
+ {
246
+ urls: 'turn:global.turn.twilio.com:443?transport=tcp',
247
+ username,
248
+ credential,
249
+ },
250
+ ]),
251
+ pricing: '$0.40/GB',
252
+ docs: 'https://www.twilio.com/docs/stun-turn',
253
+ },
254
+
255
+ // Xirsys (global coverage)
256
+ xirsys: {
257
+ name: 'Xirsys',
258
+ config: (username, credential, domain) => ([
259
+ { urls: `stun:${domain}:3478` },
260
+ { urls: `turn:${domain}:80?transport=udp`, username, credential },
261
+ { urls: `turn:${domain}:3478?transport=udp`, username, credential },
262
+ { urls: `turn:${domain}:80?transport=tcp`, username, credential },
263
+ { urls: `turn:${domain}:3478?transport=tcp`, username, credential },
264
+ ]),
265
+ pricing: 'Free: 500MB/month, Paid: from $24.99/month',
266
+ docs: 'https://xirsys.com/',
267
+ },
268
+ };
269
+
81
270
  /**
82
271
  * WebRTC Peer Connection Manager
83
272
  *
@@ -104,6 +293,9 @@ export class WebRTCPeerConnection extends EventEmitter {
104
293
  latency: [],
105
294
  connectionTime: null,
106
295
  };
296
+ // WebRTC classes (set during initialize for Node.js compatibility)
297
+ this._RTCSessionDescription = null;
298
+ this._RTCIceCandidate = null;
107
299
  }
108
300
 
109
301
  /**
@@ -111,13 +303,18 @@ export class WebRTCPeerConnection extends EventEmitter {
111
303
  */
112
304
  async initialize() {
113
305
  // Use wrtc for Node.js or native WebRTC in browser
114
- const RTCPeerConnection = globalThis.RTCPeerConnection ||
115
- (await this.loadNodeWebRTC());
306
+ const webrtcClasses = await this.loadWebRTCClasses();
116
307
 
117
- if (!RTCPeerConnection) {
308
+ if (!webrtcClasses) {
118
309
  throw new Error('WebRTC not available');
119
310
  }
120
311
 
312
+ const { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } = webrtcClasses;
313
+
314
+ // Store classes for later use in handleOffer/handleAnswer/addIceCandidate
315
+ this._RTCSessionDescription = RTCSessionDescription;
316
+ this._RTCIceCandidate = RTCIceCandidate;
317
+
121
318
  this.pc = new RTCPeerConnection({
122
319
  iceServers: WEBRTC_CONFIG.iceServers,
123
320
  });
@@ -132,15 +329,31 @@ export class WebRTCPeerConnection extends EventEmitter {
132
329
  }
133
330
 
134
331
  /**
135
- * Load wrtc for Node.js environment
332
+ * Load WebRTC classes (from browser globals or wrtc package)
136
333
  */
137
- async loadNodeWebRTC() {
334
+ async loadWebRTCClasses() {
335
+ // Check for browser globals first
336
+ if (globalThis.RTCPeerConnection) {
337
+ return {
338
+ RTCPeerConnection: globalThis.RTCPeerConnection,
339
+ RTCSessionDescription: globalThis.RTCSessionDescription,
340
+ RTCIceCandidate: globalThis.RTCIceCandidate,
341
+ };
342
+ }
343
+
344
+ // Try to load wrtc for Node.js
138
345
  try {
139
- const wrtc = await import('wrtc');
140
- return wrtc.RTCPeerConnection;
346
+ const wrtcModule = await import('wrtc');
347
+ // wrtc exports everything under default in ESM
348
+ const wrtc = wrtcModule.default || wrtcModule;
349
+ return {
350
+ RTCPeerConnection: wrtc.RTCPeerConnection,
351
+ RTCSessionDescription: wrtc.RTCSessionDescription,
352
+ RTCIceCandidate: wrtc.RTCIceCandidate,
353
+ };
141
354
  } catch (err) {
142
- // wrtc not available, will use simulation
143
- console.warn('WebRTC not available in Node.js, using simulation');
355
+ // wrtc not available
356
+ console.warn('WebRTC not available in Node.js:', err.message);
144
357
  return null;
145
358
  }
146
359
  }
@@ -234,6 +447,10 @@ export class WebRTCPeerConnection extends EventEmitter {
234
447
  * Handle incoming offer and create answer
235
448
  */
236
449
  async handleOffer(offer) {
450
+ // Create RTCSessionDescription using stored class reference
451
+ const RTCSessionDescription = this._RTCSessionDescription || globalThis.RTCSessionDescription;
452
+ const RTCIceCandidate = this._RTCIceCandidate || globalThis.RTCIceCandidate;
453
+
237
454
  await this.pc.setRemoteDescription(new RTCSessionDescription(offer));
238
455
 
239
456
  // Process any pending ICE candidates
@@ -251,6 +468,10 @@ export class WebRTCPeerConnection extends EventEmitter {
251
468
  * Handle incoming answer
252
469
  */
253
470
  async handleAnswer(answer) {
471
+ // Create RTCSessionDescription using stored class reference
472
+ const RTCSessionDescription = this._RTCSessionDescription || globalThis.RTCSessionDescription;
473
+ const RTCIceCandidate = this._RTCIceCandidate || globalThis.RTCIceCandidate;
474
+
254
475
  await this.pc.setRemoteDescription(new RTCSessionDescription(answer));
255
476
 
256
477
  // Process any pending ICE candidates
@@ -264,6 +485,8 @@ export class WebRTCPeerConnection extends EventEmitter {
264
485
  * Add ICE candidate
265
486
  */
266
487
  async addIceCandidate(candidate) {
488
+ const RTCIceCandidate = this._RTCIceCandidate || globalThis.RTCIceCandidate;
489
+
267
490
  if (this.pc.remoteDescription) {
268
491
  await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
269
492
  } else {
@@ -772,7 +995,8 @@ export class WebRTCPeerManager extends EventEmitter {
772
995
  * Setup event handlers for a peer connection
773
996
  */
774
997
  setupPeerHandlers(peerConnection) {
775
- peerConnection.on('ice-candidate', ({ candidate }) => {
998
+ peerConnection.on('ice-candidate', async ({ candidate }) => {
999
+ // Forward ICE candidate via available signaling method
776
1000
  if (this.signalingSocket?.readyState === 1) {
777
1001
  this.signalingSocket.send(JSON.stringify({
778
1002
  type: 'ice-candidate',
@@ -780,6 +1004,13 @@ export class WebRTCPeerManager extends EventEmitter {
780
1004
  from: this.localIdentity.piKey,
781
1005
  candidate,
782
1006
  }));
1007
+ } else if (this.externalSignaling) {
1008
+ // Use external signaling (Firebase, etc.)
1009
+ try {
1010
+ await this.externalSignaling('ice-candidate', peerConnection.peerId, candidate);
1011
+ } catch (err) {
1012
+ console.warn('Failed to send ICE candidate via external signaling:', err.message);
1013
+ }
783
1014
  }
784
1015
  });
785
1016
 
@@ -1011,10 +1242,432 @@ export class QDAGSynchronizer extends EventEmitter {
1011
1242
  }
1012
1243
  }
1013
1244
 
1245
+ /**
1246
+ * ICE Connection Diagnostics
1247
+ *
1248
+ * Provides comprehensive diagnostics for ICE connectivity issues,
1249
+ * helping identify NAT types, test STUN/TURN servers, and diagnose
1250
+ * connection failures.
1251
+ */
1252
+ export class ICEDiagnostics extends EventEmitter {
1253
+ constructor(options = {}) {
1254
+ super();
1255
+ this.options = {
1256
+ timeout: options.timeout || 10000,
1257
+ iceServers: options.iceServers || WEBRTC_CONFIG.iceServers,
1258
+ verbose: options.verbose || false,
1259
+ };
1260
+ this.results = {
1261
+ startTime: null,
1262
+ endTime: null,
1263
+ natType: 'unknown',
1264
+ stunResults: [],
1265
+ turnResults: [],
1266
+ candidateTypes: [],
1267
+ errors: [],
1268
+ recommendations: [],
1269
+ };
1270
+ }
1271
+
1272
+ /**
1273
+ * Run full ICE diagnostics suite
1274
+ */
1275
+ async runDiagnostics() {
1276
+ this.results.startTime = Date.now();
1277
+ this.log('Starting ICE diagnostics...');
1278
+
1279
+ try {
1280
+ // Test STUN servers
1281
+ await this.testStunServers();
1282
+
1283
+ // Test TURN servers
1284
+ await this.testTurnServers();
1285
+
1286
+ // Determine NAT type
1287
+ this.determineNatType();
1288
+
1289
+ // Generate recommendations
1290
+ this.generateRecommendations();
1291
+
1292
+ } catch (err) {
1293
+ this.results.errors.push({
1294
+ phase: 'diagnostics',
1295
+ error: err.message,
1296
+ });
1297
+ }
1298
+
1299
+ this.results.endTime = Date.now();
1300
+ this.results.duration = this.results.endTime - this.results.startTime;
1301
+
1302
+ return this.results;
1303
+ }
1304
+
1305
+ /**
1306
+ * Test STUN servers for reachability
1307
+ */
1308
+ async testStunServers() {
1309
+ const stunServers = this.options.iceServers.filter(
1310
+ server => server.urls && server.urls.startsWith('stun:')
1311
+ );
1312
+
1313
+ this.log(`Testing ${stunServers.length} STUN servers...`);
1314
+
1315
+ for (const server of stunServers) {
1316
+ const result = await this.testIceServer(server, 'stun');
1317
+ this.results.stunResults.push(result);
1318
+ this.emit('stun-result', result);
1319
+ }
1320
+ }
1321
+
1322
+ /**
1323
+ * Test TURN servers for reachability and authentication
1324
+ */
1325
+ async testTurnServers() {
1326
+ const turnServers = this.options.iceServers.filter(
1327
+ server => server.urls && (
1328
+ server.urls.startsWith('turn:') ||
1329
+ server.urls.startsWith('turns:')
1330
+ )
1331
+ );
1332
+
1333
+ this.log(`Testing ${turnServers.length} TURN servers...`);
1334
+
1335
+ for (const server of turnServers) {
1336
+ const result = await this.testIceServer(server, 'turn');
1337
+ this.results.turnResults.push(result);
1338
+ this.emit('turn-result', result);
1339
+ }
1340
+ }
1341
+
1342
+ /**
1343
+ * Test a single ICE server
1344
+ */
1345
+ async testIceServer(server, type) {
1346
+ const startTime = Date.now();
1347
+ const result = {
1348
+ url: server.urls,
1349
+ type,
1350
+ success: false,
1351
+ latency: null,
1352
+ candidateType: null,
1353
+ error: null,
1354
+ };
1355
+
1356
+ try {
1357
+ // Get RTCPeerConnection
1358
+ const RTCPeerConnection = globalThis.RTCPeerConnection ||
1359
+ (await this.loadNodeWebRTC());
1360
+
1361
+ if (!RTCPeerConnection) {
1362
+ result.error = 'WebRTC not available';
1363
+ return result;
1364
+ }
1365
+
1366
+ const pc = new RTCPeerConnection({
1367
+ iceServers: [server],
1368
+ iceTransportPolicy: type === 'turn' ? 'relay' : 'all',
1369
+ });
1370
+
1371
+ const candidatePromise = new Promise((resolve, reject) => {
1372
+ const timeout = setTimeout(() => {
1373
+ reject(new Error('ICE gathering timeout'));
1374
+ }, this.options.timeout);
1375
+
1376
+ pc.onicecandidate = (event) => {
1377
+ if (event.candidate) {
1378
+ clearTimeout(timeout);
1379
+ resolve(event.candidate);
1380
+ }
1381
+ };
1382
+
1383
+ pc.onicegatheringstatechange = () => {
1384
+ if (pc.iceGatheringState === 'complete') {
1385
+ clearTimeout(timeout);
1386
+ reject(new Error('No candidates gathered'));
1387
+ }
1388
+ };
1389
+ });
1390
+
1391
+ // Create data channel to trigger ICE gathering
1392
+ pc.createDataChannel('test');
1393
+
1394
+ // Create offer
1395
+ const offer = await pc.createOffer();
1396
+ await pc.setLocalDescription(offer);
1397
+
1398
+ // Wait for candidate
1399
+ const candidate = await candidatePromise;
1400
+
1401
+ result.success = true;
1402
+ result.latency = Date.now() - startTime;
1403
+ result.candidateType = candidate.type;
1404
+ result.candidate = {
1405
+ type: candidate.type,
1406
+ protocol: candidate.protocol,
1407
+ address: candidate.address,
1408
+ port: candidate.port,
1409
+ };
1410
+
1411
+ // Track candidate types
1412
+ if (!this.results.candidateTypes.includes(candidate.type)) {
1413
+ this.results.candidateTypes.push(candidate.type);
1414
+ }
1415
+
1416
+ pc.close();
1417
+
1418
+ } catch (err) {
1419
+ result.error = err.message;
1420
+ result.latency = Date.now() - startTime;
1421
+ }
1422
+
1423
+ this.log(` ${type.toUpperCase()} ${server.urls}: ${result.success ? 'OK' : 'FAILED'} (${result.latency}ms)`);
1424
+ return result;
1425
+ }
1426
+
1427
+ /**
1428
+ * Load wrtc for Node.js environment
1429
+ */
1430
+ async loadNodeWebRTC() {
1431
+ try {
1432
+ const wrtc = await import('wrtc');
1433
+ return wrtc.RTCPeerConnection;
1434
+ } catch (err) {
1435
+ return null;
1436
+ }
1437
+ }
1438
+
1439
+ /**
1440
+ * Determine NAT type based on gathered candidates
1441
+ */
1442
+ determineNatType() {
1443
+ const hasHost = this.results.candidateTypes.includes('host');
1444
+ const hasSrflx = this.results.candidateTypes.includes('srflx');
1445
+ const hasRelay = this.results.candidateTypes.includes('relay');
1446
+
1447
+ if (hasHost && hasSrflx) {
1448
+ // Can get server reflexive = not symmetric NAT
1449
+ const stunSuccess = this.results.stunResults.filter(r => r.success).length;
1450
+ if (stunSuccess >= 2) {
1451
+ this.results.natType = 'full-cone';
1452
+ } else {
1453
+ this.results.natType = 'restricted-cone';
1454
+ }
1455
+ } else if (hasHost && !hasSrflx && hasRelay) {
1456
+ // Can only get relay = symmetric NAT
1457
+ this.results.natType = 'symmetric';
1458
+ } else if (hasHost && !hasSrflx && !hasRelay) {
1459
+ // No external connectivity
1460
+ this.results.natType = 'blocked';
1461
+ } else if (!hasHost) {
1462
+ // No host candidates = unusual configuration
1463
+ this.results.natType = 'unknown';
1464
+ }
1465
+
1466
+ this.log(`NAT Type detected: ${this.results.natType}`);
1467
+ }
1468
+
1469
+ /**
1470
+ * Generate recommendations based on diagnostics
1471
+ */
1472
+ generateRecommendations() {
1473
+ const recs = [];
1474
+
1475
+ // Check STUN connectivity
1476
+ const stunSuccess = this.results.stunResults.filter(r => r.success).length;
1477
+ const stunTotal = this.results.stunResults.length;
1478
+
1479
+ if (stunSuccess === 0) {
1480
+ recs.push({
1481
+ severity: 'error',
1482
+ message: 'No STUN servers reachable',
1483
+ action: 'Check firewall rules for UDP port 3478',
1484
+ });
1485
+ } else if (stunSuccess < stunTotal / 2) {
1486
+ recs.push({
1487
+ severity: 'warning',
1488
+ message: `Only ${stunSuccess}/${stunTotal} STUN servers reachable`,
1489
+ action: 'Some STUN servers may be blocked or unreliable',
1490
+ });
1491
+ }
1492
+
1493
+ // Check TURN connectivity
1494
+ const turnSuccess = this.results.turnResults.filter(r => r.success).length;
1495
+ const turnTotal = this.results.turnResults.length;
1496
+
1497
+ if (this.results.natType === 'symmetric' && turnSuccess === 0) {
1498
+ recs.push({
1499
+ severity: 'error',
1500
+ message: 'Symmetric NAT detected but no TURN servers available',
1501
+ action: 'Configure a TURN server for reliable connectivity',
1502
+ });
1503
+ } else if (turnSuccess === 0 && turnTotal > 0) {
1504
+ recs.push({
1505
+ severity: 'warning',
1506
+ message: 'No TURN servers reachable',
1507
+ action: 'Check TURN credentials and firewall rules',
1508
+ });
1509
+ }
1510
+
1511
+ // NAT-specific recommendations
1512
+ if (this.results.natType === 'symmetric') {
1513
+ recs.push({
1514
+ severity: 'info',
1515
+ message: 'Symmetric NAT detected',
1516
+ action: 'TURN server is required for reliable P2P connections',
1517
+ });
1518
+ }
1519
+
1520
+ if (this.results.natType === 'blocked') {
1521
+ recs.push({
1522
+ severity: 'error',
1523
+ message: 'Network appears to block WebRTC',
1524
+ action: 'Configure TURN over TCP/443 or use a WebSocket fallback',
1525
+ });
1526
+ }
1527
+
1528
+ // Performance recommendations
1529
+ const avgLatency = this.calculateAverageLatency();
1530
+ if (avgLatency > 200) {
1531
+ recs.push({
1532
+ severity: 'warning',
1533
+ message: `High ICE latency (${avgLatency}ms average)`,
1534
+ action: 'Consider using geographically closer STUN/TURN servers',
1535
+ });
1536
+ }
1537
+
1538
+ this.results.recommendations = recs;
1539
+ }
1540
+
1541
+ /**
1542
+ * Calculate average latency across all tests
1543
+ */
1544
+ calculateAverageLatency() {
1545
+ const allResults = [
1546
+ ...this.results.stunResults,
1547
+ ...this.results.turnResults,
1548
+ ].filter(r => r.success && r.latency);
1549
+
1550
+ if (allResults.length === 0) return 0;
1551
+
1552
+ const total = allResults.reduce((sum, r) => sum + r.latency, 0);
1553
+ return Math.round(total / allResults.length);
1554
+ }
1555
+
1556
+ /**
1557
+ * Get a summary report
1558
+ */
1559
+ getSummary() {
1560
+ const stunOk = this.results.stunResults.filter(r => r.success).length;
1561
+ const turnOk = this.results.turnResults.filter(r => r.success).length;
1562
+
1563
+ return {
1564
+ status: this.getOverallStatus(),
1565
+ natType: this.results.natType,
1566
+ stunServers: `${stunOk}/${this.results.stunResults.length} reachable`,
1567
+ turnServers: `${turnOk}/${this.results.turnResults.length} reachable`,
1568
+ avgLatency: `${this.calculateAverageLatency()}ms`,
1569
+ candidateTypes: this.results.candidateTypes,
1570
+ recommendations: this.results.recommendations.length,
1571
+ duration: `${this.results.duration}ms`,
1572
+ };
1573
+ }
1574
+
1575
+ /**
1576
+ * Determine overall connectivity status
1577
+ */
1578
+ getOverallStatus() {
1579
+ const stunOk = this.results.stunResults.some(r => r.success);
1580
+ const turnOk = this.results.turnResults.some(r => r.success);
1581
+ const hasErrors = this.results.recommendations.some(r => r.severity === 'error');
1582
+
1583
+ if (hasErrors) return 'error';
1584
+ if (stunOk && turnOk) return 'good';
1585
+ if (stunOk || turnOk) return 'degraded';
1586
+ return 'failed';
1587
+ }
1588
+
1589
+ /**
1590
+ * Format results as readable string
1591
+ */
1592
+ formatReport() {
1593
+ const summary = this.getSummary();
1594
+ const lines = [
1595
+ '================================================================',
1596
+ ' ICE CONNECTION DIAGNOSTICS REPORT ',
1597
+ '================================================================',
1598
+ ` Status: ${summary.status.toUpperCase()}`,
1599
+ ` NAT Type: ${summary.natType}`,
1600
+ ` STUN Servers: ${summary.stunServers}`,
1601
+ ` TURN Servers: ${summary.turnServers}`,
1602
+ ` Avg Latency: ${summary.avgLatency}`,
1603
+ ` Candidates: ${summary.candidateTypes.join(', ') || 'none'}`,
1604
+ '----------------------------------------------------------------',
1605
+ ];
1606
+
1607
+ if (this.results.recommendations.length > 0) {
1608
+ lines.push(' RECOMMENDATIONS:');
1609
+ for (const rec of this.results.recommendations) {
1610
+ const icon = rec.severity === 'error' ? '[!]' :
1611
+ rec.severity === 'warning' ? '[W]' : '[i]';
1612
+ lines.push(` ${icon} ${rec.message}`);
1613
+ lines.push(` -> ${rec.action}`);
1614
+ }
1615
+ }
1616
+
1617
+ lines.push('================================================================');
1618
+
1619
+ return lines.join('\n');
1620
+ }
1621
+
1622
+ log(message) {
1623
+ if (this.options.verbose) {
1624
+ console.log(`[ICE] ${message}`);
1625
+ }
1626
+ this.emit('log', message);
1627
+ }
1628
+ }
1629
+
1630
+ /**
1631
+ * Quick ICE connectivity test
1632
+ *
1633
+ * Usage:
1634
+ * import { testIceConnectivity } from './webrtc.js';
1635
+ * const result = await testIceConnectivity();
1636
+ * console.log(result.formatReport());
1637
+ */
1638
+ export async function testIceConnectivity(options = {}) {
1639
+ const diagnostics = new ICEDiagnostics({
1640
+ verbose: true,
1641
+ ...options,
1642
+ });
1643
+ await diagnostics.runDiagnostics();
1644
+ return diagnostics;
1645
+ }
1646
+
1647
+ /**
1648
+ * Create custom ICE configuration for specific provider
1649
+ *
1650
+ * Usage:
1651
+ * import { createIceConfig, ICE_PROVIDERS } from './webrtc.js';
1652
+ * const config = createIceConfig('coturn', 'turn.example.com', 'user', 'pass');
1653
+ */
1654
+ export function createIceConfig(provider, ...args) {
1655
+ if (!ICE_PROVIDERS[provider]) {
1656
+ throw new Error(`Unknown ICE provider: ${provider}. Available: ${Object.keys(ICE_PROVIDERS).join(', ')}`);
1657
+ }
1658
+ return {
1659
+ iceServers: ICE_PROVIDERS[provider].config(...args),
1660
+ };
1661
+ }
1662
+
1014
1663
  // Export default configuration for testing
1015
1664
  export default {
1016
1665
  WebRTCPeerConnection,
1017
1666
  WebRTCPeerManager,
1018
1667
  QDAGSynchronizer,
1668
+ ICEDiagnostics,
1019
1669
  WEBRTC_CONFIG,
1670
+ ICE_PROVIDERS,
1671
+ testIceConnectivity,
1672
+ createIceConfig,
1020
1673
  };