livekit-client 2.20.2 → 2.22.0

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.
Files changed (142) hide show
  1. package/README.md +10 -10
  2. package/dist/livekit-client.e2ee.worker.js +1 -1
  3. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  4. package/dist/livekit-client.e2ee.worker.mjs +6 -1
  5. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  6. package/dist/livekit-client.esm.mjs +2588 -1056
  7. package/dist/livekit-client.esm.mjs.map +1 -1
  8. package/dist/livekit-client.fm.worker.js +1 -1
  9. package/dist/livekit-client.fm.worker.js.map +1 -1
  10. package/dist/livekit-client.fm.worker.mjs +6 -1
  11. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  12. package/dist/livekit-client.umd.js +1 -1
  13. package/dist/livekit-client.umd.js.map +1 -1
  14. package/dist/src/index.d.ts +1 -1
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/room/PCTransport.d.ts +49 -2
  17. package/dist/src/room/PCTransport.d.ts.map +1 -1
  18. package/dist/src/room/RTCEngine.d.ts +48 -65
  19. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  20. package/dist/src/room/Room.d.ts +6 -0
  21. package/dist/src/room/Room.d.ts.map +1 -1
  22. package/dist/src/room/data-channel/DataChannelManager.d.ts +62 -0
  23. package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -0
  24. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
  25. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -0
  26. package/dist/src/room/data-channel/LossyDataChannel.d.ts +40 -0
  27. package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -0
  28. package/dist/src/room/data-channel/ReliableDataChannel.d.ts +56 -0
  29. package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -0
  30. package/dist/src/room/data-channel/types.d.ts +12 -0
  31. package/dist/src/room/data-channel/types.d.ts.map +1 -0
  32. package/dist/src/room/data-stream/compression.d.ts +49 -0
  33. package/dist/src/room/data-stream/compression.d.ts.map +1 -0
  34. package/dist/src/room/data-stream/constants.d.ts +18 -0
  35. package/dist/src/room/data-stream/constants.d.ts.map +1 -0
  36. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
  37. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
  38. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  39. package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
  40. package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts.map +1 -1
  41. package/dist/src/room/data-stream/outgoing/header-utils.d.ts +18 -0
  42. package/dist/src/room/data-stream/outgoing/header-utils.d.ts.map +1 -0
  43. package/dist/src/room/errors.d.ts +3 -1
  44. package/dist/src/room/errors.d.ts.map +1 -1
  45. package/dist/src/room/participant/LocalParticipant.d.ts +10 -2
  46. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  47. package/dist/src/room/participant/RemoteParticipant.d.ts +11 -2
  48. package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
  49. package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
  50. package/dist/src/room/rpc/server/RpcServerManager.d.ts.map +1 -1
  51. package/dist/src/room/token-source/TokenSource.d.ts +19 -10
  52. package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
  53. package/dist/src/room/track/LocalVideoTrack.d.ts +12 -1
  54. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  55. package/dist/src/room/track/RemoteTrack.d.ts +3 -0
  56. package/dist/src/room/track/RemoteTrack.d.ts.map +1 -1
  57. package/dist/src/room/track/Track.d.ts.map +1 -1
  58. package/dist/src/room/types.d.ts +17 -0
  59. package/dist/src/room/types.d.ts.map +1 -1
  60. package/dist/src/room/utils.d.ts +28 -0
  61. package/dist/src/room/utils.d.ts.map +1 -1
  62. package/dist/src/test/promiseState.d.ts +12 -0
  63. package/dist/src/test/promiseState.d.ts.map +1 -0
  64. package/dist/src/test/signalToken.d.ts.map +1 -1
  65. package/dist/src/utils/AsyncQueue.d.ts +3 -3
  66. package/dist/src/utils/AsyncQueue.d.ts.map +1 -1
  67. package/dist/src/version.d.ts +5 -1
  68. package/dist/src/version.d.ts.map +1 -1
  69. package/dist/ts4.2/index.d.ts +1 -1
  70. package/dist/ts4.2/room/PCTransport.d.ts +49 -2
  71. package/dist/ts4.2/room/RTCEngine.d.ts +48 -65
  72. package/dist/ts4.2/room/Room.d.ts +6 -0
  73. package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +62 -0
  74. package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
  75. package/dist/ts4.2/room/data-channel/LossyDataChannel.d.ts +41 -0
  76. package/dist/ts4.2/room/data-channel/ReliableDataChannel.d.ts +57 -0
  77. package/dist/ts4.2/room/data-channel/types.d.ts +12 -0
  78. package/dist/ts4.2/room/data-stream/compression.d.ts +49 -0
  79. package/dist/ts4.2/room/data-stream/constants.d.ts +18 -0
  80. package/dist/ts4.2/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
  81. package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
  82. package/dist/ts4.2/room/data-stream/outgoing/header-utils.d.ts +18 -0
  83. package/dist/ts4.2/room/errors.d.ts +3 -1
  84. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +10 -2
  85. package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +11 -1
  86. package/dist/ts4.2/room/token-source/TokenSource.d.ts +17 -8
  87. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
  88. package/dist/ts4.2/room/track/RemoteTrack.d.ts +3 -0
  89. package/dist/ts4.2/room/types.d.ts +17 -0
  90. package/dist/ts4.2/room/utils.d.ts +28 -0
  91. package/dist/ts4.2/test/promiseState.d.ts +12 -0
  92. package/dist/ts4.2/utils/AsyncQueue.d.ts +3 -3
  93. package/dist/ts4.2/version.d.ts +5 -1
  94. package/package.json +14 -13
  95. package/src/api/SignalClient.e2e.test.ts +16 -9
  96. package/src/e2ee/utils.ts +1 -1
  97. package/src/index.ts +2 -0
  98. package/src/room/PCTransport.test.ts +243 -1
  99. package/src/room/PCTransport.ts +181 -80
  100. package/src/room/RTCEngine.test.ts +397 -101
  101. package/src/room/RTCEngine.ts +244 -545
  102. package/src/room/Room.test.ts +138 -4
  103. package/src/room/Room.ts +92 -25
  104. package/src/room/data-channel/DataChannelManager.test.ts +152 -0
  105. package/src/room/data-channel/DataChannelManager.ts +237 -0
  106. package/src/room/data-channel/FlowControlledDataChannel.test.ts +154 -0
  107. package/src/room/data-channel/FlowControlledDataChannel.ts +216 -0
  108. package/src/room/data-channel/LossyDataChannel.test.ts +118 -0
  109. package/src/room/data-channel/LossyDataChannel.ts +125 -0
  110. package/src/room/data-channel/ReliableDataChannel.test.ts +158 -0
  111. package/src/room/data-channel/ReliableDataChannel.ts +153 -0
  112. package/src/room/data-channel/types.ts +30 -0
  113. package/src/room/data-stream/compression.ts +117 -0
  114. package/src/room/data-stream/constants.ts +18 -0
  115. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +1601 -0
  116. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +438 -86
  117. package/src/room/data-stream/incoming/StreamReader.ts +9 -3
  118. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts +1292 -0
  119. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +489 -116
  120. package/src/room/data-stream/outgoing/header-utils.ts +87 -0
  121. package/src/room/errors.ts +7 -2
  122. package/src/room/participant/LocalParticipant.ts +13 -0
  123. package/src/room/participant/RemoteParticipant.ts +19 -5
  124. package/src/room/rpc/client/RpcClientManager.test.ts +20 -27
  125. package/src/room/rpc/client/RpcClientManager.ts +1 -4
  126. package/src/room/rpc/server/RpcServerManager.test.ts +24 -23
  127. package/src/room/rpc/server/RpcServerManager.ts +1 -3
  128. package/src/room/token-source/TokenSource.ts +25 -12
  129. package/src/room/track/LocalVideoTrack.test.ts +105 -2
  130. package/src/room/track/LocalVideoTrack.ts +36 -10
  131. package/src/room/track/RemoteTrack.test.ts +144 -0
  132. package/src/room/track/RemoteTrack.ts +39 -12
  133. package/src/room/track/Track.ts +2 -1
  134. package/src/room/types.ts +18 -0
  135. package/src/room/utils.test.ts +71 -2
  136. package/src/room/utils.ts +119 -0
  137. package/src/test/promiseState.ts +23 -0
  138. package/src/test/signalServerSetup.ts +2 -1
  139. package/src/test/signalToken.ts +17 -13
  140. package/src/type-polyfills/header-extensions.d.ts +13 -0
  141. package/src/utils/AsyncQueue.ts +3 -3
  142. package/src/version.ts +5 -1
