node-rtc-connection 1.0.12 → 1.0.14

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,956 @@
1
+ /**
2
+ * @file RTCIceTransport.js
3
+ * @description ICE transport implementation for establishing connectivity.
4
+ * @module ice/RTCIceTransport
5
+ *
6
+ * Ported from Chromium's RTCIceTransport implementation:
7
+ * - cc/rtc_ice_transport.h
8
+ * - cc/rtc_ice_transport.cc
9
+ * - cc/rtc_ice_transport.idl
10
+ */
11
+
12
+ const EventEmitter = require('events');
13
+ const STUNClient = require('../stun/stun-client');
14
+ const dgram = require('dgram');
15
+ const crypto = require('crypto');
16
+
17
+ /**
18
+ * RTCIceRole - The role in the ICE process
19
+ * @readonly
20
+ * @enum {string}
21
+ */
22
+ const RTCIceRole = Object.freeze({
23
+ CONTROLLING: 'controlling',
24
+ CONTROLLED: 'controlled'
25
+ });
26
+
27
+ /**
28
+ * RTCIceTransportState - Current state of the ICE transport
29
+ * @readonly
30
+ * @enum {string}
31
+ */
32
+ const RTCIceTransportState = Object.freeze({
33
+ NEW: 'new',
34
+ CHECKING: 'checking',
35
+ CONNECTED: 'connected',
36
+ COMPLETED: 'completed',
37
+ DISCONNECTED: 'disconnected',
38
+ FAILED: 'failed',
39
+ CLOSED: 'closed'
40
+ });
41
+
42
+ /**
43
+ * RTCIceGatheringState - ICE candidate gathering state
44
+ * @readonly
45
+ * @enum {string}
46
+ */
47
+ const RTCIceGatheringState = Object.freeze({
48
+ NEW: 'new',
49
+ GATHERING: 'gathering',
50
+ COMPLETE: 'complete'
51
+ });
52
+
53
+ /**
54
+ * RTCIceParameters - ICE username fragment and password
55
+ * @typedef {Object} RTCIceParameters
56
+ * @property {string} usernameFragment - ICE username fragment
57
+ * @property {string} password - ICE password
58
+ */
59
+
60
+ /**
61
+ * RTCIceCandidatePair - A pair of local and remote ICE candidates
62
+ * @typedef {Object} RTCIceCandidatePair
63
+ * @property {RTCIceCandidate} local - The local candidate
64
+ * @property {RTCIceCandidate} remote - The remote candidate
65
+ */
66
+
67
+ /**
68
+ * RTCIceGatherOptions - Options for ICE candidate gathering
69
+ * @typedef {Object} RTCIceGatherOptions
70
+ * @property {RTCIceGatherPolicy} [gatherPolicy='all'] - Candidate gathering policy
71
+ * @property {Array<RTCIceServer>} [iceServers] - STUN/TURN servers to use
72
+ */
73
+
74
+ /**
75
+ * @class RTCIceTransport
76
+ * @extends EventEmitter
77
+ * @description Represents the ICE transport layer for a peer connection.
78
+ * Manages ICE candidate gathering, connectivity checks, and state transitions.
79
+ *
80
+ * Events:
81
+ * - 'statechange': Fired when the transport state changes
82
+ * - 'gatheringstatechange': Fired when the gathering state changes
83
+ * - 'selectedcandidatepairchange': Fired when the selected candidate pair changes
84
+ * - 'icecandidate': Fired when a new local candidate is gathered
85
+ *
86
+ * @example
87
+ * const transport = new RTCIceTransport();
88
+ * transport.on('statechange', () => {
89
+ * console.log('State:', transport.state);
90
+ * });
91
+ * transport.on('icecandidate', (candidate) => {
92
+ * // Send candidate to remote peer
93
+ * });
94
+ */
95
+ class RTCIceTransport extends EventEmitter {
96
+ /**
97
+ * Create an RTCIceTransport instance.
98
+ */
99
+ constructor() {
100
+ super();
101
+
102
+ // Internal state
103
+ this._role = null; // RTCIceRole or null
104
+ this._state = RTCIceTransportState.NEW;
105
+ this._gatheringState = RTCIceGatheringState.NEW;
106
+
107
+ // Candidate lists
108
+ this._localCandidates = [];
109
+ this._remoteCandidates = [];
110
+
111
+ // ICE parameters
112
+ this._localParameters = null;
113
+ this._remoteParameters = null;
114
+
115
+ // Selected candidate pair
116
+ this._selectedCandidatePair = null;
117
+
118
+ // Started flag
119
+ this._started = false;
120
+
121
+ // Closed flag
122
+ this._closed = false;
123
+
124
+ // ICE servers (STUN/TURN)
125
+ this._iceServers = [];
126
+
127
+ // STUN clients
128
+ this._stunClients = [];
129
+
130
+ // UDP sockets for candidates
131
+ this._sockets = new Map(); // Map of candidate foundation -> socket
132
+
133
+ // Candidate pairs and connectivity checks
134
+ this._candidatePairs = [];
135
+ this._validPairs = [];
136
+ }
137
+
138
+ /**
139
+ * Get the ICE role (controlling or controlled).
140
+ * @returns {string|null} The ICE role or null if not started
141
+ */
142
+ get role() {
143
+ return this._role;
144
+ }
145
+
146
+ /**
147
+ * Get the current ICE transport state.
148
+ * @returns {string} The transport state
149
+ */
150
+ get state() {
151
+ return this._state;
152
+ }
153
+
154
+ /**
155
+ * Get the current ICE gathering state.
156
+ * @returns {string} The gathering state
157
+ */
158
+ get gatheringState() {
159
+ return this._gatheringState;
160
+ }
161
+
162
+ /**
163
+ * Get the local ICE candidates.
164
+ * @returns {Array<RTCIceCandidate>} Array of local candidates
165
+ */
166
+ getLocalCandidates() {
167
+ return [...this._localCandidates];
168
+ }
169
+
170
+ /**
171
+ * Get the remote ICE candidates.
172
+ * @returns {Array<RTCIceCandidate>} Array of remote candidates
173
+ */
174
+ getRemoteCandidates() {
175
+ return [...this._remoteCandidates];
176
+ }
177
+
178
+ /**
179
+ * Get the selected candidate pair.
180
+ * @returns {RTCIceCandidatePair|null} The selected candidate pair or null
181
+ */
182
+ getSelectedCandidatePair() {
183
+ return this._selectedCandidatePair ? { ...this._selectedCandidatePair } : null;
184
+ }
185
+
186
+ /**
187
+ * Get the local ICE parameters.
188
+ * @returns {RTCIceParameters|null} The local parameters or null
189
+ */
190
+ getLocalParameters() {
191
+ return this._localParameters ? { ...this._localParameters } : null;
192
+ }
193
+
194
+ /**
195
+ * Get the remote ICE parameters.
196
+ * @returns {RTCIceParameters|null} The remote parameters or null
197
+ */
198
+ getRemoteParameters() {
199
+ return this._remoteParameters ? { ...this._remoteParameters } : null;
200
+ }
201
+
202
+ /**
203
+ * Generate random local ICE parameters.
204
+ * Creates a random username fragment and password.
205
+ * @private
206
+ */
207
+ _generateLocalParameters() {
208
+ const crypto = require('crypto');
209
+
210
+ // Generate random username fragment (16 characters)
211
+ const usernameFragment = crypto.randomBytes(8).toString('hex');
212
+
213
+ // Generate random password (22 characters, base64)
214
+ const password = crypto.randomBytes(16).toString('base64').substring(0, 22);
215
+
216
+ this._localParameters = {
217
+ usernameFragment,
218
+ password
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Start the ICE transport with remote parameters and role.
224
+ * @param {RTCIceParameters} remoteParameters - The remote ICE parameters
225
+ * @param {string} role - The ICE role ('controlling' or 'controlled')
226
+ * @throws {Error} If transport is closed or already started
227
+ * @throws {TypeError} If parameters are invalid
228
+ */
229
+ start(remoteParameters, role) {
230
+ if (this._closed) {
231
+ throw new Error('RTCIceTransport is closed');
232
+ }
233
+
234
+ if (this._started) {
235
+ throw new Error('RTCIceTransport already started');
236
+ }
237
+
238
+ // Validate role
239
+ if (role !== RTCIceRole.CONTROLLING && role !== RTCIceRole.CONTROLLED) {
240
+ throw new TypeError(`Invalid role: ${role}`);
241
+ }
242
+
243
+ // Validate remote parameters
244
+ if (!remoteParameters || typeof remoteParameters !== 'object') {
245
+ throw new TypeError('Remote parameters must be an object');
246
+ }
247
+
248
+ if (typeof remoteParameters.usernameFragment !== 'string' ||
249
+ remoteParameters.usernameFragment.length === 0) {
250
+ throw new TypeError('usernameFragment must be a non-empty string');
251
+ }
252
+
253
+ if (typeof remoteParameters.password !== 'string' ||
254
+ remoteParameters.password.length === 0) {
255
+ throw new TypeError('password must be a non-empty string');
256
+ }
257
+
258
+ // Generate local parameters if not already done
259
+ if (!this._localParameters) {
260
+ this._generateLocalParameters();
261
+ }
262
+
263
+ // Store remote parameters and role
264
+ this._remoteParameters = {
265
+ usernameFragment: remoteParameters.usernameFragment,
266
+ password: remoteParameters.password
267
+ };
268
+ this._role = role;
269
+ this._started = true;
270
+
271
+ // Transition to checking state
272
+ this._setState(RTCIceTransportState.CHECKING);
273
+
274
+ // Start connectivity checks if we have candidates
275
+ if (this._remoteCandidates.length > 0 && this._localCandidates.length > 0) {
276
+ this._startConnectivityChecks();
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Start gathering ICE candidates.
282
+ * @param {RTCIceGatherOptions} [options] - Gathering options
283
+ * @throws {Error} If transport is closed
284
+ */
285
+ async gather(options = {}) {
286
+ if (this._closed) {
287
+ throw new Error('RTCIceTransport is closed');
288
+ }
289
+
290
+ // Generate local parameters if not already done
291
+ if (!this._localParameters) {
292
+ this._generateLocalParameters();
293
+ }
294
+
295
+ // Store ICE servers
296
+ if (options.iceServers) {
297
+ this._iceServers = options.iceServers;
298
+ }
299
+
300
+ // Transition to gathering state
301
+ this._setGatheringState(RTCIceGatheringState.GATHERING);
302
+
303
+ // Gather host candidates (local addresses)
304
+ await this._gatherHostCandidates();
305
+
306
+ // Gather server reflexive candidates (STUN)
307
+ await this._gatherServerReflexiveCandidates();
308
+
309
+ // Gather relay candidates (TURN)
310
+ await this._gatherRelayCandidates();
311
+
312
+ // Complete gathering
313
+ setImmediate(() => {
314
+ if (!this._closed) {
315
+ this._setGatheringState(RTCIceGatheringState.COMPLETE);
316
+ }
317
+ });
318
+ }
319
+
320
+ /**
321
+ * Add a remote ICE candidate.
322
+ * @param {RTCIceCandidate} candidate - The remote candidate to add
323
+ * @throws {Error} If transport is closed or not started
324
+ * @throws {TypeError} If candidate is invalid
325
+ */
326
+ addRemoteCandidate(candidate) {
327
+ if (this._closed) {
328
+ throw new Error('RTCIceTransport is closed');
329
+ }
330
+
331
+ if (!this._started) {
332
+ throw new Error('RTCIceTransport not started');
333
+ }
334
+
335
+ if (!candidate || typeof candidate !== 'object') {
336
+ throw new TypeError('Candidate must be an object');
337
+ }
338
+
339
+ // Add to remote candidates list
340
+ this._remoteCandidates.push(candidate);
341
+
342
+ // Start connectivity checks if we're in checking state
343
+ if (this._state === RTCIceTransportState.CHECKING && this._localCandidates.length > 0) {
344
+ // Create pairs for this new remote candidate with all local candidates
345
+ for (const localCandidate of this._localCandidates) {
346
+ const pair = {
347
+ local: localCandidate,
348
+ remote: candidate,
349
+ state: 'waiting'
350
+ };
351
+ this._candidatePairs.push(pair);
352
+ this._sendConnectivityCheck(pair);
353
+ }
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Stop the ICE transport.
359
+ * Transitions to closed state and stops all ICE processing.
360
+ */
361
+ stop() {
362
+ if (this._closed) {
363
+ return;
364
+ }
365
+
366
+ this._close('stopped');
367
+ }
368
+
369
+ /**
370
+ * Internal close method.
371
+ * @param {string} reason - Reason for closing
372
+ * @private
373
+ */
374
+ _close(reason) {
375
+ if (this._closed) {
376
+ return;
377
+ }
378
+
379
+ this._closed = true;
380
+ this._setState(RTCIceTransportState.CLOSED);
381
+
382
+ // Close all UDP sockets
383
+ this._closeSockets();
384
+
385
+ // Clean up STUN/TURN clients
386
+ if (this._stunClients) {
387
+ for (const client of this._stunClients) {
388
+ client.close();
389
+ }
390
+ this._stunClients = [];
391
+ }
392
+
393
+ // Clear refresh timers
394
+ if (this._refreshTimers) {
395
+ for (const timer of this._refreshTimers) {
396
+ clearInterval(timer);
397
+ }
398
+ this._refreshTimers = [];
399
+ }
400
+
401
+ // Clean up
402
+ this._localCandidates = [];
403
+ this._remoteCandidates = [];
404
+ this._selectedCandidatePair = null;
405
+ }
406
+
407
+ /**
408
+ * Set the transport state and emit event if changed.
409
+ * @param {string} newState - The new state
410
+ * @private
411
+ */
412
+ _setState(newState) {
413
+ if (this._state !== newState) {
414
+ this._state = newState;
415
+ this.emit('statechange');
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Set the gathering state and emit event if changed.
421
+ * @param {string} newState - The new gathering state
422
+ * @private
423
+ */
424
+ _setGatheringState(newState) {
425
+ if (this._gatheringState !== newState) {
426
+ this._gatheringState = newState;
427
+ this.emit('gatheringstatechange');
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Add a local candidate (called internally during gathering).
433
+ * @param {RTCIceCandidate} candidate - The local candidate
434
+ * @private
435
+ */
436
+ _addLocalCandidate(candidate) {
437
+ this._localCandidates.push(candidate);
438
+ this.emit('icecandidate', candidate);
439
+ }
440
+
441
+ /**
442
+ * Set the selected candidate pair.
443
+ * @param {RTCIceCandidatePair} pair - The selected pair
444
+ * @private
445
+ */
446
+ _setSelectedCandidatePair(pair) {
447
+ this._selectedCandidatePair = pair;
448
+ this.emit('selectedcandidatepairchange');
449
+ }
450
+
451
+ /**
452
+ * Check if the transport is in a terminal state.
453
+ * @returns {boolean} True if closed, false otherwise
454
+ */
455
+ isClosed() {
456
+ return this._state === RTCIceTransportState.CLOSED;
457
+ }
458
+
459
+ /**
460
+ * Gather host candidates (local network interfaces)
461
+ * @private
462
+ */
463
+ async _gatherHostCandidates() {
464
+ const os = require('os');
465
+ const interfaces = os.networkInterfaces();
466
+
467
+ for (const [name, addrs] of Object.entries(interfaces)) {
468
+ for (const addr of addrs) {
469
+ // Skip internal and IPv6 for now
470
+ if (addr.internal || addr.family !== 'IPv4') {
471
+ continue;
472
+ }
473
+
474
+ // Create UDP socket for this candidate
475
+ const socket = dgram.createSocket('udp4');
476
+
477
+ await new Promise((resolve, reject) => {
478
+ socket.on('error', reject);
479
+
480
+ // Bind to the interface address with port 0 (auto-assign)
481
+ socket.bind(0, addr.address, () => {
482
+ const address = socket.address();
483
+
484
+ const RTCIceCandidate = require('./RTCIceCandidate');
485
+ const foundation = crypto.randomBytes(4).toString('hex');
486
+
487
+ // Generate SDP candidate string
488
+ const candidateString = `candidate:${foundation} 1 udp ${2130706431} ${address.address} ${address.port} typ host`;
489
+
490
+ const candidate = new RTCIceCandidate({
491
+ candidate: candidateString,
492
+ sdpMid: '0',
493
+ sdpMLineIndex: 0,
494
+ foundation,
495
+ priority: 2130706431, // Host candidates have highest priority
496
+ address: address.address,
497
+ protocol: 'udp',
498
+ port: address.port,
499
+ type: 'host',
500
+ component: 'rtp',
501
+ usernameFragment: this._localParameters.usernameFragment
502
+ });
503
+
504
+ // Store socket associated with this candidate
505
+ this._sockets.set(foundation, socket);
506
+
507
+ // Setup socket message handler for ICE connectivity checks
508
+ socket.on('message', (msg, rinfo) => {
509
+ this._handleSocketMessage(msg, rinfo, candidate);
510
+ });
511
+
512
+ socket.on('error', (err) => {
513
+ console.error(`Socket error for ${address.address}:${address.port}:`, err);
514
+ });
515
+
516
+ this._addLocalCandidate(candidate);
517
+ resolve();
518
+ });
519
+ });
520
+ }
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Gather server reflexive candidates using STUN
526
+ * @private
527
+ */
528
+ async _gatherServerReflexiveCandidates() {
529
+ if (!this._iceServers || this._iceServers.length === 0) {
530
+ return;
531
+ }
532
+
533
+ // Use the first host candidate's socket for STUN queries
534
+ const hostCandidates = this._localCandidates.filter(c => c.type === 'host');
535
+ if (hostCandidates.length === 0) {
536
+ return; // No host candidates to use
537
+ }
538
+
539
+ for (const server of this._iceServers) {
540
+ const urls = Array.isArray(server.urls) ? server.urls : [server.urls];
541
+
542
+ for (const url of urls) {
543
+ if (!url.startsWith('stun:')) {
544
+ continue; // Skip non-STUN servers
545
+ }
546
+
547
+ try {
548
+ const parsed = this._parseServerUrl(url);
549
+ const stunClient = new STUNClient({
550
+ server: parsed.host,
551
+ port: parsed.port,
552
+ username: server.username,
553
+ credential: server.credential,
554
+ transport: parsed.transport,
555
+ params: parsed.params
556
+ });
557
+
558
+ this._stunClients.push(stunClient);
559
+
560
+ const reflexiveAddr = await stunClient.getReflexiveAddress();
561
+
562
+ const RTCIceCandidate = require('./RTCIceCandidate');
563
+ const foundation = crypto.randomBytes(4).toString('hex');
564
+ const hostCandidate = hostCandidates[0];
565
+
566
+ // Generate SDP candidate string for server reflexive candidate
567
+ const candidateString = `candidate:${foundation} 1 udp ${1694498815} ${reflexiveAddr.address} ${reflexiveAddr.port} typ srflx raddr ${hostCandidate.address} rport ${hostCandidate.port}`;
568
+
569
+ const candidate = new RTCIceCandidate({
570
+ candidate: candidateString,
571
+ sdpMid: '0',
572
+ sdpMLineIndex: 0,
573
+ foundation,
574
+ priority: 1694498815, // Server reflexive candidates
575
+ address: reflexiveAddr.address,
576
+ protocol: 'udp',
577
+ port: reflexiveAddr.port,
578
+ type: 'srflx',
579
+ component: 'rtp',
580
+ relatedAddress: hostCandidate.address,
581
+ relatedPort: hostCandidate.port,
582
+ usernameFragment: this._localParameters.usernameFragment
583
+ });
584
+
585
+ // Store the socket (same as host candidate's socket)
586
+ this._sockets.set(foundation, this._sockets.get(hostCandidate.foundation));
587
+
588
+ this._addLocalCandidate(candidate);
589
+ } catch (error) {
590
+ console.error(`Failed to gather from STUN server ${url}:`, error.message);
591
+ }
592
+ }
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Gather relay candidates using TURN
598
+ * @private
599
+ */
600
+ async _gatherRelayCandidates() {
601
+ if (!this._iceServers || this._iceServers.length === 0) {
602
+ return;
603
+ }
604
+
605
+ for (const server of this._iceServers) {
606
+ const urls = Array.isArray(server.urls) ? server.urls : [server.urls];
607
+
608
+ for (const url of urls) {
609
+ if (!url.startsWith('turn:') && !url.startsWith('turns:')) {
610
+ continue; // Skip non-TURN servers
611
+ }
612
+
613
+ // TURN requires authentication
614
+ if (!server.username || !server.credential) {
615
+ console.warn(`TURN server ${url} requires username and credential`);
616
+ continue;
617
+ }
618
+
619
+ try {
620
+ const parsed = this._parseServerUrl(url);
621
+ const turnClient = new STUNClient({
622
+ server: parsed.host,
623
+ port: parsed.port,
624
+ username: server.username,
625
+ credential: server.credential,
626
+ transport: parsed.transport,
627
+ params: parsed.params
628
+ });
629
+
630
+ this._stunClients.push(turnClient);
631
+
632
+ // Allocate relay address
633
+ const allocation = await turnClient.allocateRelay(600);
634
+
635
+ const RTCIceCandidate = require('./RTCIceCandidate');
636
+ const foundation = crypto.randomBytes(4).toString('hex');
637
+
638
+ // Generate SDP candidate string for relay candidate
639
+ const candidateString = `candidate:${foundation} 1 udp ${16777215} ${allocation.relayedAddress} ${allocation.relayedPort} typ relay raddr ${parsed.host} rport ${parsed.port}`;
640
+
641
+ const candidate = new RTCIceCandidate({
642
+ candidate: candidateString,
643
+ sdpMid: '0',
644
+ sdpMLineIndex: 0,
645
+ foundation,
646
+ priority: 16777215, // Relay candidates have lowest priority
647
+ address: allocation.relayedAddress,
648
+ protocol: 'udp',
649
+ port: allocation.relayedPort,
650
+ type: 'relay',
651
+ component: 'rtp',
652
+ relatedAddress: parsed.host, // TURN server address
653
+ relatedPort: parsed.port,
654
+ usernameFragment: this._localParameters.usernameFragment
655
+ });
656
+
657
+ // Store TURN client as the "socket" for this relay candidate
658
+ this._sockets.set(foundation, { type: 'turn', client: turnClient });
659
+
660
+ this._addLocalCandidate(candidate);
661
+
662
+ // Keep allocation alive
663
+ this._keepAllocAlive(turnClient, allocation.lifetime);
664
+ } catch (error) {
665
+ console.error(`Failed to allocate from TURN server ${url}:`, error.message);
666
+ }
667
+ }
668
+ }
669
+ }
670
+
671
+ /**
672
+ * Keep TURN allocation alive by sending periodic refresh requests
673
+ * @param {STUNClient} client - TURN client
674
+ * @param {number} lifetime - Allocation lifetime
675
+ * @private
676
+ */
677
+ _keepAllocAlive(client, lifetime) {
678
+ // Refresh 30 seconds before expiry
679
+ const refreshInterval = (lifetime - 30) * 1000;
680
+
681
+ const refreshTimer = setInterval(async () => {
682
+ if (this._closed) {
683
+ clearInterval(refreshTimer);
684
+ return;
685
+ }
686
+
687
+ try {
688
+ await client.refreshAllocation(600);
689
+ } catch (error) {
690
+ console.error('Failed to refresh TURN allocation:', error.message);
691
+ clearInterval(refreshTimer);
692
+ }
693
+ }, refreshInterval);
694
+
695
+ // Store timer for cleanup
696
+ if (!this._refreshTimers) {
697
+ this._refreshTimers = [];
698
+ }
699
+ this._refreshTimers.push(refreshTimer);
700
+ }
701
+
702
+ /**
703
+ * Start ICE connectivity checks
704
+ * @private
705
+ */
706
+ _startConnectivityChecks() {
707
+ // Form candidate pairs (simplified: just pair first local with first remote)
708
+ for (const localCandidate of this._localCandidates) {
709
+ for (const remoteCandidate of this._remoteCandidates) {
710
+ this._candidatePairs.push({
711
+ local: localCandidate,
712
+ remote: remoteCandidate,
713
+ state: 'waiting'
714
+ });
715
+ }
716
+ }
717
+
718
+ // Send connectivity checks for each pair
719
+ for (const pair of this._candidatePairs) {
720
+ this._sendConnectivityCheck(pair);
721
+ }
722
+ }
723
+
724
+ /**
725
+ * Send a connectivity check (STUN Binding Request) to a candidate pair
726
+ * @param {Object} pair - Candidate pair
727
+ * @private
728
+ */
729
+ _sendConnectivityCheck(pair) {
730
+ const socket = this._sockets.get(pair.local.foundation);
731
+ if (!socket || socket.type === 'turn') {
732
+ return; // Skip TURN candidates for now
733
+ }
734
+
735
+ const transactionId = crypto.randomBytes(12);
736
+ const request = this._createBindingRequest(transactionId);
737
+
738
+ try {
739
+ socket.send(request, pair.remote.port, pair.remote.address, (err) => {
740
+ if (err) {
741
+ console.error(`Connectivity check failed for ${pair.remote.address}:${pair.remote.port}:`, err);
742
+ }
743
+ });
744
+ } catch (err) {
745
+ console.error(`Error sending connectivity check:`, err);
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Create a STUN Binding Request for connectivity checks
751
+ * @param {Buffer} transactionId - Transaction ID
752
+ * @returns {Buffer} STUN Binding Request
753
+ * @private
754
+ */
755
+ _createBindingRequest(transactionId) {
756
+ const MAGIC_COOKIE = 0x2112A442;
757
+
758
+ // Simple binding request with no attributes
759
+ const header = Buffer.alloc(20);
760
+ header.writeUInt16BE(0x0001, 0); // Binding Request
761
+ header.writeUInt16BE(0, 2); // Message length (no attributes for now)
762
+ header.writeUInt32BE(MAGIC_COOKIE, 4);
763
+ transactionId.copy(header, 8);
764
+
765
+ return header;
766
+ }
767
+
768
+ /**
769
+ * Parse STUN/TURN server URL
770
+ * @param {string} url - Server URL
771
+ * @returns {Object} Parsed URL
772
+ * @private
773
+ */
774
+ /**
775
+ * Parse server URL with query string support
776
+ * @param {string} url - Server URL (e.g., turn:host:port?transport=udp&ttl=86400)
777
+ * @returns {Object} Parsed server info
778
+ * @private
779
+ */
780
+ _parseServerUrl(url) {
781
+ // Match: (stun|turn|turns)://host:port?query or (stun|turn|turns):host:port?query
782
+ const match = url.match(/^(stun|turn|turns):\/?\/?([^:?]+):?(\d+)?(\?(.+))?/);
783
+ if (!match) {
784
+ throw new Error(`Invalid server URL: ${url}`);
785
+ }
786
+
787
+ const protocol = match[1];
788
+ const host = match[2];
789
+ const port = match[3];
790
+ const queryString = match[5];
791
+
792
+ // Parse query parameters
793
+ const params = {};
794
+ if (queryString) {
795
+ queryString.split('&').forEach(param => {
796
+ const [key, value] = param.split('=');
797
+ if (key) {
798
+ // If value is undefined (no = sign), set to true
799
+ // If value is empty string, keep it as empty string
800
+ params[key] = value !== undefined ? value : true;
801
+ }
802
+ });
803
+ }
804
+
805
+ return {
806
+ protocol,
807
+ host,
808
+ port: parseInt(port || (protocol === 'turns' ? '5349' : '3478'), 10),
809
+ transport: params.transport || 'udp', // Default to UDP
810
+ params // Include all query parameters for future use
811
+ };
812
+ }
813
+
814
+ /**
815
+ * Handle incoming messages on a socket (ICE connectivity checks)
816
+ * @param {Buffer} msg - The message buffer
817
+ * @param {Object} rinfo - Remote address info
818
+ * @param {RTCIceCandidate} localCandidate - The local candidate that received the message
819
+ * @private
820
+ */
821
+ _handleSocketMessage(msg, rinfo, localCandidate) {
822
+ // Check if this is a STUN message (magic cookie check)
823
+ if (msg.length < 20) return;
824
+
825
+ const magicCookie = msg.readUInt32BE(4);
826
+ if (magicCookie !== 0x2112A442) return; // Not a STUN message
827
+
828
+ const messageType = msg.readUInt16BE(0);
829
+
830
+ // STUN Binding Request (0x0001) - this is an ICE connectivity check
831
+ if (messageType === 0x0001) {
832
+ this._handleBindingRequest(msg, rinfo, localCandidate);
833
+ }
834
+ // STUN Binding Response (0x0101) - response to our connectivity check
835
+ else if (messageType === 0x0101) {
836
+ this._handleBindingResponse(msg, rinfo, localCandidate);
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Handle STUN Binding Request (incoming connectivity check)
842
+ * @param {Buffer} msg - The STUN message
843
+ * @param {Object} rinfo - Remote address info
844
+ * @param {RTCIceCandidate} localCandidate - The local candidate
845
+ * @private
846
+ */
847
+ _handleBindingRequest(msg, rinfo, localCandidate) {
848
+ // Send Binding Response
849
+ const transactionId = msg.slice(8, 20);
850
+ const response = this._createBindingResponse(transactionId, rinfo.address, rinfo.port);
851
+
852
+ const socket = this._sockets.get(localCandidate.foundation);
853
+ if (socket) {
854
+ socket.send(response, rinfo.port, rinfo.address);
855
+
856
+ // If we haven't selected a candidate pair yet and this check succeeded,
857
+ // this could be our selected pair
858
+ if (!this._selectedCandidatePair && this._state === RTCIceTransportState.CHECKING) {
859
+ this._setState(RTCIceTransportState.CONNECTED);
860
+ }
861
+ }
862
+ }
863
+
864
+ /**
865
+ * Handle STUN Binding Response (response to our connectivity check)
866
+ * @param {Buffer} msg - The STUN message
867
+ * @param {Object} rinfo - Remote address info
868
+ * @param {RTCIceCandidate} localCandidate - The local candidate
869
+ * @private
870
+ */
871
+ _handleBindingResponse(msg, rinfo, localCandidate) {
872
+ // Mark this candidate pair as valid
873
+ // In a full implementation, we would track which pair this belongs to
874
+ if (!this._selectedCandidatePair && this._state === RTCIceTransportState.CHECKING) {
875
+ this._setState(RTCIceTransportState.CONNECTED);
876
+ }
877
+ }
878
+
879
+ /**
880
+ * Create a STUN Binding Response
881
+ * @param {Buffer} transactionId - Transaction ID from the request
882
+ * @param {string} address - XOR-mapped address
883
+ * @param {number} port - XOR-mapped port
884
+ * @returns {Buffer} STUN Binding Response message
885
+ * @private
886
+ */
887
+ _createBindingResponse(transactionId, address, port) {
888
+ const MAGIC_COOKIE = 0x2112A442;
889
+
890
+ // XOR the port with magic cookie high 16 bits
891
+ const xorPort = port ^ (MAGIC_COOKIE >> 16);
892
+
893
+ // XOR the address with magic cookie
894
+ const addrParts = address.split('.').map(Number);
895
+ const addrNum = (addrParts[0] << 24) | (addrParts[1] << 16) | (addrParts[2] << 8) | addrParts[3];
896
+ const xorAddr = addrNum ^ MAGIC_COOKIE;
897
+
898
+ // Build XOR-MAPPED-ADDRESS attribute
899
+ const attr = Buffer.alloc(12);
900
+ attr.writeUInt16BE(0x0020, 0); // XOR-MAPPED-ADDRESS
901
+ attr.writeUInt16BE(8, 2); // Length
902
+ attr.writeUInt8(0, 4); // Reserved
903
+ attr.writeUInt8(0x01, 5); // Family (IPv4)
904
+ attr.writeUInt16BE(xorPort, 6);
905
+ attr.writeUInt32BE(xorAddr, 8);
906
+
907
+ // Build message
908
+ const header = Buffer.alloc(20);
909
+ header.writeUInt16BE(0x0101, 0); // Binding Response
910
+ header.writeUInt16BE(12, 2); // Message length (attribute size)
911
+ header.writeUInt32BE(MAGIC_COOKIE, 4);
912
+ transactionId.copy(header, 8);
913
+
914
+ return Buffer.concat([header, attr]);
915
+ }
916
+
917
+ /**
918
+ * Check if start() has been called.
919
+ * @returns {boolean} True if started, false otherwise
920
+ */
921
+ isStarted() {
922
+ return this._started;
923
+ }
924
+
925
+ /**
926
+ * Get a socket for data transmission (returns the first available socket)
927
+ * @returns {Object|null} UDP socket or null
928
+ */
929
+ getSocket() {
930
+ const sockets = Array.from(this._sockets.values());
931
+ return sockets.length > 0 ? sockets[0] : null;
932
+ }
933
+
934
+ /**
935
+ * Close all sockets
936
+ * @private
937
+ */
938
+ _closeSockets() {
939
+ for (const socket of this._sockets.values()) {
940
+ try {
941
+ socket.close();
942
+ } catch (err) {
943
+ // Ignore errors when closing
944
+ }
945
+ }
946
+ this._sockets.clear();
947
+ }
948
+ }
949
+
950
+ // Export the class and enums
951
+ module.exports = {
952
+ RTCIceTransport,
953
+ RTCIceRole,
954
+ RTCIceTransportState,
955
+ RTCIceGatheringState
956
+ };