livekit-client 2.20.2 → 2.21.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 (105) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +4 -0
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +1993 -791
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +1 -1
  8. package/dist/livekit-client.fm.worker.js.map +1 -1
  9. package/dist/livekit-client.fm.worker.mjs +4 -0
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/index.d.ts +1 -1
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/room/RTCEngine.d.ts +28 -64
  16. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  17. package/dist/src/room/Room.d.ts +4 -0
  18. package/dist/src/room/Room.d.ts.map +1 -1
  19. package/dist/src/room/data-channel/DataChannelManager.d.ts +62 -0
  20. package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -0
  21. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
  22. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -0
  23. package/dist/src/room/data-channel/LossyDataChannel.d.ts +40 -0
  24. package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -0
  25. package/dist/src/room/data-channel/ReliableDataChannel.d.ts +56 -0
  26. package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -0
  27. package/dist/src/room/data-channel/types.d.ts +12 -0
  28. package/dist/src/room/data-channel/types.d.ts.map +1 -0
  29. package/dist/src/room/data-stream/compression.d.ts +49 -0
  30. package/dist/src/room/data-stream/compression.d.ts.map +1 -0
  31. package/dist/src/room/data-stream/constants.d.ts +18 -0
  32. package/dist/src/room/data-stream/constants.d.ts.map +1 -0
  33. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
  34. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
  35. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  36. package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
  37. package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts.map +1 -1
  38. package/dist/src/room/data-stream/outgoing/header-utils.d.ts +18 -0
  39. package/dist/src/room/data-stream/outgoing/header-utils.d.ts.map +1 -0
  40. package/dist/src/room/errors.d.ts +3 -1
  41. package/dist/src/room/errors.d.ts.map +1 -1
  42. package/dist/src/room/participant/LocalParticipant.d.ts +9 -1
  43. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  44. package/dist/src/room/participant/RemoteParticipant.d.ts +11 -2
  45. package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
  46. package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
  47. package/dist/src/room/rpc/server/RpcServerManager.d.ts.map +1 -1
  48. package/dist/src/room/types.d.ts +17 -0
  49. package/dist/src/room/types.d.ts.map +1 -1
  50. package/dist/src/room/utils.d.ts +13 -0
  51. package/dist/src/room/utils.d.ts.map +1 -1
  52. package/dist/src/version.d.ts +5 -1
  53. package/dist/src/version.d.ts.map +1 -1
  54. package/dist/ts4.2/index.d.ts +1 -1
  55. package/dist/ts4.2/room/RTCEngine.d.ts +28 -64
  56. package/dist/ts4.2/room/Room.d.ts +4 -0
  57. package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +62 -0
  58. package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
  59. package/dist/ts4.2/room/data-channel/LossyDataChannel.d.ts +41 -0
  60. package/dist/ts4.2/room/data-channel/ReliableDataChannel.d.ts +57 -0
  61. package/dist/ts4.2/room/data-channel/types.d.ts +12 -0
  62. package/dist/ts4.2/room/data-stream/compression.d.ts +49 -0
  63. package/dist/ts4.2/room/data-stream/constants.d.ts +18 -0
  64. package/dist/ts4.2/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
  65. package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
  66. package/dist/ts4.2/room/data-stream/outgoing/header-utils.d.ts +18 -0
  67. package/dist/ts4.2/room/errors.d.ts +3 -1
  68. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +9 -1
  69. package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +11 -1
  70. package/dist/ts4.2/room/types.d.ts +17 -0
  71. package/dist/ts4.2/room/utils.d.ts +13 -0
  72. package/dist/ts4.2/version.d.ts +5 -1
  73. package/package.json +2 -2
  74. package/src/index.ts +2 -0
  75. package/src/room/RTCEngine.test.ts +58 -99
  76. package/src/room/RTCEngine.ts +92 -534
  77. package/src/room/Room.test.ts +4 -1
  78. package/src/room/Room.ts +40 -8
  79. package/src/room/data-channel/DataChannelManager.test.ts +152 -0
  80. package/src/room/data-channel/DataChannelManager.ts +237 -0
  81. package/src/room/data-channel/FlowControlledDataChannel.test.ts +154 -0
  82. package/src/room/data-channel/FlowControlledDataChannel.ts +216 -0
  83. package/src/room/data-channel/LossyDataChannel.test.ts +118 -0
  84. package/src/room/data-channel/LossyDataChannel.ts +125 -0
  85. package/src/room/data-channel/ReliableDataChannel.test.ts +158 -0
  86. package/src/room/data-channel/ReliableDataChannel.ts +153 -0
  87. package/src/room/data-channel/types.ts +30 -0
  88. package/src/room/data-stream/compression.ts +117 -0
  89. package/src/room/data-stream/constants.ts +18 -0
  90. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +1601 -0
  91. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +438 -86
  92. package/src/room/data-stream/incoming/StreamReader.ts +8 -2
  93. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts +1292 -0
  94. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +489 -116
  95. package/src/room/data-stream/outgoing/header-utils.ts +87 -0
  96. package/src/room/errors.ts +6 -0
  97. package/src/room/participant/LocalParticipant.ts +13 -0
  98. package/src/room/participant/RemoteParticipant.ts +19 -5
  99. package/src/room/rpc/client/RpcClientManager.test.ts +20 -27
  100. package/src/room/rpc/client/RpcClientManager.ts +1 -4
  101. package/src/room/rpc/server/RpcServerManager.test.ts +24 -23
  102. package/src/room/rpc/server/RpcServerManager.ts +1 -3
  103. package/src/room/types.ts +18 -0
  104. package/src/room/utils.ts +67 -0
  105. package/src/version.ts +5 -1