@@ -1,8 +1,22 @@
1
- import { ClientInfo_Capability, JoinResponse } from '@livekit/protocol';
1
+ import {
2
+ ClientInfo_Capability,
3
+ JoinResponse,
4
+ StreamState as ProtoStreamState,
5
+ StreamStateUpdate,
6
+ SubscriptionError,
7
+ SubscriptionResponse,
8
+ TrackInfo,
9
+ TrackType,
10
+ } from '@livekit/protocol';
2
11
  import { afterEach, describe, expect, it, vi } from 'vitest';
3
- import Room from './Room';
12
+ import MockMediaStreamTrack from '../test/MockMediaStreamTrack';
13
+ import Room, { ConnectionState } from './Room';
4
14
  import { roomConnectOptionDefaults, roomOptionDefaults } from './defaults';
5
- import { RoomEvent } from './events';
15
+ import { EngineEvent, ParticipantEvent, RoomEvent } from './events';
16
+ import RemoteParticipant from './participant/RemoteParticipant';
17
+ import RemoteTrackPublication from './track/RemoteTrackPublication';
18
+ import RemoteVideoTrack from './track/RemoteVideoTrack';
19
+ import { Track } from './track/Track';
6
20
 
7
21
  describe('Active device switch', () => {
8
22
  it('updates devices correctly', async () => {
@@ -81,7 +95,10 @@ describe('Room signaling options', () => {
81
95
  'wss://test.livekit.io',
82
96
  'test-token',
83
97
  expect.objectContaining({
84
- clientInfoCapabilities: [ClientInfo_Capability.CAP_PACKET_TRAILER],
98
+ clientInfoCapabilities: [
99
+ ClientInfo_Capability.CAP_PACKET_TRAILER,
100
+ ClientInfo_Capability.CAP_COMPRESSION_DEFLATE_RAW,
101
+ ],
85
102
  e2eeEnabled: true,
86
103
  }),
87
104
  expect.any(AbortSignal),
@@ -187,3 +204,120 @@ describe('Room lifecycle', () => {
187
204
  }
188
205
  });
189
206
  });
207
+
208
+ describe('remote track subscription', () => {
209
+ it('does not replay a deferred ontrack after the subscription failed', () => {
210
+ const room = new Room();
211
+ room.state = ConnectionState.Connecting;
212
+
213
+ const trackSid = 'TR_h264';
214
+ const mediaTrack = { id: trackSid } as MediaStreamTrack;
215
+ const stream = {
216
+ id: `PA_remote|${trackSid}`,
217
+ getTracks: () => [mediaTrack],
218
+ } as unknown as MediaStream;
219
+ const receiver = {} as RTCRtpReceiver;
220
+
221
+ room.engine.emit(EngineEvent.MediaTrackAdded, mediaTrack, stream, receiver);
222
+
223
+ const replay = vi.spyOn(
224
+ room as unknown as {
225
+ onTrackAdded(
226
+ mediaTrack: MediaStreamTrack,
227
+ stream: MediaStream,
228
+ receiver: RTCRtpReceiver,
229
+ ): void;
230
+ },
231
+ 'onTrackAdded',
232
+ );
233
+
234
+ room.engine.emit(
235
+ EngineEvent.SubscriptionError,
236
+ new SubscriptionResponse({
237
+ trackSid,
238
+ err: SubscriptionError.SE_CODEC_UNSUPPORTED,
239
+ }),
240
+ );
241
+
242
+ room.state = ConnectionState.Connected;
243
+ room.emit(RoomEvent.Connected);
244
+
245
+ expect(replay).not.toHaveBeenCalled();
246
+ });
247
+ });
248
+
249
+ describe('stream state updates', () => {
250
+ const participantSid = 'PA_remote';
251
+ const participantIdentity = 'remote-user';
252
+ const trackSid = 'TR_video';
253
+
254
+ function setupSubscribedTrack() {
255
+ const room = new Room();
256
+ room.state = ConnectionState.Connected;
257
+
258
+ const participant = new RemoteParticipant(
259
+ room.engine.client,
260
+ participantSid,
261
+ participantIdentity,
262
+ );
263
+ const publication = new RemoteTrackPublication(
264
+ Track.Kind.Video,
265
+ new TrackInfo({ sid: trackSid, type: TrackType.VIDEO, name: 'camera' }),
266
+ true,
267
+ );
268
+ publication.setTrack(
269
+ new RemoteVideoTrack(new MockMediaStreamTrack(), trackSid, undefined as never, {}),
270
+ );
271
+ participant.trackPublications.set(trackSid, publication);
272
+
273
+ (
274
+ room as unknown as { remoteParticipants: Map<string, RemoteParticipant> }
275
+ ).remoteParticipants.set(participantIdentity, participant);
276
+ (room as unknown as { sidToIdentity: Map<string, string> }).sidToIdentity.set(
277
+ participantSid,
278
+ participantIdentity,
279
+ );
280
+
281
+ return { room, participant, publication };
282
+ }
283
+
284
+ function pushStreamState(room: Room, state: ProtoStreamState) {
285
+ (
286
+ room as unknown as { handleStreamStateUpdate: (update: StreamStateUpdate) => void }
287
+ ).handleStreamStateUpdate(
288
+ new StreamStateUpdate({
289
+ streamStates: [{ participantSid, trackSid, state }],
290
+ }),
291
+ );
292
+ }
293
+
294
+ it('emits TrackStreamStateChanged when the SFU pauses a subscribed track', () => {
295
+ const { room, participant, publication } = setupSubscribedTrack();
296
+
297
+ const roomEvents = vi.fn();
298
+ const participantEvents = vi.fn();
299
+ room.on(RoomEvent.TrackStreamStateChanged, roomEvents);
300
+ participant.on(ParticipantEvent.TrackStreamStateChanged, participantEvents);
301
+
302
+ pushStreamState(room, ProtoStreamState.PAUSED);
303
+
304
+ expect(publication.track?.streamState).toBe(Track.StreamState.Paused);
305
+ expect(roomEvents).toHaveBeenCalledWith(publication, Track.StreamState.Paused, participant);
306
+ expect(participantEvents).toHaveBeenCalledWith(publication, Track.StreamState.Paused);
307
+ });
308
+
309
+ it('does not emit TrackStreamStateChanged when the state is unchanged', () => {
310
+ const { room, participant } = setupSubscribedTrack();
311
+
312
+ const roomEvents = vi.fn();
313
+ const participantEvents = vi.fn();
314
+ room.on(RoomEvent.TrackStreamStateChanged, roomEvents);
315
+ participant.on(ParticipantEvent.TrackStreamStateChanged, participantEvents);
316
+
317
+ // The track starts Active; a redundant ACTIVE update must stay silent.
318
+ pushStreamState(room, ProtoStreamState.ACTIVE);
319
+
320
+ expect(roomEvents).not.toHaveBeenCalled();
321
+ expect(participantEvents).not.toHaveBeenCalled();
322
+ });
323
+ });
package/src/room/Room.ts CHANGED
@@ -113,11 +113,13 @@ import {
113
113
  Future,
114
114
  createDummyVideoStreamTrack,
115
115
  extractChatMessage,
116
+ extractTrackSid,
116
117
  extractTranscriptionSegments,
117
118
  getDisconnectReasonFromConnectionError,
118
119
  getEmptyAudioStreamTrack,
119
120
  isBrowserSupported,
120
121
  isCloud,
122
+ isCompressionStreamSupported,
121
123
  isLocalAudioTrack,
122
124
  isLocalParticipant,
123
125
  isReactNative,
@@ -217,6 +219,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
217
219
 
218
220
  private isResuming: boolean = false;
219
221
 
222
+ private pendingTrackAddedCallbacks = new Map<Track.SID, Set<() => void>>();
223
+
220
224
  /**
221
225
  * map to store first point in time when a particular transcription segment was received
222
226
  */
@@ -268,7 +272,13 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
268
272
  this.maybeCreateEngine();
269
273
 
270
274
  this.incomingDataStreamManager = new IncomingDataStreamManager();
271
- this.outgoingDataStreamManager = new OutgoingDataStreamManager(this.engine, this.log);
275
+ this.outgoingDataStreamManager = new OutgoingDataStreamManager(
276
+ this.engine,
277
+ this.log,
278
+ this.getRemoteParticipantClientProtocol,
279
+ this.getRemoteParticipantCapabilities,
280
+ this.getAllRemoteParticipantIdentities,
281
+ );
272
282
 
273
283
  this.incomingDataTrackManager = new IncomingDataTrackManager({ e2eeManager: this.e2eeManager });
274
284
  this.incomingDataTrackManager
@@ -308,7 +318,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
308
318
  })
309
319
  .on('packetAvailable', ({ handle, bytes }) => {
310
320
  this.engine
311
- .sendLossyBytes(bytes, DataChannelKind.DATA_TRACK_LOSSY, 'wait')
321
+ .sendDataTrackFrame(bytes)
312
322
  .finally(() => this.outgoingDataTrackManager.handlePacketSendComplete(handle));
313
323
  });
314
324
 
@@ -623,16 +633,16 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
623
633
  this.isResuming = false;
624
634
  this.log.debug('Resumed signal connection');
625
635
  this.updateSubscriptions();
626
- this.emitBufferedEvents();
627
636
  if (this.setAndEmitConnectionState(ConnectionState.Connected)) {
628
637
  this.emit(RoomEvent.Reconnected);
629
638
  }
639
+ this.emitBufferedEvents();
630
640
  })
