node-rtc-connection 1.0.17 → 1.0.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-rtc-connection",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
4
4
  "description": "WebRTC DataChannel implementation for Node.js with STUN, TURN, NAT traversal, and encryption. Pure Node.js, no native dependencies.",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.mjs",
@@ -357,7 +357,10 @@ class RTCIceTransport extends EventEmitter {
357
357
 
358
358
  // Validate that candidate has required properties for connectivity checks
359
359
  if (!parsedCandidate.address || parsedCandidate.port === null || parsedCandidate.port === undefined) {
360
- console.warn('Remote candidate missing address or port, skipping connectivity checks');
360
+ // Only warn if it's not an empty candidate (which signals end-of-candidates)
361
+ if (parsedCandidate.candidate !== '') {
362
+ console.warn('Remote candidate missing address or port, skipping connectivity checks');
363
+ }
361
364
  // Store the original candidate for compatibility
362
365
  this._remoteCandidates.push(candidate);
363
366
  return;
@@ -684,6 +687,11 @@ class RTCIceTransport extends EventEmitter {
684
687
  // Store TURN client as the "socket" for this relay candidate
685
688
  this._sockets.set(foundation, { type: 'turn', client: turnClient });
686
689
 
690
+ // Handle incoming data from TURN
691
+ turnClient.on('data', (data, peer) => {
692
+ this._handleSocketMessage(data, peer, candidate);
693
+ });
694
+
687
695
  this._addLocalCandidate(candidate);
688
696
 
689
697
  // Keep allocation alive
@@ -760,8 +768,8 @@ class RTCIceTransport extends EventEmitter {
760
768
  */
761
769
  _sendConnectivityCheck(pair) {
762
770
  const socket = this._sockets.get(pair.local.foundation);
763
- if (!socket || socket.type === 'turn') {
764
- return; // Skip TURN candidates for now
771
+ if (!socket) {
772
+ return;
765
773
  }
766
774
 
767
775
  // Validate remote candidate has required properties
@@ -778,11 +786,23 @@ class RTCIceTransport extends EventEmitter {
778
786
  const request = this._createBindingRequest(transactionId);
779
787
 
780
788
  try {
781
- socket.send(request, pair.remote.port, pair.remote.address, (err) => {
782
- if (err) {
783
- console.error(`Connectivity check failed for ${pair.remote.address}:${pair.remote.port}:`, err);
784
- }
785
- });
789
+ if (socket.type === 'turn') {
790
+ const turnClient = socket.client;
791
+ // Create permission and send indication
792
+ turnClient.createPermission(pair.remote.address)
793
+ .then(() => {
794
+ return turnClient.sendIndication(pair.remote.address, pair.remote.port, request);
795
+ })
796
+ .catch(err => {
797
+ // Suppress errors for now as this happens frequently during connection
798
+ });
799
+ } else {
800
+ socket.send(request, pair.remote.port, pair.remote.address, (err) => {
801
+ if (err) {
802
+ console.error(`Connectivity check failed for ${pair.remote.address}:${pair.remote.port}:`, err);
803
+ }
804
+ });
805
+ }
786
806
  } catch (err) {
787
807
  console.error(`Error sending connectivity check to ${pair.remote.address || 'unknown'}:${pair.remote.port || 'unknown'}:`, err);
788
808
  }
@@ -225,6 +225,16 @@ class RTCPeerConnection extends EventEmitter {
225
225
  * @private
226
226
  */
227
227
  _openDataChannels() {
228
+ // Check if network transport has active connections
229
+ const hasConnections = this._networkTransport &&
230
+ this._networkTransport.tcpTransport &&
231
+ this._networkTransport.tcpTransport.connections.size > 0;
232
+
233
+ if (!hasConnections) {
234
+ // Network not ready yet, channels will be opened when connection establishes
235
+ return;
236
+ }
237
+
228
238
  for (const channel of this._dataChannels.values()) {
229
239
  if (channel.readyState === 'connecting') {
230
240
  this._connectChannelToNetwork(channel);
@@ -592,6 +602,11 @@ class RTCPeerConnection extends EventEmitter {
592
602
  console.error('Failed to establish network connection:', error);
593
603
  }
594
604
 
605
+ // Snapshot the candidates BEFORE starting ICE to avoid race condition
606
+ // If addIceCandidate runs while start() is yielding, it will see transport running and add the candidate.
607
+ // If we snapshot after start(), we might add the same candidate twice.
608
+ const candidatesToAdd = [...this._remoteIceCandidates];
609
+
595
610
  // Start ICE
596
611
  if (iceParams.usernameFragment && iceParams.password) {
597
612
  try {
@@ -602,7 +617,7 @@ class RTCPeerConnection extends EventEmitter {
602
617
  }
603
618
 
604
619
  // Add remote candidates
605
- for (const candidate of this._remoteIceCandidates) {
620
+ for (const candidate of candidatesToAdd) {
606
621
  try {
607
622
  // Parse candidate string (simplified)
608
623
  await this._iceTransport.addRemoteCandidate(candidate);
@@ -614,15 +629,8 @@ class RTCPeerConnection extends EventEmitter {
614
629
  // Open data channels when connection is established
615
630
  this._sctpTransport.once('statechange', () => {
616
631
  if (this._sctpTransport.state === 'connected') {
617
- for (const channel of this._dataChannels.values()) {
618
- if (channel.readyState === 'connecting') {
619
- // Hook up channel to network transport first
620
- this._connectChannelToNetwork(channel);
621
-
622
- // Then set state to open (emits 'open' event)
623
- channel._setStateToOpen();
624
- }
625
- }
632
+ // Wait for network to be ready before opening channels
633
+ this._openDataChannels();
626
634
  }
627
635
  });
628
636
  }
@@ -669,7 +677,7 @@ class RTCPeerConnection extends EventEmitter {
669
677
  throw new Error('RTCPeerConnection is closed');
670
678
  }
671
679
 
672
- if (!candidate) {
680
+ if (!candidate || (candidate.candidate === '')) {
673
681
  // End of candidates signal
674
682
  this._iceGatheringState = RTCIceGatheringState.COMPLETE;
675
683
  this.emit('icegatheringstatechange');
@@ -682,7 +690,26 @@ class RTCPeerConnection extends EventEmitter {
682
690
  this._remoteIceCandidates.push(candidate);
683
691
 
684
692
  // If connection is already started, add candidate immediately
685
- if (this._remoteDescription) {
693
+ // Check if transport is actually started (not just initialized)
694
+ // We can infer this if we have remote description AND the transport state is not 'new'
695
+ // Or simply try/catch and ignore "not started" error, but better to check.
696
+ // Since we don't have public access to _started, we rely on state.
697
+ // However, state might be 'new' but start() was called? No, start() sets state to checking.
698
+
699
+ // Actually, _startConnection calls start().
700
+ // If we are Answerer, we set remote offer. _startConnection is NOT called yet.
701
+ // It is called when we set local answer.
702
+ // So we should NOT add candidates yet.
703
+
704
+ // If we are Offerer, we set remote answer. _startConnection IS called.
705
+
706
+ // So we should only add if we have both descriptions (Stable state)?
707
+ // Or if the transport is started.
708
+
709
+ // Let's check if we are in a state where transport should be running.
710
+ const isTransportRunning = this._iceTransport.state !== 'new' && this._iceTransport.state !== 'closed';
711
+
712
+ if (isTransportRunning) {
686
713
  try {
687
714
  await this._iceTransport.addRemoteCandidate(candidate);
688
715
  } catch (error) {
@@ -105,7 +105,7 @@ function parseCandidates(sdp) {
105
105
  for (const line of lines) {
106
106
  if (line.startsWith('a=candidate:')) {
107
107
  candidates.push({
108
- candidate: line,
108
+ candidate: line.substring(2),
109
109
  sdpMid: '0',
110
110
  sdpMLineIndex: 0
111
111
  });
@@ -12,6 +12,8 @@
12
12
  const dgram = require('dgram');
13
13
  const crypto = require('crypto');
14
14
 
15
+ const EventEmitter = require('events');
16
+
15
17
  /**
16
18
  * STUN message types
17
19
  */
@@ -69,7 +71,7 @@ const MAGIC_COOKIE = 0x2112A442;
69
71
  * @class STUNClient
70
72
  * @description STUN/TURN client for NAT traversal
71
73
  */
72
- class STUNClient {
74
+ class STUNClient extends EventEmitter {
73
75
  /**
74
76
  * Create a STUN client
75
77
  * @param {Object} options - Client options
@@ -81,6 +83,7 @@ class STUNClient {
81
83
  * @param {Object} [options.params={}] - Additional query parameters from URL
82
84
  */
83
85
  constructor(options) {
86
+ super();
84
87
  this.server = options.server;
85
88
  this.port = options.port;
86
89
  this.username = options.username;
@@ -204,6 +207,46 @@ class STUNClient {
204
207
  return this._sendRequest(request, transactionId, 'refresh');
205
208
  }
206
209
 
210
+ /**
211
+ * Create a TURN Permission for a peer
212
+ * @param {string} peerAddress - Peer IP address
213
+ * @returns {Promise<void>}
214
+ */
215
+ async createPermission(peerAddress) {
216
+ if (!this.username || !this.credential) {
217
+ throw new Error('TURN requires username and credential');
218
+ }
219
+
220
+ const transactionId = crypto.randomBytes(12);
221
+ const request = this._createCreatePermissionRequest(transactionId, peerAddress);
222
+
223
+ await this._sendRequest(request, transactionId, 'createPermission');
224
+ }
225
+
226
+ /**
227
+ * Send data to a peer via TURN Send Indication
228
+ * @param {string} peerAddress - Peer IP address
229
+ * @param {number} peerPort - Peer port
230
+ * @param {Buffer} data - Data to send
231
+ * @returns {Promise<void>}
232
+ */
233
+ async sendIndication(peerAddress, peerPort, data) {
234
+ if (!this.username || !this.credential) {
235
+ throw new Error('TURN requires username and credential');
236
+ }
237
+
238
+ const transactionId = crypto.randomBytes(12);
239
+ const indication = this._createSendIndication(transactionId, peerAddress, peerPort, data);
240
+
241
+ // Indications are fire-and-forget, no response expected
242
+ return new Promise((resolve, reject) => {
243
+ this.socket.send(indication, this.port, this.server, (err) => {
244
+ if (err) reject(err);
245
+ else resolve();
246
+ });
247
+ });
248
+ }
249
+
207
250
  /**
208
251
  * Send a TURN request
209
252
  * @param {Buffer} request - Request message
@@ -307,6 +350,87 @@ class STUNClient {
307
350
  return this._createMessage(STUN_MESSAGE_TYPES.ALLOCATE_REQUEST, transactionId, attributes, withAuth);
308
351
  }
309
352
 
353
+ /**
354
+ * Create a TURN CreatePermission Request
355
+ * @param {Buffer} transactionId - Transaction ID
356
+ * @param {string} peerAddress - Peer IP address
357
+ * @returns {Buffer} STUN message
358
+ * @private
359
+ */
360
+ _createCreatePermissionRequest(transactionId, peerAddress) {
361
+ const attributes = [];
362
+
363
+ // XOR-PEER-ADDRESS
364
+ const peerAttr = this._createXorPeerAddressAttribute(peerAddress, 0, transactionId);
365
+ attributes.push(peerAttr);
366
+
367
+ // Auth attributes
368
+ if (this.realm && this.nonce) {
369
+ attributes.push(this._createStringAttribute(STUN_ATTRIBUTES.USERNAME, this.username));
370
+ attributes.push(this._createStringAttribute(STUN_ATTRIBUTES.REALM, this.realm));
371
+ attributes.push(this._createStringAttribute(STUN_ATTRIBUTES.NONCE, this.nonce));
372
+ }
373
+
374
+ return this._createMessage(STUN_MESSAGE_TYPES.CREATE_PERMISSION_REQUEST, transactionId, attributes, true);
375
+ }
376
+
377
+ /**
378
+ * Create a TURN Send Indication
379
+ * @param {Buffer} transactionId - Transaction ID
380
+ * @param {string} peerAddress - Peer IP address
381
+ * @param {number} peerPort - Peer port
382
+ * @param {Buffer} data - Data to send
383
+ * @returns {Buffer} STUN message
384
+ * @private
385
+ */
386
+ _createSendIndication(transactionId, peerAddress, peerPort, data) {
387
+ const attributes = [];
388
+
389
+ // XOR-PEER-ADDRESS
390
+ const peerAttr = this._createXorPeerAddressAttribute(peerAddress, peerPort, transactionId);
391
+ attributes.push(peerAttr);
392
+
393
+ // DATA
394
+ const dataAttr = Buffer.alloc(4 + data.length + (4 - (data.length % 4)) % 4);
395
+ dataAttr.writeUInt16BE(STUN_ATTRIBUTES.DATA, 0);
396
+ dataAttr.writeUInt16BE(data.length, 2);
397
+ data.copy(dataAttr, 4);
398
+ attributes.push(dataAttr);
399
+
400
+ return this._createMessage(STUN_MESSAGE_TYPES.SEND_INDICATION, transactionId, attributes, false);
401
+ }
402
+
403
+ /**
404
+ * Create XOR-PEER-ADDRESS attribute
405
+ * @param {string} address - IP address
406
+ * @param {number} port - Port
407
+ * @param {Buffer} transactionId - Transaction ID
408
+ * @returns {Buffer} Attribute buffer
409
+ * @private
410
+ */
411
+ _createXorPeerAddressAttribute(address, port, transactionId) {
412
+ const family = 0x01; // IPv4
413
+ const buffer = Buffer.alloc(4 + 8); // Type(2) + Length(2) + Reserved(1) + Family(1) + Port(2) + Address(4)
414
+
415
+ buffer.writeUInt16BE(STUN_ATTRIBUTES.XOR_PEER_ADDRESS, 0);
416
+ buffer.writeUInt16BE(8, 2);
417
+ buffer.writeUInt8(0, 4);
418
+ buffer.writeUInt8(family, 5);
419
+
420
+ // XOR Port
421
+ const xorPort = port ^ (MAGIC_COOKIE >> 16);
422
+ buffer.writeUInt16BE(xorPort, 6);
423
+
424
+ // XOR Address
425
+ const parts = address.split('.').map(Number);
426
+ const addrInt = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
427
+ const xorAddr = addrInt ^ MAGIC_COOKIE;
428
+
429
+ buffer.writeUInt32BE(xorAddr >>> 0, 8); // Ensure unsigned
430
+
431
+ return buffer;
432
+ }
433
+
310
434
  /**
311
435
  * Create a TURN Refresh Request
312
436
  * @param {Buffer} transactionId - Transaction ID
@@ -483,6 +607,16 @@ class STUNClient {
483
607
  });
484
608
  this.transactions.delete(transactionKey);
485
609
  }
610
+ // Handle Data Indication
611
+ else if (messageType === STUN_MESSAGE_TYPES.DATA_INDICATION) {
612
+ if (attributes.xorPeerAddress && attributes.data) {
613
+ this.emit('data', attributes.data, {
614
+ address: attributes.xorPeerAddress.address,
615
+ port: attributes.xorPeerAddress.port,
616
+ family: attributes.xorPeerAddress.family || 'IPv4'
617
+ });
618
+ }
619
+ }
486
620
  // Handle error responses
487
621
  else if (messageType === STUN_MESSAGE_TYPES.BINDING_ERROR_RESPONSE ||
488
622
  messageType === STUN_MESSAGE_TYPES.ALLOCATE_ERROR_RESPONSE) {