@@ -81,7 +81,10 @@ describe('Room signaling options', () => {
81
81
  'wss://test.livekit.io',
82
82
  'test-token',
83
83
  expect.objectContaining({
84
- clientInfoCapabilities: [ClientInfo_Capability.CAP_PACKET_TRAILER],
84
+ clientInfoCapabilities: [
85
+ ClientInfo_Capability.CAP_PACKET_TRAILER,
86
+ ClientInfo_Capability.CAP_COMPRESSION_DEFLATE_RAW,
87
+ ],
85
88
  e2eeEnabled: true,
86
89
  }),
87
90
  expect.any(AbortSignal),
package/src/room/Room.ts CHANGED
@@ -118,6 +118,7 @@ import {
118
118
  getEmptyAudioStreamTrack,
119
119
  isBrowserSupported,
120
120
  isCloud,
121
+ isCompressionStreamSupported,
121
122
  isLocalAudioTrack,
122
123
  isLocalParticipant,
123
124
  isReactNative,
@@ -268,7 +269,13 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
268
269
  this.maybeCreateEngine();
269
270
 
270
271
  this.incomingDataStreamManager = new IncomingDataStreamManager();
271
- this.outgoingDataStreamManager = new OutgoingDataStreamManager(this.engine, this.log);
272
+ this.outgoingDataStreamManager = new OutgoingDataStreamManager(
273
+ this.engine,
274
+ this.log,
275
+ this.getRemoteParticipantClientProtocol,
276
+ this.getRemoteParticipantCapabilities,
277
+ this.getAllRemoteParticipantIdentities,
278
+ );
272
279
 
273
280
  this.incomingDataTrackManager = new IncomingDataTrackManager({ e2eeManager: this.e2eeManager });
274
281
  this.incomingDataTrackManager
@@ -308,7 +315,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
308
315
  })
309
316
  .on('packetAvailable', ({ handle, bytes }) => {
310
317
  this.engine
311
- .sendLossyBytes(bytes, DataChannelKind.DATA_TRACK_LOSSY, 'wait')
318
+ .sendDataTrackFrame(bytes)
312
319
  .finally(() => this.outgoingDataTrackManager.handlePacketSendComplete(handle));
313
320
  });
314
321
 
@@ -623,10 +630,10 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
623
630
  this.isResuming = false;
624
631
  this.log.debug('Resumed signal connection');
625
632
  this.updateSubscriptions();
626
- this.emitBufferedEvents();
627
633
  if (this.setAndEmitConnectionState(ConnectionState.Connected)) {
628
634
  this.emit(RoomEvent.Reconnected);
629
635
  }
636
+ this.emitBufferedEvents();
630
637
  })