631
641
  .on(EngineEvent.SignalResumed, () => {
632
- this.bufferedEvents = [];
633
642
  if (this.state === ConnectionState.Reconnecting || this.isResuming) {
634
643
  this.sendSyncState();
635
644
  }
645
+ this.emitBufferedEvents();
636
646
  })
637
647
  .on(EngineEvent.Restarting, this.handleRestarting)
638
648
  .on(EngineEvent.Restarted, this.handleRestarted)
@@ -971,11 +981,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
971
981
  autoSubscribe: connectOptions.autoSubscribe,
972
982
  adaptiveStream:
973
983
  typeof roomOptions.adaptiveStream === 'object' ? true : roomOptions.adaptiveStream,
974
- clientInfoCapabilities:
975
- isFrameMetadataSupported(roomOptions.frameMetadata ?? roomOptions.packetTrailer) ||
976
- !!this.e2eeManager
977
- ? [ClientInfo_Capability.CAP_PACKET_TRAILER]
978
- : undefined,
984
+ clientInfoCapabilities: this.getClientInfoCapabilities(roomOptions),
979
985
  maxRetries: connectOptions.maxRetries,
980
986
  e2eeEnabled: !!this.e2eeManager,
981
987
  websocketTimeout: connectOptions.websocketTimeout,
