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.
- package/README.md +355 -289
- package/dist/index.cjs +4318 -3095
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +4318 -3095
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/datachannel/RTCDataChannel.js +354 -0
- package/src/dtls/RTCCertificate.js +310 -0
- package/src/dtls/RTCDtlsTransport.js +247 -0
- package/src/foundation/ByteBufferQueue.js +235 -0
- package/src/foundation/RTCError.js +226 -0
- package/src/ice/RTCIceCandidate.js +301 -0
- package/src/ice/RTCIceTransport.js +956 -0
- package/src/index.d.ts +316 -145
- package/src/index.js +78 -45
- package/src/network/network-transport.js +478 -0
- package/src/peerconnection/RTCPeerConnection.js +847 -0
- package/src/sctp/RTCSctpTransport.js +253 -0
- package/src/sdp/RTCSessionDescription.js +102 -0
- package/src/sdp/sdp-utils.js +224 -0
- package/src/stun/stun-client.js +643 -0
- package/src/ICEGatherer.js +0 -341
- package/src/NativePeerConnectionFactory.js +0 -1044
- package/src/RTCDataChannel.js +0 -346
- package/src/RTCDataChannelEvent.js +0 -50
- package/src/RTCError.js +0 -66
- package/src/RTCIceCandidate.js +0 -184
- package/src/RTCPeerConnection.js +0 -505
- package/src/RTCPeerConnectionIceEvent.js +0 -58
- package/src/RTCSessionDescription.js +0 -62
- package/src/STUNClient.js +0 -222
- package/src/SecureConnection.js +0 -298
- package/src/TURNClient.js +0 -561
- package/src/UDPTransport.js +0 -236
|
@@ -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
|
+
};
|