631
638
  .on(EngineEvent.SignalResumed, () => {
632
639
  this.bufferedEvents = [];
@@ -971,11 +978,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
971
978
  autoSubscribe: connectOptions.autoSubscribe,
972
979
  adaptiveStream:
973
980
  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,
981
+ clientInfoCapabilities: this.getClientInfoCapabilities(roomOptions),
979
982
  maxRetries: connectOptions.maxRetries,
980
983
  e2eeEnabled: !!this.e2eeManager,
981
984
  websocketTimeout: connectOptions.websocketTimeout,
@@ -2505,10 +2508,39 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2505
2508
  }
2506
2509
  }
2507
2510
 
2511
+ /** The client capabilities this SDK advertises to other participants in its `ClientInfo`. */
2512
+ private getClientInfoCapabilities(
2513
+ roomOptions: InternalRoomOptions,
2514
+ ): Array<ClientInfo_Capability> {
2515
+ const capabilities: Array<ClientInfo_Capability> = [];
2516
+ if (
2517
+ isFrameMetadataSupported(roomOptions.frameMetadata ?? roomOptions.packetTrailer) ||
2518
+ !!this.e2eeManager
2519
+ ) {
2520
+ capabilities.push(ClientInfo_Capability.CAP_PACKET_TRAILER);
2521
+ }
2522
+ // Advertise deflate-raw decompression support so peers know they can send us compressed data
2523
+ // streams (gated separately from clientProtocol — see the data streams v2 spec).
2524
+ if (isCompressionStreamSupported()) {
2525
+ capabilities.push(ClientInfo_Capability.CAP_COMPRESSION_DEFLATE_RAW);
2526
+ }
2527
+ return capabilities;
2528
+ }
2529
+
2508
2530
  private getRemoteParticipantClientProtocol = (identity: Participant['identity']) => {
2509
2531
  return this.remoteParticipants.get(identity)?.clientProtocol ?? CLIENT_PROTOCOL_DEFAULT;
2510
2532
  };
2511
2533
 