@@ -1585,24 +1591,38 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1585
1591
  // at that time, ICE connectivity has not been established so the track is not
1586
1592
  // technically subscribed.
1587
1593
  // We'll defer these events until when the room is connected or eventually disconnected.
1588
- if (this.state === ConnectionState.Connecting || this.state === ConnectionState.Reconnecting) {
1594
+ if ([ConnectionState.Connecting, ConnectionState.Reconnecting].includes(this.state)) {
1595
+ const pendingTrackSid = extractTrackSid(mediaTrack, stream);
1596
+ this.log.debug('deferring on track for later', {
1597
+ mediaTrackId: mediaTrack.id,
1598
+ mediaStreamId: stream.id,
1599
+ tracksInStream: stream.getTracks().map((track) => track.id),
1600
+ });
1589
1601
  const reconnectedHandler = () => {
1590
- this.log.debug('deferring on track for later', {
1591
- mediaTrackId: mediaTrack.id,
1592
- mediaStreamId: stream.id,
1593
- tracksInStream: stream.getTracks().map((track) => track.id),
1594
- });
1595
- this.onTrackAdded(mediaTrack, stream, receiver);
1596
1602
  cleanup();
1603
+ this.onTrackAdded(mediaTrack, stream, receiver);
1597
1604
  };
1598
1605
  const cleanup = () => {
1599
1606
  this.off(RoomEvent.Reconnected, reconnectedHandler);
1600
1607
  this.off(RoomEvent.Connected, reconnectedHandler);
1601
1608
  this.off(RoomEvent.Disconnected, cleanup);
1609
+ if (pendingTrackSid) {
1610
+ const pendingCallbacks = this.pendingTrackAddedCallbacks.get(pendingTrackSid);
1611
+ pendingCallbacks?.delete(cleanup);
1612
+ if (pendingCallbacks?.size === 0) {
1613
+ this.pendingTrackAddedCallbacks.delete(pendingTrackSid);
1614
+ }
1615
+ }
1602
1616
  };
1603
1617
  this.once(RoomEvent.Reconnected, reconnectedHandler);
1604
1618
  this.once(RoomEvent.Connected, reconnectedHandler);
1605
1619
  this.once(RoomEvent.Disconnected, cleanup);
1620
+ if (pendingTrackSid) {
1621
+ const pendingCallbacks =
1622
+ this.pendingTrackAddedCallbacks.get(pendingTrackSid) ?? new Set<() => void>();
1623
+ pendingCallbacks.add(cleanup);
1624
+ this.pendingTrackAddedCallbacks.set(pendingTrackSid, pendingCallbacks);
1625
+ }
1606
1626
  return;
1607
1627
  }
1608
1628
  if (this.state === ConnectionState.Disconnected) {
@@ -1615,11 +1635,10 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1615
1635
  }
1616
1636
  const parts = unpackStreamId(stream.id);
1617
1637
  const participantSid = parts[0];
1618
- let streamId = parts[1];
1619
- let trackId = mediaTrack.id;
1638
+ const streamId = parts[1];
1620
1639
  // firefox will get streamId (pID|trackId) instead of (pID|streamId) as it doesn't support sync tracks by stream
1621
1640
  // and generates its own track id instead of infer from sdp track id.
1622
- if (streamId && streamId.startsWith('TR')) trackId = streamId;
1641
+ let trackId = extractTrackSid(mediaTrack, stream) ?? mediaTrack.id;
1623
1642
 
1624
1643
  if (participantSid === this.localParticipant.sid) {
1625
1644
  this.log.warn('tried to create RemoteParticipant for local participant');
@@ -1688,6 +1707,10 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1688
1707
  }
1689
1708
  }
