@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/deploy/.env.example +97 -0
- package/deploy/DEPLOY.md +481 -0
- package/deploy/Dockerfile +99 -0
- package/deploy/docker-compose.yml +162 -0
- package/deploy/genesis-prod.js +1536 -0
- package/deploy/health-check.js +187 -0
- package/deploy/prometheus.yml +38 -0
- package/firebase-signaling.js +41 -2
- package/package.json +8 -1
- package/real-workers.js +9 -4
- package/scheduler.js +8 -4
- package/tests/distributed-workers-test.js +1609 -0
- package/tests/p2p-migration-test.js +1102 -0
- package/tests/webrtc-peer-test.js +686 -0
- package/webrtc.js +693 -40
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{
|
|
46
|
-
urls:
|
|
47
|
-
username:
|
|
48
|
-
credential:
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
115
|
-
(await this.loadNodeWebRTC());
|
|
306
|
+
const webrtcClasses = await this.loadWebRTCClasses();
|
|
116
307
|
|
|
117
|
-
if (!
|
|
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
|
|
332
|
+
* Load WebRTC classes (from browser globals or wrtc package)
|
|
136
333
|
*/
|
|
137
|
-
async
|
|
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
|
|
140
|
-
|
|
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
|
|
143
|
-
console.warn('WebRTC not available in Node.js,
|
|
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
|
};
|