2534
+ private getRemoteParticipantCapabilities = (
2535
+ identity: Participant['identity'],
2536
+ ): Array<ClientInfo_Capability> => {
2537
+ return this.remoteParticipants.get(identity)?.capabilities ?? [];
2538
+ };
2539
+
2540
+ private getAllRemoteParticipantIdentities = () => {
2541
+ return Array.from(this.remoteParticipants.keys());
2542
+ };
2543
+
2512
2544
  private registerRpcDataStreamHandler() {
2513
2545
  this.incomingDataStreamManager.registerTextStreamHandler(
2514
2546
  RPC_REQUEST_DATA_STREAM_TOPIC,
@@ -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
+ });
@@ -0,0 +1,237 @@
1
+ import type { PCTransportManager } from '../PCTransportManager';
2
+ import { FlowControlledDataChannel } from './FlowControlledDataChannel';
3
+ import { LossyDataChannel } from './LossyDataChannel';
4
+ import { ReliableDataChannel } from './ReliableDataChannel';
5
+ import { DataChannelKind, dataChannelHighWaterMark, dataChannelLowWaterMark } from './types';
6
+
7
+ const lossyDataChannelLabel = '_lossy';
8
+ const reliableDataChannelLabel = '_reliable';
9
+ const dataTrackDataChannelLabel = '_data_track';
10
+
11
+ export interface DataChannelManagerOptions {
12
+ /** Whether the owning engine has been closed — a closed engine rejects headroom waiters. */
13
+ isEngineClosed: () => boolean;
14
+ /**
15
+ * Whether a reconnect attempt is underway: reliable sends defer to the resume replay and lossy
16
+ * sends are skipped while this is true.
17
+ */
18
+ isReconnecting: () => boolean;
19
+ onDataMessage: (message: MessageEvent) => void;
20
+ onDataTrackMessage: (message: MessageEvent) => void;
21
+ onDataError: (event: Event) => void;
22
+ onChannelClose: (kind: DataChannelKind) => void;
23
+ /** A channel's buffer crossed its low-water mark (debounced). Drives DCBufferStatusChanged. */
24
+ onBufferStatusChanged: (kind: DataChannelKind, isLow: boolean) => void;
25
+ }
26
+
27
+ /**
28
+ * Owns the engine's data channels: the three flow-controlled publisher channel wrappers (which
29
+ * live for the engine's lifetime and have RTCDataChannel handles attached/detached as peer
30
+ * connections come and go) plus the subscriber-side receive handles adopted by label.
31
+ *
32
+ * Handle turnover goes through {@link FlowControlledDataChannel.attach}/`detach`, which reject
33
+ * parked headroom waiters as a built-in — there is no separate invalidation step to forget.
34
+ */
35
+ export class DataChannelManager {
36
+ readonly reliable: ReliableDataChannel;
37
+
38
+ readonly lossy: LossyDataChannel;
39
+
40
+ readonly dataTrack: LossyDataChannel;
41
+
42
+ private reliableSub?: RTCDataChannel;
43
+
44
+ private lossySub?: RTCDataChannel;
45
+
46
+ private dataTrackSub?: RTCDataChannel;
47
+
48
+ private opts: DataChannelManagerOptions;
49
+
50
+ constructor(opts: DataChannelManagerOptions) {
51
+ this.opts = opts;
52
+ const flowControlOptions = (kind: DataChannelKind) => ({
53
+ kind,
54
+ lowWaterMark: dataChannelLowWaterMark(kind),
55
+ highWaterMark: dataChannelHighWaterMark(kind),
56
+ isEngineClosed: opts.isEngineClosed,
57
+ onBufferStatusChanged: (isLow: boolean) => opts.onBufferStatusChanged(kind, isLow),
58
+ });
59
+ this.reliable = new ReliableDataChannel({
60
+ ...flowControlOptions(DataChannelKind.RELIABLE),
61
+ isDeferringSends: opts.isReconnecting,
62
+ });
63
+ this.lossy = new LossyDataChannel({
64
+ ...flowControlOptions(DataChannelKind.LOSSY),
65
+ // Classic lossy user data: a stale packet is worthless, so drop instead of queueing.
66
+ bufferFullBehavior: 'drop',
67
+ shouldSkipSends: opts.isReconnecting,
68
+ });
69
+ this.dataTrack = new LossyDataChannel({
70
+ ...flowControlOptions(DataChannelKind.DATA_TRACK_LOSSY),
71
+ // Data tracks backpressure the producer instead — it decides what to skip at frame
72
+ // granularity rather than the engine dropping arbitrary chunks out of frames.
73
+ bufferFullBehavior: 'wait',
74
+ shouldSkipSends: opts.isReconnecting,
75
+ });
76
+ }
77
+
78
+ /** The flow-control wrapper for `kind`. */
79
+ channelFor(kind: DataChannelKind): FlowControlledDataChannel {
80
+ switch (kind) {
81
+ case DataChannelKind.RELIABLE:
82
+ return this.reliable;
83
+ case DataChannelKind.LOSSY:
84
+ return this.lossy;
85
+ case DataChannelKind.DATA_TRACK_LOSSY:
86
+ return this.dataTrack;
87
+ }
88
+ }
89
+
90
+ /** The raw RTCDataChannel handle for `kind`, publisher side by default. */
91
+ getHandle(kind: DataChannelKind, subscriber: boolean = false): RTCDataChannel | undefined {
92
+ if (!subscriber) {
93
+ return this.channelFor(kind).channelHandle;
94
+ }
95
+ switch (kind) {
96
+ case DataChannelKind.RELIABLE:
97
+ return this.reliableSub;
98
+ case DataChannelKind.LOSSY:
99
+ return this.lossySub;
100
+ case DataChannelKind.DATA_TRACK_LOSSY:
101
+ return this.dataTrackSub;
102
+ }
103
+ }
104
+
105
+ get hasPublisherChannels(): boolean {
106
+ return Boolean(
107
+ this.reliable.channelHandle || this.lossy.channelHandle || this.dataTrack.channelHandle,
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Creates the three publisher data channels on the given transport, wires their handlers, and
113
+ * attaches them to the wrappers — attaching rejects any waiters still parked on replaced
114
+ * channel objects.
115
+ */
116
+ createPublisherChannels(pcManager: PCTransportManager) {
117
+ // clear old data channel callbacks if recreate
118
+ for (const channel of [this.lossy, this.reliable, this.dataTrack]) {
119
+ const old = channel.channelHandle;
120
+ if (old) {
121
+ old.onmessage = null;
122
+ old.onerror = null;
123
+ old.onclose = null;
124
+ }
125
+ }
126
+
127
+ const wire = (
128
+ channel: FlowControlledDataChannel,
129
+ dc: RTCDataChannel,
130
+ onMessage: (message: MessageEvent) => void,
131
+ ) => {
132
+ // also handle messages over the pub channel, for backwards compatibility
133
+ dc.onmessage = onMessage;
134
+ // handle datachannel errors
135
+ dc.onerror = this.opts.onDataError;
136
+ // detect unexpected publisher data channel closes
137
+ dc.onclose = () => this.opts.onChannelClose(channel.kind);
138
+ // set up dc buffer threshold - if this is not set, it will default to 0
139
+ dc.bufferedAmountLowThreshold = channel.lowWaterMark;
140
+ // handle buffer amount low events
141
+ dc.onbufferedamountlow = () => channel.refreshBufferStatus();
142
+ channel.attach(dc);
143
+ };
144
+
145
+ wire(
146
+ this.lossy,
147
+ pcManager.createPublisherDataChannel(lossyDataChannelLabel, {
148
+ ordered: false,
149
+ maxRetransmits: 0,
150
+ }),
151
+ this.opts.onDataMessage,
152
+ );
153
+ wire(
154
+ this.reliable,
155
+ pcManager.createPublisherDataChannel(reliableDataChannelLabel, {
156
+ ordered: true,
157
+ }),
158
+ this.opts.onDataMessage,
159
+ );
160
+ wire(
161
+ this.dataTrack,
162
+ pcManager.createPublisherDataChannel(dataTrackDataChannelLabel, {
163
+ ordered: false,
164
+ maxRetransmits: 0,
165
+ }),
166
+ this.opts.onDataTrackMessage,
167
+ );
168
+
169
+ this.lossy.startThresholdTuning();
170
+ }
171
+
172
+ /**
173
+ * Adopts a subscriber-side data channel by label, wiring the matching receive handler.
174
+ * Returns false for labels this manager doesn't own.
175
+ */
176
+ adoptSubscriberChannel(channel: RTCDataChannel): boolean {
177
+ let handler: (message: MessageEvent) => void;
178
+ if (channel.label === reliableDataChannelLabel) {
179
+ this.reliableSub = channel;
180
+ handler = this.opts.onDataMessage;
181
+ } else if (channel.label === lossyDataChannelLabel) {
182
+ this.lossySub = channel;
183
+ handler = this.opts.onDataMessage;
184
+ } else if (channel.label === dataTrackDataChannelLabel) {
185
+ this.dataTrackSub = channel;
186
+ handler = this.opts.onDataTrackMessage;
187
+ } else {
188
+ return false;
189
+ }
190
+ channel.onmessage = handler;
191
+ return true;
192
+ }
193
+
194
+ /**
195
+ * Tears down all channels for a peer-connection cleanup: rejects parked waiters (detach — the
196
+ * spec allows `pc.close()` to transition channels to 'closed' without firing events, so waiting
197
+ * for browser close events is not an option), strips handlers, closes the handles, and resets
198
+ * the reliable session state.
199
+ */
200
+ teardown() {
201
+ const dcCleanup = (dc: RTCDataChannel | undefined) => {
202
+ if (!dc) {
203
+ return;
204
+ }
205
+
206
+ // Detach the data channel handlers before closing anything. Closing a peer connection tears
207
+ // down the SCTP transport, which can dispatch `error`/`close` events on the still-open data
208
+ // channels; if our handlers are still attached at that point, handleDataError logs a spurious
209
+ // "Unknown DataChannel error" during an otherwise graceful disconnect. Removing the handlers
210
+ // before dc.close()/pcManager.close() makes this deterministic regardless of how/when the
211
+ // browser dispatches those teardown events. See livekit/client-sdk-js#1953.
212
+ dc.onbufferedamountlow = null;
213
+ dc.onclose = null;
214
+ dc.onclosing = null;
215
+ dc.onerror = null;
216
+ dc.onmessage = null;
217
+ dc.onopen = null;
218
+
219
+ dc.close();
220
+ };
221
+
222
+ for (const channel of [this.lossy, this.reliable, this.dataTrack]) {
223
+ const dc = channel.channelHandle;
224
+ channel.detach('peer connections cleaned up');
225
+ dcCleanup(dc);
226
+ }
227
+ dcCleanup(this.lossySub);
228
+ dcCleanup(this.reliableSub);
229
+ dcCleanup(this.dataTrackSub);
230
+ this.lossySub = undefined;
231
+ this.reliableSub = undefined;
232
+ this.dataTrackSub = undefined;
233
+
234
+ // Full teardown starts the session over: drop replay state and restart sequencing.
235
+ this.reliable.reset();
236
+ }
237
+ }
@@ -0,0 +1,154 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { UnexpectedConnectionState } from '../errors';
3
+ import { FlowControlledDataChannel } from './FlowControlledDataChannel';
4
+ import { DataChannelKind } from './types';
5
+
6
+ class FakeDataChannel extends EventTarget {
7
+ bufferedAmount = 0;
8
+
9
+ bufferedAmountLowThreshold = 64;
10
+ }
11
+
12
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
13
+
14
+ // Pass `dc: null` for a handle-less channel; omit it to get a fresh one. `null` (not `undefined`)
15
+ // is deliberate — a destructuring default fills in on `undefined`, so only `null` passes through.
16
+ function makeChannel({
17
+ dc = new FakeDataChannel(),
18
+ engineClosed = false,
19
+ }: { dc?: FakeDataChannel | null; engineClosed?: boolean } = {}) {
20
+ const state = { engineClosed };
21
+ const onBufferStatusChanged = vi.fn();
22
+ const channel = new FlowControlledDataChannel({
23
+ kind: DataChannelKind.RELIABLE,
24
+ lowWaterMark: 64,
25
+ highWaterMark: 1024,
26
+ isEngineClosed: () => state.engineClosed,
27
+ onBufferStatusChanged,
28
+ });
29
+ if (dc) {
30
+ channel.attach(dc as unknown as RTCDataChannel);
31
+ }
32
+ return { channel, dc: dc as FakeDataChannel, state, onBufferStatusChanged };
33
+ }
34
+
35
+ describe('FlowControlledDataChannel', () => {
36
+ describe('refreshBufferStatus', () => {
37
+ it('notifies only on a change and in both directions', () => {
38
+ const { channel, dc, onBufferStatusChanged } = makeChannel();
39
+
40
+ // Starts "low" (empty buffer); a refresh that stays low emits nothing.
41
+ channel.refreshBufferStatus();
42
+ expect(onBufferStatusChanged).not.toHaveBeenCalled();
43
+
44
+ // Cross above the low mark → one not-low notification.
45
+ dc.bufferedAmount = 512;
46
+ channel.refreshBufferStatus();
47
+ channel.refreshBufferStatus(); // debounced: no second fire
48
+ expect(onBufferStatusChanged.mock.calls).toEqual([[false]]);
49
+
50
+ // Drain back below → one low notification.
51
+ dc.bufferedAmount = 0;
52
+ channel.refreshBufferStatus();
53
+ expect(onBufferStatusChanged.mock.calls).toEqual([[false], [true]]);
54
+ });
55
+
56
+ it('is a no-op without a handle (no throw, no notification)', () => {
57
+ const { channel, onBufferStatusChanged } = makeChannel({ dc: null });
58
+ expect(() => channel.refreshBufferStatus()).not.toThrow();
59
+ expect(onBufferStatusChanged).not.toHaveBeenCalled();
60
+ });
61
+ });
62
+
63
+ it('reports watermark status against the given channel', () => {
64
+ const { channel, dc } = makeChannel();
65
+ const handle = dc as unknown as RTCDataChannel;
66
+ dc.bufferedAmount = 0;
67
+ expect(channel.isBelowHighWaterMark(handle)).toBe(true);
68
+ expect(channel.isBelowLowWaterMark(handle)).toBe(true);
69
+
70
+ dc.bufferedAmount = 512; // between low (64) and high (1024)
71
+ expect(channel.isBelowHighWaterMark(handle)).toBe(true);
72
+ expect(channel.isBelowLowWaterMark(handle)).toBe(false);
73
+
74
+ dc.bufferedAmount = 2048;
75
+ expect(channel.isBelowHighWaterMark(handle)).toBe(false);
76
+ });
77
+
78
+ it('waiting for headroom without a handle rejects with a connection error', async () => {
79
+ const { channel } = makeChannel({ dc: null });
80
+ await expect(channel.waitForHeadroomWithLock()).rejects.toBeInstanceOf(
81
+ UnexpectedConnectionState,
82
+ );
83
+ });
84
+
85
+ it('resolves immediately while below the high-water mark', async () => {
86
+ const { channel, dc } = makeChannel();
87
+ dc.bufferedAmount = 1024;
88
+ await expect(channel.waitForHeadroomWithLock()).resolves.toBeUndefined();
89
+ });
90
+
91
+ it('parks above the high-water mark and resumes on bufferedamountlow', async () => {
92
+ const { channel, dc } = makeChannel();
93
+ dc.bufferedAmount = 2048;
94
+ const resolved = vi.fn();
95
+ const wait = channel.waitForHeadroomWithLock().then(resolved);
96
+ await tick();
97
+ expect(resolved).not.toHaveBeenCalled();
98
+
99
+ dc.bufferedAmount = 32;
100
+ dc.dispatchEvent(new Event('bufferedamountlow'));
101
+ await wait;
102
+ expect(resolved).toHaveBeenCalled();
103
+ });
104
+
105
+ it('rejects a parked waiter when the channel closes', async () => {
106
+ const { channel, dc } = makeChannel();
107
+ dc.bufferedAmount = 2048;
108
+ const wait = channel.waitForHeadroomWithLock();
109
+ wait.catch(() => {});
110
+ await tick();
111
+
112
+ dc.dispatchEvent(new Event('close'));
113
+ await expect(wait).rejects.toBeInstanceOf(UnexpectedConnectionState);
114
+ });
115
+
116
+ it('rejects a parked waiter on invalidateWaiters and recovers with a fresh controller', async () => {
117
+ const { channel, dc } = makeChannel();
118
+ dc.bufferedAmount = 2048;
119
+ const wait = channel.waitForHeadroomWithLock();
120
+ wait.catch(() => {});
121
+ await tick();
122
+
123
+ channel.invalidateWaiters('channel replaced');
124
+ await expect(wait).rejects.toBeInstanceOf(UnexpectedConnectionState);
125
+
126
+ // Fresh controller: the gate is usable again and the lock was released.
127
+ dc.bufferedAmount = 0;
128
+ await expect(channel.waitForHeadroomWithLock()).resolves.toBeUndefined();
129
+ });
130
+
131
+ it('rejects immediately when the engine is closed', async () => {
132
+ const { channel, state } = makeChannel();
133
+ state.engineClosed = true;
134
+ await expect(channel.waitForHeadroomWithLock()).rejects.toBeInstanceOf(
135
+ UnexpectedConnectionState,
136
+ );
137
+ });
138
+
139
+ it('serializes waiters FIFO through the headroom lock', async () => {
140
+ const { channel, dc } = makeChannel();
141
+ dc.bufferedAmount = 2048;
142
+ const order: number[] = [];
143
+ const first = channel.waitForHeadroomWithLock().then(() => order.push(1));
144
+ const second = channel.waitForHeadroomWithLock().then(() => order.push(2));
145
+ await tick();
146
+
147
+ // One drain event wakes the head waiter; the second re-checks under the lock and, with the
148
+ // buffer now low, proceeds right after — strictly in arrival order.
149
+ dc.bufferedAmount = 0;
150
+ dc.dispatchEvent(new Event('bufferedamountlow'));
151
+ await Promise.all([first, second]);
152
+ expect(order).toEqual([1, 2]);
153
+ });
154
+ });