1690
1709
 
1710
+ private cancelPendingTrackAdded(trackSid: Track.SID) {
1711
+ this.pendingTrackAddedCallbacks.get(trackSid)?.forEach((cleanup) => cleanup());
1712
+ }
1713
+
1691
1714
  private handleLocalTrackSubscribed(subscribedSid: string) {
1692
1715
  const findPublication = () =>
1693
1716
  this.localParticipant
@@ -2005,8 +2028,9 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2005
2028
  return;
2006
2029
  }
2007
2030
  const newStreamState = Track.streamStateFromProto(streamState.state);
2031
+ const prevStreamState = pub.track.streamState;
2008
2032
  pub.track.setStreamState(newStreamState);
2009
- if (newStreamState !== pub.track.streamState) {
2033
+ if (newStreamState !== prevStreamState) {
2010
2034
  participant.emit(ParticipantEvent.TrackStreamStateChanged, pub, pub.track.streamState);
2011
2035
  this.emitWhenConnected(
2012
2036
  RoomEvent.TrackStreamStateChanged,
@@ -2032,6 +2056,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2032
2056
  };
2033
2057
 
2034
2058
  private handleSubscriptionError = (update: SubscriptionResponse) => {
2059
+ this.cancelPendingTrackAdded(update.trackSid);
2060
+
2035
2061
  const participant = Array.from(this.remoteParticipants.values()).find((p) =>
2036
2062
  p.trackPublications.has(update.trackSid),
2037
2063
  );
@@ -2406,6 +2432,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2406
2432
  },
2407
2433
  )
2408
2434
  .on(ParticipantEvent.TrackUnpublished, (publication: RemoteTrackPublication) => {
2435
+ this.cancelPendingTrackAdded(publication.trackSid);
2409
2436
  this.emit(RoomEvent.TrackUnpublished, publication, participant);
2410
2437
  })
2411
2438
  .on(
@@ -2505,10 +2532,39 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2505
2532
  }
2506
2533
  }
