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
@@ -0,0 +1,118 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { LossyDataChannel } from './LossyDataChannel';
3
+ import { DataChannelKind } from './types';
4
+
5
+ class FakeDataChannel extends EventTarget {
6
+ bufferedAmount = 0;
7
+
8
+ bufferedAmountLowThreshold = 64;
9
+
10
+ send = vi.fn();
11
+ }
12
+
13
+ // Pass `dc: null` for a handle-less channel; omit it to get a fresh one. `null` (not `undefined`)
14
+ // is deliberate — a destructuring default fills in on `undefined`, so only `null` passes through.
15
+ function makeChannel(
16
+ bufferFullBehavior: 'drop' | 'wait',
17
+ { dc = new FakeDataChannel() }: { dc?: FakeDataChannel | null } = {},
18
+ ) {
19
+ const state = { engineClosed: false, skipSends: false };
20
+ const channel = new LossyDataChannel({
21
+ kind: DataChannelKind.LOSSY,
22
+ lowWaterMark: 64,
23
+ highWaterMark: 1024,
24
+ isEngineClosed: () => state.engineClosed,
25
+ bufferFullBehavior,
26
+ shouldSkipSends: () => state.skipSends,
27
+ });
28
+ if (dc) {
29
+ channel.attach(dc as unknown as RTCDataChannel);
30
+ }
31
+ const stats = channel as unknown as { statCurrentBytes: number; dropCount: number };
32
+ return { channel, dc, state, stats };
33
+ }
34
+
35
+ describe('LossyDataChannel', () => {
36
+ beforeEach(() => {
37
+ vi.useFakeTimers();
38
+ });
39
+
40
+ afterEach(() => {
41
+ vi.useRealTimers();
42
+ });
43
+
44
+ it('drop policy: sends below the threshold and counts bytes', async () => {
45
+ const { channel, dc, stats } = makeChannel('drop');
46
+ const msg = new Uint8Array(10);
47
+
48
+ await channel.send(msg);
49
+
50
+ expect(dc.send).toHaveBeenCalledWith(msg);
51
+ expect(stats.statCurrentBytes).toBe(10);
52
+ });
53
+
54
+ it('drop policy: discards while the buffer is above the dc threshold', async () => {
55
+ const { channel, dc, stats } = makeChannel('drop');
56
+ dc.bufferedAmount = 128; // above the 64-byte dc threshold
57
+
58
+ await channel.send(new Uint8Array(10));
59
+
60
+ expect(dc.send).not.toHaveBeenCalled();
61
+ expect(stats.dropCount).toBe(1);
62
+ expect(stats.statCurrentBytes).toBe(0);
63
+ });
64
+
65
+ it('wait policy: parks above the high-water mark instead of dropping', async () => {
66
+ const { channel, dc } = makeChannel('wait');
67
+ dc.bufferedAmount = 2048;
68
+ const resolved = vi.fn();
69
+ const send = channel.send(new Uint8Array(10)).then(resolved);
70
+ await vi.advanceTimersByTimeAsync(0);
71
+ expect(resolved).not.toHaveBeenCalled();
72
+
73
+ dc.bufferedAmount = 0;
74
+ dc.dispatchEvent(new Event('bufferedamountlow'));
75
+ await send;
76
+ expect(dc.send).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it('skips sends while a reconnect is underway', async () => {
80
+ const { channel, dc, state } = makeChannel('drop');
81
+ state.skipSends = true;
82
+
83
+ await channel.send(new Uint8Array(10));
84
+
85
+ expect(dc.send).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it('tunes the dc threshold to ~100ms of observed byterate, clamped to the watermarks', async () => {
89
+ const { channel, dc } = makeChannel('drop');
90
+ channel.startThresholdTuning();
91
+
92
+ // 10_000 bytes/second → threshold byterate/10 = 1000, clamped to the 1024 high mark? No —
93
+ // 1000 is within [64, 1024], so it applies as-is.
94
+ await channel.send(new Uint8Array(10_000));
95
+ await vi.advanceTimersByTimeAsync(1000);
96
+ expect(dc.bufferedAmountLowThreshold).toBe(1000);
97
+
98
+ // Idle second → byterate 0 → clamps up to the low-water mark.
99
+ await vi.advanceTimersByTimeAsync(1000);
100
+ expect(dc.bufferedAmountLowThreshold).toBe(64);
101
+
102
+ channel.stopThresholdTuning();
103
+ });
104
+
105
+ it('stopThresholdTuning halts adjustments and resets stats', async () => {
106
+ const { channel, dc, stats } = makeChannel('drop');
107
+ channel.startThresholdTuning();
108
+ await channel.send(new Uint8Array(500));
109
+
110
+ channel.stopThresholdTuning();
111
+ expect(stats.statCurrentBytes).toBe(0);
112
+ expect(stats.dropCount).toBe(0);
113
+
114
+ const before = dc.bufferedAmountLowThreshold;
115
+ await vi.advanceTimersByTimeAsync(2000);
116
+ expect(dc.bufferedAmountLowThreshold).toBe(before);
117
+ });
118
+ });
@@ -0,0 +1,125 @@
1
+ import log from '../../logger';
2
+ import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays';
3
+ import CriticalTimers from '../timers';
4
+ import {
5
+ FlowControlledDataChannel,
6
+ type FlowControlledDataChannelOptions,
7
+ } from './FlowControlledDataChannel';
8
+
9
+ export interface LossyDataChannelOptions extends FlowControlledDataChannelOptions {
10
+ /**
11
+ * What to do with a send while the buffer is full: `drop` discards it to keep latency bounded
12
+ * (the classic lossy channel), `wait` backpressures the producer until there is headroom (the
13
+ * data-track channel, whose producer decides what to skip at frame granularity).
14
+ */
15
+ bufferFullBehavior: 'drop' | 'wait';
16
+ /** Sends are silently discarded while this is true (a reconnect attempt is underway). */
17
+ shouldSkipSends: () => boolean;
18
+ }
19
+
20
+ /**
21
+ * A lossy channel: flow control plus a per-instance full-buffer policy.
22
+ *
23
+ * Each instance owns its own byterate stat, drop counter, and (when tuning is started) the
24
+ * dynamic `bufferedAmountLowThreshold` adjustment that keeps the drop gate at roughly 100ms of
25
+ * buffered latency. Keeping these per instance is what prevents one channel's traffic from
26
+ * steering another channel's policy.
27
+ */
28
+ export class LossyDataChannel extends FlowControlledDataChannel {
29
+ private bufferFullBehavior: 'drop' | 'wait';
30
+
31
+ private shouldSkipSends: () => boolean;
32
+
33
+ private statCurrentBytes = 0;
34
+
35
+ private statByterate = 0;
36
+
37
+ private statInterval: ReturnType<typeof setInterval> | undefined;
38
+
39
+ private dropCount = 0;
40
+
41
+ constructor(opts: LossyDataChannelOptions) {
42
+ super(opts);
43
+ this.bufferFullBehavior = opts.bufferFullBehavior;
44
+ this.shouldSkipSends = opts.shouldSkipSends;
45
+ }
46
+
47
+ /** Sends prepared bytes with this channel's full-buffer policy (drop or wait). */
48
+ async send(msg: NonSharedUint8Array) {
49
+ const dc = this.getChannel();
50
+ if (!dc) {
51
+ return;
52
+ }
53
+ // Depending on the channel's policy, either drop or wait for the buffer to drain below the
54
+ // high-water mark before continuing.
55
+ switch (this.bufferFullBehavior) {
56
+ case 'wait':
57
+ if (!this.isBelowHighWaterMark(dc)) {
58
+ await this.waitForHeadroomWithLock();
59
+ }
60
+ break;
61
+ case 'drop':
62
+ // We check against the actual threshold on the DC here, as it is tuned dynamically.
63
+ if (!this.isBelowLowWaterMark(dc)) {
64
+ // Drop messages to reduce latency
65
+ this.dropCount += 1;
66
+ if (this.dropCount % 100 === 0) {
67
+ log.warn(`dropping lossy data channel messages, total dropped: ${this.dropCount}`);
68
+ }
69
+ return;
70
+ }
71
+ }
72
+ this.statCurrentBytes += msg.byteLength;
73
+
74
+ if (this.shouldSkipSends()) {
75
+ return;
76
+ }
77
+
78
+ try {
79
+ dc.send(msg);
80
+ this.refreshBufferStatus();
81
+ } catch (error: unknown) {
82
+ // Preserve prior surface behaviour: a send that fails because the channel is closing is
83
+ // logged, not thrown, so lossy/data-track sends don't reject during teardown windows.
84
+ if (error instanceof TypeError) {
85
+ log.error(error);
86
+ } else {
87
+ throw error;
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Starts the once-per-second adjustment of the channel's `bufferedAmountLowThreshold` to the
94
+ * observed byterate, keeping the drop gate at roughly 100ms of buffered latency (clamped to
95
+ * the watermarks). Restarts cleanly if already running.
96
+ */
97
+ startThresholdTuning() {
98
+ this.stopThresholdTuning();
99
+ this.statInterval = CriticalTimers.setInterval(() => {
100
+ this.statByterate = this.statCurrentBytes;
101
+ this.statCurrentBytes = 0;
102
+
103
+ const dc = this.getChannel();
104
+ if (dc) {
105
+ // control buffered latency to ~100ms
106
+ const threshold = this.statByterate / 10;
107
+ dc.bufferedAmountLowThreshold = Math.min(
108
+ Math.max(threshold, this.lowWaterMark),
109
+ this.highWaterMark,
110
+ );
111
+ }
112
+ }, 1000);
113
+ }
114
+
115
+ /** Stops the threshold tuning and resets the stats and drop counter. */
116
+ stopThresholdTuning() {
117
+ this.statByterate = 0;
118
+ this.statCurrentBytes = 0;
119
+ if (this.statInterval) {
120
+ CriticalTimers.clearInterval(this.statInterval);
121
+ this.statInterval = undefined;
122
+ }
123
+ this.dropCount = 0;
124
+ }
125
+ }
@@ -0,0 +1,158 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { UnexpectedConnectionState } from '../errors';
3
+ import { ReliableDataChannel } from './ReliableDataChannel';
4
+ import { DataChannelKind } from './types';
5
+
6
+ class FakeDataChannel extends EventTarget {
7
+ bufferedAmount = 0;
8
+
9
+ bufferedAmountLowThreshold = 64;
10
+
11
+ send = vi.fn();
12
+ }
13
+
14
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
15
+
16
+ // Pass `dc: null` for a handle-less channel; omit it to get a fresh one. `null` (not `undefined`)
17
+ // is deliberate — a destructuring default fills in on `undefined`, so only `null` passes through.
18
+ function makeChannel({ dc = new FakeDataChannel() }: { dc?: FakeDataChannel | null } = {}) {
19
+ const state = { engineClosed: false, deferring: false };
20
+ const channel = new ReliableDataChannel({
21
+ kind: DataChannelKind.RELIABLE,
22
+ lowWaterMark: 64,
23
+ highWaterMark: 1024,
24
+ isEngineClosed: () => state.engineClosed,
25
+ isDeferringSends: () => state.deferring,
26
+ });
27
+ if (dc) {
28
+ channel.attach(dc as unknown as RTCDataChannel);
29
+ }
30
+ const buffer = (channel as unknown as { messageBuffer: { getAll(): unknown[]; length: number } })
31
+ .messageBuffer as unknown as {
32
+ getAll(): Array<{ data: Uint8Array; sequence: number; sent: boolean }>;
33
+ length: number;
34
+ };
35
+ return { channel, dc, state, buffer };
36
+ }
37
+
38
+ describe('ReliableDataChannel', () => {
39
+ it('hands out monotonic sequences and resets them with the session', () => {
40
+ const { channel } = makeChannel();
41
+ expect(channel.nextSequence()).toBe(1);
42
+ expect(channel.nextSequence()).toBe(2);
43
+ channel.reset();
44
+ expect(channel.nextSequence()).toBe(1);
45
+ });
46
+
47
+ it('sends below the high-water mark and retains the packet for replay as sent', async () => {
48
+ const { channel, dc, buffer } = makeChannel();
49
+ const msg = new Uint8Array([1]);
50
+
51
+ await channel.send(msg, channel.nextSequence());
52
+
53
+ expect(dc.send).toHaveBeenCalledWith(msg);
54
+ expect(buffer.getAll()).toEqual([{ data: msg, sequence: 1, sent: true }]);
55
+ });
56
+
57
+ it('queues unsent while sends are deferred (reconnect window)', async () => {
58
+ const { channel, dc, state, buffer } = makeChannel();
59
+ state.deferring = true;
60
+
61
+ await channel.send(new Uint8Array([1]), channel.nextSequence());
62
+
63
+ expect(dc.send).not.toHaveBeenCalled();
64
+ expect(buffer.getAll()[0].sent).toBe(false);
65
+ });
66
+
67
+ it('queues unsent and resolves when the headroom wait is torn down transiently', async () => {
68
+ const { channel, dc, buffer } = makeChannel();
69
+ dc.bufferedAmount = 2048; // above high mark → send parks
70
+ const send = channel.send(new Uint8Array([1]), channel.nextSequence());
71
+ await tick();
72
+
73
+ channel.invalidateWaiters('channel replaced');
74
+
75
+ await expect(send).resolves.toBeUndefined();
76
+ expect(dc.send).not.toHaveBeenCalled();
77
+ expect(buffer.getAll()[0].sent).toBe(false);
78
+ });
79
+
80
+ it('rejects when the engine is closed while waiting', async () => {
81
+ const { channel, dc, state } = makeChannel();
82
+ dc.bufferedAmount = 2048;
83
+ const send = channel.send(new Uint8Array([1]), channel.nextSequence());
84
+ send.catch(() => {});
85
+ await tick();
86
+
87
+ state.engineClosed = true;
88
+ channel.invalidateWaiters('engine closed');
89
+
90
+ await expect(send).rejects.toBeInstanceOf(UnexpectedConnectionState);
91
+ });
92
+
93
+ it('replay drops acked packets, resends the rest in order, and marks them sent', async () => {
94
+ const { channel, dc, buffer } = makeChannel();
95
+ const acked = new Uint8Array([1]);
96
+ const unacked = new Uint8Array([2]);
97
+ await channel.send(acked, channel.nextSequence());
98
+ await channel.send(unacked, channel.nextSequence());
99
+ // A packet queued unsent during the reconnect window joins the replay.
100
+ const queued = new Uint8Array([3]);
101
+ (channel as unknown as { isDeferringSends: () => boolean }).isDeferringSends = () => true;
102
+ await channel.send(queued, 3);
103
+ dc.send.mockClear();
104
+
105
+ await channel.replay(1); // server acked sequence 1
106
+
107
+ expect(dc.send.mock.calls.map(([data]) => data)).toEqual([unacked, queued]);
108
+ expect(buffer.getAll().every((item) => item.sent)).toBe(true);
109
+ });
110
+
111
+ it('transmits a packet deferred mid-replay instead of marking it sent without sending', async () => {
112
+ const { channel, dc, state, buffer } = makeChannel();
113
+ // A packet sent before the disconnect, still buffered (unacked) at resume.
114
+ await channel.send(new Uint8Array([1]), channel.nextSequence());
115
+ dc.send.mockClear();
116
+
117
+ // Replay parks on a full buffer, giving an await window mid-drain.
118
+ dc.bufferedAmount = 2048;
119
+ const replay = channel.replay(0);
120
+ await tick();
121
+
122
+ // A send arrives while replay is parked; the reconnect is still active, so it defers into the
123
+ // buffer as sent:false — after replay's first drain pass already started.
124
+ state.deferring = true;
125
+ await channel.send(new Uint8Array([2]), channel.nextSequence());
126
+ state.deferring = false;
127
+
128
+ dc.bufferedAmount = 0;
129
+ dc.dispatchEvent(new Event('bufferedamountlow'));
130
+ await replay;
131
+
132
+ // The drain loop must transmit both; a blanket mark-all-sent would flip the late arrival to
133
+ // sent without sending it, and a later align would then strand it.
134
+ expect(dc.send.mock.calls.map(([d]) => d[0])).toEqual([1, 2]);
135
+ expect(buffer.getAll().filter((i) => !i.sent)).toHaveLength(0);
136
+ });
137
+
138
+ it('holds the headroom lock across the whole replay so new sends cannot interleave', async () => {
139
+ const { channel, dc } = makeChannel();
140
+ (channel as unknown as { isDeferringSends: () => boolean }).isDeferringSends = () => true;
141
+ await channel.send(new Uint8Array([1]), 1);
142
+ await channel.send(new Uint8Array([2]), 2);
143
+ (channel as unknown as { isDeferringSends: () => boolean }).isDeferringSends = () => false;
144
+
145
+ // Replay parks on a full buffer while a concurrent send races it.
146
+ dc.bufferedAmount = 2048;
147
+ const replay = channel.replay(0);
148
+ await tick();
149
+ const concurrent = channel.send(new Uint8Array([9]), channel.nextSequence());
150
+ await tick();
151
+
152
+ dc.bufferedAmount = 0;
153
+ dc.dispatchEvent(new Event('bufferedamountlow'));
154
+ await Promise.all([replay, concurrent]);
155
+
156
+ expect(dc.send.mock.calls.map(([data]) => data[0])).toEqual([1, 2, 9]);
157
+ });
158
+ });
@@ -0,0 +1,153 @@
1
+ import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays';
2
+ import { DataPacketBuffer } from '../../utils/dataPacketBuffer';
3
+ import {
4
+ FlowControlledDataChannel,
5
+ type FlowControlledDataChannelOptions,
6
+ } from './FlowControlledDataChannel';
7
+
8
+ export interface ReliableDataChannelOptions extends FlowControlledDataChannelOptions {
9
+ /**
10
+ * Whether sends should currently be deferred to the resume replay instead of hitting the wire
11
+ * (i.e. a reconnect attempt is underway). Read at send time so the reliable channel matches the
12
+ * engine's reconnect state without owning it.
13
+ */
14
+ isDeferringSends: () => boolean;
15
+ }
16
+
17
+ /**
18
+ * The reliable channel: flow control plus delivery-across-resume semantics.
19
+ *
20
+ * Every packet gets a monotonic sequence (stamped into the protobuf by the caller before
21
+ * serialization, via {@link nextSequence}) and is retained in a replay buffer until the channel's
22
+ * `bufferedAmount` confirms it has been handed to the transport. Sends that land in a reconnect
23
+ * window — or whose headroom wait is torn down transiently — are queued unsent and resolve;
24
+ * {@link replay} delivers them (plus any unacked packets) after a resume. Only an engine close
25
+ * rejects, because no replay is coming after that.
26
+ */
27
+ export class ReliableDataChannel extends FlowControlledDataChannel {
28
+ private messageBuffer = new DataPacketBuffer();
29
+
30
+ private sequence = 1;
31
+
32
+ private isDeferringSends: () => boolean;
33
+
34
+ constructor(opts: ReliableDataChannelOptions) {
35
+ super(opts);
36
+ this.isDeferringSends = opts.isDeferringSends;
37
+ }
38
+
39
+ /**
40
+ * Claims the next packet sequence. The caller stamps it into the packet before serialization,
41
+ * then passes it back to {@link send} so the replay buffer stays keyed by wire sequence.
42
+ */
43
+ nextSequence(): number {
44
+ const sequence = this.sequence;
45
+ this.sequence += 1;
46
+ return sequence;
47
+ }
48
+
49
+ /**
50
+ * Sends prepared bytes with reliable semantics. Resolves once the packet has either been handed
51
+ * to the channel or queued for the resume replay; throws only when the engine is closed.
52
+ */
53
+ async send(msg: NonSharedUint8Array, sequence: number) {
54
+ if (this.isDeferringSends()) {
55
+ // A reconnect is already underway — queue for the resume replay instead of parking on a
56
+ // channel that is being torn down. The send resolves; delivery is deferred to the replay.
57
+ this.messageBuffer.push({ data: msg, sequence, sent: false });
58
+ return;
59
+ }
60
+
61
+ const dc = this.getChannel();
62
+ if (!dc) {
63
+ return;
64
+ }
65
+
66
+ try {
67
+ await this.waitForHeadroomWithLock();
68
+ } catch (error) {
69
+ if (this.isEngineClosed()) {
70
+ // No replay is coming after an engine close — surface the failure.
71
+ throw error;
72
+ }
73
+ // Transient teardown (the channel closed or was replaced while we waited): the reliable
74
+ // channel promises delivery across resume, so queue the packet for the replay instead of
75
+ // rejecting a send the app can't meaningfully retry.
76
+ this.messageBuffer.push({ data: msg, sequence, sent: false });
77
+ return;
78
+ }
79
+
80
+ if (this.isDeferringSends()) {
81
+ // A reconnect began while we waited for headroom — same deal as above.
82
+ this.messageBuffer.push({ data: msg, sequence, sent: false });
83
+ return;
84
+ }
85
+
86
+ this.messageBuffer.push({ data: msg, sequence, sent: true });
87
+ dc.send(msg);
88
+ this.refreshBufferStatus();
89
+ }
90
+
91
+ /**
92
+ * Replays the buffered backlog after a resume: drops everything the server acked
93
+ * (`lastMessageSeq`), then re-sends the rest in order. The headroom lock is held across the
94
+ * whole replay — releasing it between messages would let a concurrent send (whose newer
95
+ * sequence was already assigned before it queued on the lock) hit the wire mid-replay, and
96
+ * receivers would then discard the remaining lower-sequence resent messages as duplicates.
97
+ */
98
+ async replay(lastMessageSeq: number) {
99
+ const dc = this.getChannel();
100
+ if (!dc) {
101
+ return;
102
+ }
103
+ this.messageBuffer.popToSequence(lastMessageSeq);
104
+ const unlock = await this.lockHeadroom();
105
+ try {
106
+ // Everything left after the ack cutoff must be re-handed to the current channel.
107
+ this.messageBuffer.markAllUnsent();
108
+ // Drain in passes, re-scanning the live buffer each time: a send that arrives (deferred,
109
+ // sent:false) during our own awaits appends after this pass started, so we pick it up on
110
+ // the next one. Mark each packet only once we've actually handed it to the channel — a
111
+ // blanket "mark all sent" would flip such a late arrival to sent without transmitting it,
112
+ // and a later alignBufferedAmount would then drop it for good. If the loop throws
113
+ // mid-drain, unsent entries keep their flag and the next replay picks them up.
114
+ for (
115
+ let batch = this.messageBuffer.getUnsent();
116
+ batch.length > 0;
117
+ batch = this.messageBuffer.getUnsent()
118
+ ) {
119
+ for (const item of batch) {
120
+ // Respect flow control on resume too, so a large resend doesn't overflow the buffer.
121
+ await this.waitForHeadroomWithoutLock();
122
+ dc.send(item.data);
123
+ this.messageBuffer.markSent(item);
124
+ }
125
+ }
126
+ } finally {
127
+ unlock();
128
+ }
129
+ this.refreshBufferStatus();
130
+ }
131
+
132
+ /**
133
+ * Before recomputing status, trim packets the transport has now delivered — a send or a drain
134
+ * may have acked buffered packets, and the replay buffer is keyed off the channel's buffered
135
+ * bytes.
136
+ */
137
+ override refreshBufferStatus() {
138
+ const dc = this.channelHandle;
139
+ if (dc) {
140
+ this.messageBuffer.alignBufferedAmount(dc.bufferedAmount);
141
+ }
142
+ super.refreshBufferStatus();
143
+ }
144
+
145
+ /**
146
+ * Drops all replay state and restarts sequencing. Only valid on a full reconnect, where the
147
+ * session (and the receivers' sequence tracking) starts over.
148
+ */
149
+ reset() {
150
+ this.messageBuffer = new DataPacketBuffer();
151
+ this.sequence = 1;
152
+ }
153
+ }
@@ -0,0 +1,30 @@
1
+ import { DataPacket_Kind } from '@livekit/protocol';
2
+
3
+ export enum DataChannelKind {
4
+ RELIABLE = DataPacket_Kind.RELIABLE,
5
+ LOSSY = DataPacket_Kind.LOSSY,
6
+ DATA_TRACK_LOSSY = 2,
7
+ }
8
+
9
+ // Two-watermark flow control for the reliable and data-track channels. Senders fill the buffer
10
+ // freely up to the high-water mark; once it's exceeded they block until the browser's
11
+ // `bufferedamountlow` event (which we arm at the low-water mark) signals the buffer has drained.
12
+ // The gap between the marks keeps the SCTP send buffer saturated while we refill, so throughput
13
+ // isn't starved, while the high-water mark bounds the buffer well below the level that would abort
14
+ // the channel (see livekit/client-sdk-js#1995).
15
+ export const reliableDataChannelWaterMarkLow = 64 * 1024;
16
+ export const reliableDataChannelWaterMarkHigh = 1024 * 1024;
17
+ export const lossyDataChannelWaterMarkLow = 8 * 1024;
18
+ export const lossyDataChannelWaterMarkHigh = 256 * 1024;
19
+
20
+ export function dataChannelLowWaterMark(kind: DataChannelKind): number {
21
+ return kind === DataChannelKind.RELIABLE
22
+ ? reliableDataChannelWaterMarkLow
23
+ : lossyDataChannelWaterMarkLow;
24
+ }
25
+
26
+ export function dataChannelHighWaterMark(kind: DataChannelKind): number {
27
+ return kind === DataChannelKind.RELIABLE
28
+ ? reliableDataChannelWaterMarkHigh
29
+ : lossyDataChannelWaterMarkHigh;
30
+ }