2507
2534
 
2535
+ /** The client capabilities this SDK advertises to other participants in its `ClientInfo`. */
2536
+ private getClientInfoCapabilities(
2537
+ roomOptions: InternalRoomOptions,
2538
+ ): Array<ClientInfo_Capability> {
2539
+ const capabilities: Array<ClientInfo_Capability> = [];
2540
+ if (
2541
+ isFrameMetadataSupported(roomOptions.frameMetadata ?? roomOptions.packetTrailer) ||
2542
+ !!this.e2eeManager
2543
+ ) {
2544
+ capabilities.push(ClientInfo_Capability.CAP_PACKET_TRAILER);
2545
+ }
2546
+ // Advertise deflate-raw decompression support so peers know they can send us compressed data
2547
+ // streams (gated separately from clientProtocol — see the data streams v2 spec).
2548
+ if (isCompressionStreamSupported()) {
2549
+ capabilities.push(ClientInfo_Capability.CAP_COMPRESSION_DEFLATE_RAW);
2550
+ }
2551
+ return capabilities;
2552
+ }
2553
+
2508
2554
  private getRemoteParticipantClientProtocol = (identity: Participant['identity']) => {
2509
2555
  return this.remoteParticipants.get(identity)?.clientProtocol ?? CLIENT_PROTOCOL_DEFAULT;
2510
2556
  };
2511
2557
 
2558
+ private getRemoteParticipantCapabilities = (
2559
+ identity: Participant['identity'],
2560
+ ): Array<ClientInfo_Capability> => {
2561
+ return this.remoteParticipants.get(identity)?.capabilities ?? [];
2562
+ };
2563
+
2564
+ private getAllRemoteParticipantIdentities = () => {
2565
+ return Array.from(this.remoteParticipants.keys());
2566
+ };
2567
+
2512
2568
  private registerRpcDataStreamHandler() {
2513
2569
  this.incomingDataStreamManager.registerTextStreamHandler(
2514
2570
  RPC_REQUEST_DATA_STREAM_TOPIC,
@@ -2549,11 +2605,20 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2549
2605
  : undefined,
2550
2606
  });
2551
2607
  if (consecutiveFailures >= 3) {
2552
- this.recreateEngine();
2553
- this.handleDisconnect(
2554
- this.options.stopLocalTrackOnUnpublish,
2555
- DisconnectReason.STATE_MISMATCH,
2556
- );
2608
+ this.clearConnectionReconcile();
2609
+ if (this.engine && !this.engine.isClosed) {
2610
+ // The transport silently died while we still looked connected. Try a full reconnect
2611
+ // (keeps the room alive; the engine falls back to Disconnected if it ultimately fails).
2612
+ this.log.warn('detected connection state mismatch, attempting full reconnect');
2613
+ this.engine.reconnect();
2614
+ } else {
2615
+ // No usable engine to reconnect with; tear down.
2616
+ this.recreateEngine();
2617
+ this.handleDisconnect(
2618
+ this.options.stopLocalTrackOnUnpublish,
2619
+ DisconnectReason.STATE_MISMATCH,
2620
+ );
2621
+ }
2557
2622
  }
2558
2623
  } else {
2559
2624
  consecutiveFailures = 0;
@@ -2575,7 +2640,9 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2575
2640
  this.log.info(`connection state changed: ${this.state} -> ${state}`);
2576
2641
  this.state = state;
2577
2642
  this.incomingDataStreamManager.setConnected(state === ConnectionState.Connected);
2643
+
2578
2644
  this.emit(RoomEvent.ConnectionStateChanged, this.state);
2645
+
2579
2646
  return true;
2580
2647
  }
2581
2648
 
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { PCTransportManager } from '../PCTransportManager';
3
+ import { UnexpectedConnectionState } from '../errors';
4
+ import { DataChannelManager, type DataChannelManagerOptions } from './DataChannelManager';
5
+ import { DataChannelKind } from './types';
6
+
7
+ class FakeDataChannel extends EventTarget {
8
+ bufferedAmount = 0;
9
+
10
+ bufferedAmountLowThreshold = 0;
11
+
12
+ onmessage: ((message: MessageEvent) => void) | null = null;
13
+
14
+ onerror: ((event: Event) => void) | null = null;
15
+
16
+ onclose: (() => void) | null = null;
17
+
18
+ onbufferedamountlow: (() => void) | null = null;
19
+
20
+ send = vi.fn();
21
+
22
+ close = vi.fn();
23
+
24
+ constructor(public label: string) {
25
+ super();
26
+ }
27
+ }
28
+
29
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
30
+
31
+ function makeManager(overrides?: Partial<DataChannelManagerOptions>) {
32
+ const opts = {
33
+ isEngineClosed: () => false,
34
+ isReconnecting: () => false,
35
+ onDataMessage: vi.fn(),
36
+ onDataTrackMessage: vi.fn(),
37
+ onDataError: vi.fn(),
38
+ onChannelClose: vi.fn(),
39
+ onBufferStatusChanged: vi.fn(),
40
+ ...overrides,
41
+ };
42
+ const manager = new DataChannelManager(opts);
43
+ const created: Record<string, FakeDataChannel> = {};
44
+ const pcManager = {
45
+ createPublisherDataChannel: vi.fn((label: string) => {
46
+ const dc = new FakeDataChannel(label);
47
+ created[label] = dc;
48
+ return dc as unknown as RTCDataChannel;
49
+ }),
50
+ } as unknown as PCTransportManager;
51
+ return { manager, opts, pcManager, created };
52
+ }
53
+
54
+ describe('DataChannelManager', () => {
55
+ it('creates and wires the three publisher channels', () => {
56
+ const { manager, opts, pcManager, created } = makeManager();
57
+
58
+ manager.createPublisherChannels(pcManager);
59
+
60
+ expect(Object.keys(created).sort()).toEqual(['_data_track', '_lossy', '_reliable']);
61
+ expect(manager.getHandle(DataChannelKind.RELIABLE)).toBe(created._reliable);
62
+ expect(manager.hasPublisherChannels).toBe(true);
63
+ for (const dc of Object.values(created)) {
64
+ expect(dc.onmessage).toBeTruthy();
65
+ expect(dc.onerror).toBeTruthy();
66
+ expect(dc.onclose).toBeTruthy();
67
+ expect(dc.onbufferedamountlow).toBeTruthy();
68
+ expect(dc.bufferedAmountLowThreshold).toBeGreaterThan(0);
69
+ }
70
+
71
+ // Close handler reports the right kind.
72
+ created._reliable.onclose!();
73
+ expect(opts.onChannelClose).toHaveBeenCalledWith(DataChannelKind.RELIABLE);
74
+
75
+ // A drain event on the data-track channel refreshes its status; since the buffer starts empty
76
+ // (below the low mark) and the wrapper's initial status is already "low", no change fires —
77
+ // fill it first so the drain is an observable not-low → low transition.
78
+ const dataTrackDc = created._data_track;
79
+ dataTrackDc.bufferedAmount = 10 * 1024 * 1024;
80
+ dataTrackDc.onbufferedamountlow!(); // refresh: now "not low"
81
+ dataTrackDc.bufferedAmount = 0;
82
+ dataTrackDc.onbufferedamountlow!(); // refresh: back to "low"
83
+ expect(opts.onBufferStatusChanged).toHaveBeenCalledWith(
84
+ DataChannelKind.DATA_TRACK_LOSSY,
85
+ false,
86
+ );
87
+ expect(opts.onBufferStatusChanged).toHaveBeenCalledWith(DataChannelKind.DATA_TRACK_LOSSY, true);
88
+
89
+ manager.lossy.stopThresholdTuning();
90
+ });
91
+
92
+ it('recreating channels rejects waiters parked on the replaced handles', async () => {
93
+ const { manager, pcManager, created } = makeManager();
94
+ manager.createPublisherChannels(pcManager);
95
+
96
+ // Park a reliable sender on the first-generation channel.
97
+ created._reliable.bufferedAmount = 2 * 1024 * 1024;
98
+ const parked = manager.reliable.waitForHeadroomWithLock();
99
+ parked.catch(() => {});
100
+ await tick();
101
+
102
+ // Safari null-id path: channels recreated without closing the old ones.
103
+ manager.createPublisherChannels(pcManager);
104
+
105
+ await expect(parked).rejects.toBeInstanceOf(UnexpectedConnectionState);
106
+ // The gate recovers against the fresh (empty) channel.
107
+ await expect(manager.reliable.waitForHeadroomWithLock()).resolves.toBeUndefined();
108
+
109
+ manager.lossy.stopThresholdTuning();
110
+ });
111
+
112
+ it('adopts subscriber channels by label and rejects unknown labels', () => {
113
+ const { manager, opts } = makeManager();
114
+
115
+ const sub = new FakeDataChannel('_reliable');
116
+ expect(manager.adoptSubscriberChannel(sub as unknown as RTCDataChannel)).toBe(true);
117
+ expect(manager.getHandle(DataChannelKind.RELIABLE, true)).toBe(sub);
118
+ expect(sub.onmessage).toBe(opts.onDataMessage);
119
+
120
+ const dataTrackSub = new FakeDataChannel('_data_track');
121
+ expect(manager.adoptSubscriberChannel(dataTrackSub as unknown as RTCDataChannel)).toBe(true);
122
+ expect(dataTrackSub.onmessage).toBe(opts.onDataTrackMessage);
123
+
124
+ expect(
125
+ manager.adoptSubscriberChannel(new FakeDataChannel('_other') as unknown as RTCDataChannel),
126
+ ).toBe(false);
127
+ });
128
+
129
+ it('teardown rejects parked waiters, closes all handles, and detaches', async () => {
130
+ const { manager, pcManager, created } = makeManager();
131
+ manager.createPublisherChannels(pcManager);
132
+ const sub = new FakeDataChannel('_lossy');
133
+ manager.adoptSubscriberChannel(sub as unknown as RTCDataChannel);
134
+
135
+ created._reliable.bufferedAmount = 2 * 1024 * 1024;
136
+ const parked = manager.reliable.waitForHeadroomWithLock();
137
+ parked.catch(() => {});
138
+ await tick();
139
+
140
+ manager.teardown();
141
+
142
+ await expect(parked).rejects.toBeInstanceOf(UnexpectedConnectionState);
143
+ for (const dc of [...Object.values(created), sub]) {
144
+ expect(dc.close).toHaveBeenCalled();
145
+ expect(dc.onmessage).toBeNull();
146
+ }
147
+ expect(manager.hasPublisherChannels).toBe(false);
148
+ expect(manager.getHandle(DataChannelKind.LOSSY, true)).toBeUndefined();
149
+
150
+ manager.lossy.stopThresholdTuning();
151
+ });
152
+ });