livekit-client 2.20.1 → 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 (156) 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 +37 -7
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +2279 -651
  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 +37 -7
  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/api/SignalClient.d.ts.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/logger.d.ts +3 -1
  17. package/dist/src/logger.d.ts.map +1 -1
  18. package/dist/src/room/PCTransport.d.ts +2 -0
  19. package/dist/src/room/PCTransport.d.ts.map +1 -1
  20. package/dist/src/room/PCTransportManager.d.ts +1 -0
  21. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  22. package/dist/src/room/RTCEngine.d.ts +31 -26
  23. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  24. package/dist/src/room/RegionUrlProvider.d.ts.map +1 -1
  25. package/dist/src/room/Room.d.ts +4 -0
  26. package/dist/src/room/Room.d.ts.map +1 -1
  27. package/dist/src/room/data-channel/DataChannelManager.d.ts +62 -0
  28. package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -0
  29. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
  30. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -0
  31. package/dist/src/room/data-channel/LossyDataChannel.d.ts +40 -0
  32. package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -0
  33. package/dist/src/room/data-channel/ReliableDataChannel.d.ts +56 -0
  34. package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -0
  35. package/dist/src/room/data-channel/types.d.ts +12 -0
  36. package/dist/src/room/data-channel/types.d.ts.map +1 -0
  37. package/dist/src/room/data-stream/compression.d.ts +49 -0
  38. package/dist/src/room/data-stream/compression.d.ts.map +1 -0
  39. package/dist/src/room/data-stream/constants.d.ts +18 -0
  40. package/dist/src/room/data-stream/constants.d.ts.map +1 -0
  41. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
  42. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
  43. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  44. package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
  45. package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts.map +1 -1
  46. package/dist/src/room/data-stream/outgoing/header-utils.d.ts +18 -0
  47. package/dist/src/room/data-stream/outgoing/header-utils.d.ts.map +1 -0
  48. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
  49. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts.map +1 -1
  50. package/dist/src/room/data-track/outgoing/OutgoingDataTrackManager.d.ts.map +1 -1
  51. package/dist/src/room/errors.d.ts +3 -1
  52. package/dist/src/room/errors.d.ts.map +1 -1
  53. package/dist/src/room/participant/LocalParticipant.d.ts +9 -1
  54. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  55. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  56. package/dist/src/room/participant/RemoteParticipant.d.ts +11 -2
  57. package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
  58. package/dist/src/room/participant/publishUtils.d.ts +7 -0
  59. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  60. package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
  61. package/dist/src/room/rpc/server/RpcServerManager.d.ts.map +1 -1
  62. package/dist/src/room/track/LocalAudioTrack.d.ts +1 -0
  63. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  64. package/dist/src/room/track/Track.d.ts.map +1 -1
  65. package/dist/src/room/track/options.d.ts +11 -1
  66. package/dist/src/room/track/options.d.ts.map +1 -1
  67. package/dist/src/room/types.d.ts +17 -0
  68. package/dist/src/room/types.d.ts.map +1 -1
  69. package/dist/src/room/utils.d.ts +13 -0
  70. package/dist/src/room/utils.d.ts.map +1 -1
  71. package/dist/src/test/signalToken.d.ts +26 -0
  72. package/dist/src/test/signalToken.d.ts.map +1 -0
  73. package/dist/src/utils/dataPacketBuffer.d.ts +18 -0
  74. package/dist/src/utils/dataPacketBuffer.d.ts.map +1 -1
  75. package/dist/src/version.d.ts +5 -1
  76. package/dist/src/version.d.ts.map +1 -1
  77. package/dist/ts4.2/index.d.ts +1 -1
  78. package/dist/ts4.2/logger.d.ts +3 -1
  79. package/dist/ts4.2/room/PCTransport.d.ts +2 -0
  80. package/dist/ts4.2/room/PCTransportManager.d.ts +1 -0
  81. package/dist/ts4.2/room/RTCEngine.d.ts +31 -26
  82. package/dist/ts4.2/room/Room.d.ts +4 -0
  83. package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +62 -0
  84. package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
  85. package/dist/ts4.2/room/data-channel/LossyDataChannel.d.ts +41 -0
  86. package/dist/ts4.2/room/data-channel/ReliableDataChannel.d.ts +57 -0
  87. package/dist/ts4.2/room/data-channel/types.d.ts +12 -0
  88. package/dist/ts4.2/room/data-stream/compression.d.ts +49 -0
  89. package/dist/ts4.2/room/data-stream/constants.d.ts +18 -0
  90. package/dist/ts4.2/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
  91. package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
  92. package/dist/ts4.2/room/data-stream/outgoing/header-utils.d.ts +18 -0
  93. package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
  94. package/dist/ts4.2/room/errors.d.ts +3 -1
  95. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +9 -1
  96. package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +11 -1
  97. package/dist/ts4.2/room/participant/publishUtils.d.ts +7 -0
  98. package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +1 -0
  99. package/dist/ts4.2/room/track/options.d.ts +11 -1
  100. package/dist/ts4.2/room/types.d.ts +17 -0
  101. package/dist/ts4.2/room/utils.d.ts +13 -0
  102. package/dist/ts4.2/test/signalToken.d.ts +26 -0
  103. package/dist/ts4.2/utils/dataPacketBuffer.d.ts +18 -0
  104. package/dist/ts4.2/version.d.ts +5 -1
  105. package/package.json +8 -4
  106. package/src/api/SignalClient.e2e.test.ts +264 -0
  107. package/src/api/SignalClient.ts +34 -2
  108. package/src/index.ts +2 -0
  109. package/src/logger.ts +2 -0
  110. package/src/room/PCTransport.ts +66 -31
  111. package/src/room/PCTransportManager.ts +10 -6
  112. package/src/room/RTCEngine.test.ts +238 -6
  113. package/src/room/RTCEngine.ts +101 -338
  114. package/src/room/RegionUrlProvider.ts +4 -2
  115. package/src/room/Room.test.ts +4 -1
  116. package/src/room/Room.ts +47 -9
  117. package/src/room/data-channel/DataChannelManager.test.ts +152 -0
  118. package/src/room/data-channel/DataChannelManager.ts +237 -0
  119. package/src/room/data-channel/FlowControlledDataChannel.test.ts +154 -0
  120. package/src/room/data-channel/FlowControlledDataChannel.ts +216 -0
  121. package/src/room/data-channel/LossyDataChannel.test.ts +118 -0
  122. package/src/room/data-channel/LossyDataChannel.ts +125 -0
  123. package/src/room/data-channel/ReliableDataChannel.test.ts +158 -0
  124. package/src/room/data-channel/ReliableDataChannel.ts +153 -0
  125. package/src/room/data-channel/types.ts +30 -0
  126. package/src/room/data-stream/compression.ts +117 -0
  127. package/src/room/data-stream/constants.ts +18 -0
  128. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +1601 -0
  129. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +438 -86
  130. package/src/room/data-stream/incoming/StreamReader.ts +8 -2
  131. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts +1292 -0
  132. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +489 -116
  133. package/src/room/data-stream/outgoing/header-utils.ts +87 -0
  134. package/src/room/data-track/incoming/IncomingDataTrackManager.test.ts +303 -0
  135. package/src/room/data-track/incoming/IncomingDataTrackManager.ts +70 -8
  136. package/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +2 -0
  137. package/src/room/errors.ts +10 -4
  138. package/src/room/participant/LocalParticipant.ts +29 -6
  139. package/src/room/participant/Participant.ts +5 -0
  140. package/src/room/participant/RemoteParticipant.ts +19 -5
  141. package/src/room/participant/publishUtils.ts +14 -11
  142. package/src/room/rpc/client/RpcClientManager.test.ts +20 -27
  143. package/src/room/rpc/client/RpcClientManager.ts +1 -4
  144. package/src/room/rpc/server/RpcServerManager.test.ts +24 -23
  145. package/src/room/rpc/server/RpcServerManager.ts +1 -3
  146. package/src/room/track/LocalAudioTrack.ts +16 -0
  147. package/src/room/track/Track.ts +10 -6
  148. package/src/room/track/options.ts +11 -1
  149. package/src/room/types.ts +18 -0
  150. package/src/room/utils.ts +67 -0
  151. package/src/test/signalServerSetup.ts +151 -0
  152. package/src/test/signalToken.ts +59 -0
  153. package/src/test/vitest-context.d.ts +10 -0
  154. package/src/utils/dataPacketBuffer.test.ts +100 -0
  155. package/src/utils/dataPacketBuffer.ts +46 -1
  156. package/src/version.ts +5 -1
@@ -0,0 +1,264 @@
1
+ import { type LeaveRequest, LeaveRequest_Action } from '@livekit/protocol';
2
+ import { afterEach, beforeEach, describe, expect, inject, it, vi } from 'vitest';
3
+ import { ConnectionErrorReason } from '../room/errors';
4
+ import { createInvalidToken, createToken } from '../test/signalToken';
5
+ import { SignalClient, SignalConnectionState, type SignalOptions } from './SignalClient';
6
+
7
+ // Provided by src/test/signalServerSetup.ts (spawns the livekit-server test-server).
8
+ const serverUrl = inject('serverUrl');
9
+ const unavailable = inject('e2eUnavailable');
10
+
11
+ const defaultOpts = (): SignalOptions => ({
12
+ autoSubscribe: true,
13
+ maxRetries: 0,
14
+ e2eeEnabled: false,
15
+ websocketTimeout: 5_000,
16
+ });
17
+
18
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
19
+
20
+ function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
21
+ return Promise.race([
22
+ p,
23
+ new Promise<T>((_, reject) =>
24
+ setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), ms),
25
+ ),
26
+ ]);
27
+ }
28
+
29
+ /** Resolves with the reason passed to onClose (register before the event can fire). */
30
+ function captureClose(client: SignalClient): Promise<string> {
31
+ return new Promise((resolve) => {
32
+ client.onClose = (reason) => resolve(reason);
33
+ });
34
+ }
35
+
36
+ /** Resolves with the LeaveRequest passed to onLeave. */
37
+ function captureLeave(client: SignalClient): Promise<LeaveRequest> {
38
+ return new Promise((resolve) => {
39
+ client.onLeave = (leave) => resolve(leave);
40
+ });
41
+ }
42
+
43
+ describe.skipIf(!!unavailable)('SignalClient e2e', () => {
44
+ // Behaviors that depend on the signal path (v0 legacy vs v1).
45
+ describe.each([
46
+ { label: 'v0', useV0: true },
47
+ { label: 'v1', useV0: false },
48
+ ])('$label path', ({ useV0 }) => {
49
+ let client: SignalClient;
50
+
51
+ beforeEach(() => {
52
+ client = new SignalClient(false);
53
+ });
54
+
55
+ afterEach(async () => {
56
+ await client.close().catch(() => {});
57
+ });
58
+
59
+ /** Mint a token whose metadata selects the server behavior mode, then join. */
60
+ const join = (mode: string, opts: SignalOptions = defaultOpts(), abortSignal?: AbortSignal) =>
61
+ createToken({ signal: mode }).then((token) =>
62
+ client.join(serverUrl, token, opts, abortSignal, useV0),
63
+ );
64
+
65
+ it('connects and reports CONNECTED with a join response', async () => {
66
+ const res = await join('happy');
67
+ expect(res).toBeTruthy();
68
+ expect(res.pingInterval).toBeGreaterThan(0);
69
+ expect(client.currentState).toBe(SignalConnectionState.CONNECTED);
70
+ });
71
+
72
+ it('stays connected past the ping timeout while the server pongs', async () => {
73
+ const onClose = vi.fn();
74
+ await join('happy');
75
+ client.onClose = onClose;
76
+ // Server ping timeout is 3s; a healthy pong loop must keep us alive.
77
+ await sleep(4_000);
78
+ expect(onClose).not.toHaveBeenCalled();
79
+ expect(client.currentState).toBe(SignalConnectionState.CONNECTED);
80
+ });
81
+
82
+ it('closes with a ping timeout when the server stops ponging', async () => {
83
+ const closed = captureClose(client);
84
+ await join('no_pong');
85
+ expect(client.currentState).toBe(SignalConnectionState.CONNECTED);
86
+ const reason = await withTimeout(closed, 8_000, 'onClose (ping timeout)');
87
+ expect(reason).toContain('ping timeout');
88
+ expect(client.currentState).toBe(SignalConnectionState.DISCONNECTED);
89
+ });
90
+
91
+ it('surfaces an unexpected transport close while connected', async () => {
92
+ const closed = captureClose(client);
93
+ await join('close_when_connected');
94
+ const reason = await withTimeout(closed, 5_000, 'onClose (transport closed)');
95
+ expect(reason).toBeTruthy();
96
+ expect(client.currentState).toBe(SignalConnectionState.DISCONNECTED);
97
+ });
98
+
99
+ it('surfaces an abnormal socket drop (1006) while connected', async () => {
100
+ // Server sends the join, then drops the TCP connection with no close
101
+ // handshake -> the browser reports an abnormal 1006 closure, exercising
102
+ // WebSocketStream's error->close path (distinct from the clean 1011 above).
103
+ const closed = captureClose(client);
104
+ await join('drop_when_connected');
105
+ expect(client.currentState).toBe(SignalConnectionState.CONNECTED);
106
+ const reason = await withTimeout(closed, 5_000, 'onClose (abnormal drop)');
107
+
108
+ expect(reason).toBe('Unexpected WS error');
109
+ expect(client.currentState).toBe(SignalConnectionState.DISCONNECTED);
110
+ });
111
+
112
+ it('rejects the join when the socket closes before the first message', async () => {
113
+ // Upgrade succeeds, then the server closes the WS before sending a join.
114
+ // The client is still establishing, so it rejects rather than hanging.
115
+ const err = await join('close_before_join').then(
116
+ () => undefined,
117
+ (e) => e as Error,
118
+ );
119
+ expect(err).toBeInstanceOf(Error);
120
+ expect((err as { reason?: ConnectionErrorReason }).reason).toBe(
121
+ ConnectionErrorReason.InternalError,
122
+ );
123
+ });
124
+
125
+ it('delivers a server-initiated leave while connected', async () => {
126
+ const left = captureLeave(client);
127
+ await join('leave_when_connected');
128
+ const leave = await withTimeout(left, 5_000, 'onLeave');
129
+ expect(leave).toBeTruthy();
130
+ expect(leave.action).toBe(LeaveRequest_Action.DISCONNECT);
131
+ });
132
+
133
+ it('forwards a non-DISCONNECT leave action verbatim', async () => {
134
+ // SignalClient does not branch on the leave action (RTCEngine does); it
135
+ // must pass it through unchanged. Guards the FSM swap's leave_received
136
+ // effect, which carries leaveAction. Uses RECONNECT to prove it's not
137
+ // defaulted to DISCONNECT.
138
+ const left = captureLeave(client);
139
+ const token = await createToken({
140
+ signal: 'leave_when_connected',
141
+ leaveAction: LeaveRequest_Action.RECONNECT,
142
+ });
143
+ await client.join(serverUrl, token, defaultOpts(), undefined, useV0);
144
+ const leave = await withTimeout(left, 5_000, 'onLeave');
145
+ expect(leave.action).toBe(LeaveRequest_Action.RECONNECT);
146
+ });
147
+
148
+ it('rejects the join when a leave arrives as the first message', async () => {
149
+ const err = await join('leave_first_message').then(
150
+ () => undefined,
151
+ (e) => e as Error,
152
+ );
153
+ expect(err).toBeInstanceOf(Error);
154
+ });
155
+
156
+ it('closes gracefully via close() without firing onClose', async () => {
157
+ const onClose = vi.fn();
158
+ await join('happy');
159
+ client.onClose = onClose;
160
+ await client.close();
161
+ expect(client.isDisconnected).toBe(true);
162
+ expect(onClose).not.toHaveBeenCalled();
163
+ });
164
+
165
+ it('reconnects (resume) back to CONNECTED', async () => {
166
+ await join('happy');
167
+ const token = await createToken({ signal: 'happy' });
168
+ await client.reconnect(serverUrl, token, 'RM_session');
169
+ expect(client.currentState).toBe(SignalConnectionState.CONNECTED);
170
+ });
171
+
172
+ it('rejects a reconnect when a leave arrives as the first message', async () => {
173
+ // Reconnecting into a room that sends leave-first exercises the client's
174
+ // first-message validation while in RECONNECTING (path-independent: it
175
+ // doesn't rely on the mock detecting reconnect, which v1 hides inside the
176
+ // gzipped join_request the mock ignores).
177
+ await join('happy');
178
+ const token = await createToken({ signal: 'leave_first_message' });
179
+ const err = await client.reconnect(serverUrl, token, 'RM_session').then(
180
+ () => undefined,
181
+ (e) => e as Error,
182
+ );
183
+ expect(err).toBeInstanceOf(Error);
184
+ });
185
+
186
+ // --- validate-endpoint classification -------------------------------
187
+ it('classifies an invalid token as NotAllowed', async () => {
188
+ const token = await createInvalidToken();
189
+ const err = await client
190
+ .join(serverUrl, token, defaultOpts(), undefined, useV0)
191
+ .catch((e) => e);
192
+ expect(err.reason).toBe(ConnectionErrorReason.NotAllowed);
193
+ });
194
+
195
+ it('classifies room-not-found as NotAllowed', async () => {
196
+ const err = await join('room_not_found').catch((e) => e);
197
+ expect(err.reason).toBe(ConnectionErrorReason.NotAllowed);
198
+ });
199
+
200
+ it('classifies a wrong-path 404 as ServiceNotFound', async () => {
201
+ const err = await join('validate_service_not_found').catch((e) => e);
202
+ expect(err.reason).toBe(ConnectionErrorReason.ServiceNotFound);
203
+ });
204
+
205
+ it('surfaces a 5xx validate as a WebSocket error (ws error shadows 5xx→internal)', async () => {
206
+ // Current behavior: handleConnectionError only maps a 5xx to InternalError
207
+ // when the ws rejection is NOT a ConnectionError. WebSocketStream always
208
+ // rejects with one, so for a refused upgrade the WS error wins. Only
209
+ // 401/403/404 (handled before that check) override it.
210
+ const err = await join('validate_500').catch((e) => e);
211
+ expect(err.reason).toBe(ConnectionErrorReason.WebSocket);
212
+ });
213
+ });
214
+
215
+ // Path-independent transport-level failures (run once, via v0).
216
+ describe('transport failures', () => {
217
+ let client: SignalClient;
218
+
219
+ beforeEach(() => {
220
+ client = new SignalClient(false);
221
+ });
222
+
223
+ afterEach(async () => {
224
+ await client.close().catch(() => {});
225
+ });
226
+
227
+ it('classifies an unreachable server as ServerUnreachable', async () => {
228
+ const token = await createToken({ signal: 'happy' });
229
+ // Nothing listening on this port -> ws fails, validate fetch fails.
230
+ const err = await client
231
+ .join('ws://127.0.0.1:59999', token, defaultOpts(), undefined, true)
232
+ .catch((e) => e);
233
+ expect(err.reason).toBe(ConnectionErrorReason.ServerUnreachable);
234
+ });
235
+
236
+ it('cancels the connect when the AbortSignal fires mid-connection', async () => {
237
+ const controller = new AbortController();
238
+ const token = await createToken({ signal: 'no_first_message' });
239
+ const p = client.join(serverUrl, token, defaultOpts(), controller.signal, true);
240
+ setTimeout(() => controller.abort('user requested abort'), 300);
241
+ const err = await p.catch((e) => e);
242
+ expect(err.reason).toBe(ConnectionErrorReason.Cancelled);
243
+ });
244
+
245
+ it('times out when the socket opens but no first message arrives', async () => {
246
+ // wsTimeout only guards the WebSocket upgrade and is cleared once ws.opened
247
+ // resolves. If the server accepts the socket then stays silent, the wait for
248
+ // the first message is guarded by its own JOIN_RESPONSE_TIMEOUT (5s), so
249
+ // join() rejects with Timeout instead of hanging.
250
+ const token = await createToken({ signal: 'no_first_message' });
251
+ const joinPromise = client.join(serverUrl, token, defaultOpts(), undefined, true);
252
+
253
+ const err = await withTimeout(
254
+ joinPromise.then(
255
+ () => undefined,
256
+ (e) => e,
257
+ ),
258
+ 8_000,
259
+ 'join to time out',
260
+ );
261
+ expect(err.reason).toBe(ConnectionErrorReason.Timeout);
262
+ });
263
+ });
264
+ });
@@ -121,6 +121,11 @@ export enum SignalConnectionState {
121
121
  /** specifies how much time (in ms) we allow for the ws to close its connection gracefully before continuing */
122
122
  const MAX_WS_CLOSE_TIME = 250;
123
123
 
124
+ /**
125
+ * How long (in ms) to wait for the first message after the WebSocket upgrade.
126
+ */
127
+ const JOIN_RESPONSE_TIMEOUT = 5_000;
128
+
124
129
  /** @internal */
125
130
  export class SignalClient {
126
131
  requestQueue: AsyncQueue;
@@ -408,7 +413,7 @@ export class SignalClient {
408
413
  state: this.state,
409
414
  });
410
415
  if (this.state === SignalConnectionState.CONNECTED) {
411
- this.handleOnClose(closeInfo.reason ?? 'Unexpected WS error');
416
+ this.handleOnClose(closeInfo.reason || 'Unexpected WS error');
412
417
  }
413
418
  }
414
419
  return;
@@ -441,7 +446,34 @@ export class SignalClient {
441
446
  }
442
447
  const signalReader = connection.readable.getReader();
443
448
  this.streamWriter = connection.writable.getWriter();
444
- const firstMessage = await signalReader.read();
449
+
450
+ // wsTimeout only guarded the upgrade; guard the first-message read with
451
+ // its own timeout so a silent server can't hang join() forever.
452
+ let firstMessage: ReadableStreamReadResult<string | ArrayBuffer>;
453
+ let firstMessageTimeout: ReturnType<typeof setTimeout> | undefined;
454
+ try {
455
+ firstMessage = await Promise.race([
456
+ signalReader.read(),
457
+ new Promise<never>((_, rejectRead) => {
458
+ firstMessageTimeout = setTimeout(() => {
459
+ rejectRead(
460
+ ConnectionError.timeout(
461
+ 'signal connection timed out while waiting for the first message',
462
+ ),
463
+ );
464
+ }, JOIN_RESPONSE_TIMEOUT);
465
+ }),
466
+ ]);
467
+ } catch (e) {
468
+ // No first message in time: release the reader and tear down the ws
469
+ // so we surface the timeout instead of leaking an open connection.
470
+ signalReader.releaseLock();
471
+ reject(e);
472
+ this.close();
473
+ return;
474
+ } finally {
475
+ clearTimeout(firstMessageTimeout);
476
+ }
445
477
  signalReader.releaseLock();
446
478
  if (!firstMessage.value) {
447
479
  throw ConnectionError.internal('no message received as first message');
package/src/index.ts CHANGED
@@ -104,6 +104,8 @@ export type {
104
104
  TranscriptionSegment,
105
105
  ChatMessage,
106
106
  SendTextOptions,
107
+ SendBytesOptions,
108
+ ByteStreamInfo,
107
109
  } from './room/types';
108
110
  export * from './version';
109
111
  export {
package/src/logger.ts CHANGED
@@ -22,6 +22,8 @@ export enum LoggerNames {
22
22
  PCTransport = 'livekit-pc-transport',
23
23
  E2EE = 'lk-e2ee',
24
24
  DataTracks = 'livekit-data-tracks',
25
+ Region = 'livekit-region',
26
+ ICE = 'livekit-ice',
25
27
  }
26
28
 
27
29
  type LogLevelString = keyof typeof LogLevel;
@@ -15,15 +15,24 @@ interface TrackBitrateInfo {
15
15
  transceiver?: RTCRtpTransceiver;
16
16
  codec: string;
17
17
  maxbr: number;
18
+ isScreenShare?: boolean;
18
19
  }
19
20
 
20
- /* The svc codec (av1/vp9) would use a very low bitrate at the begining and
21
- increase slowly by the bandwidth estimator until it reach the target bitrate. The
22
- process commonly cost more than 10 seconds cause subscriber will get blur video at
23
- the first few seconds. So we use a 70% of target bitrate here as the start bitrate to
24
- eliminate this issue.
25
- */
26
- const startBitrateForSVC = 0.7;
21
+ /*
22
+ * Video codecs use a very low bitrate at the beginning and increase slowly by
23
+ * the bandwidth estimator until they reach the target bitrate. The process commonly
24
+ * costs more than 10 seconds causing subscribers to get blurry video at the first
25
+ * few seconds. We use x-google-start-bitrate to hint the BWE to start higher.
26
+ *
27
+ * Why 90%: Gives ~10% headroom for bandwidth estimation while starting close to target.
28
+ * Why same for all codecs: Target bitrate already accounts for codec efficiency
29
+ * (e.g., users set lower targets for VP9/AV1 knowing they're more efficient).
30
+ * Why cap at 1 Mbps: Prevents BWE from starting too aggressively on high bitrate tracks.
31
+ */
32
+ const startBitrateMultiplier = 0.9;
33
+
34
+ /** Maximum x-google-start-bitrate in kbps. 1 Mbps prevents BWE from starting too aggressively. */
35
+ const maxStartBitrateKbps = 1000;
27
36
 
28
37
  const debounceInterval = 20;
29
38
 
@@ -53,6 +62,8 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
53
62
 
54
63
  private log = log;
55
64
 
65
+ private iceLog = log;
66
+
56
67
  private loggerOptions: LoggerOptions;
57
68
 
58
69
  private ddExtID = 0;
@@ -95,8 +106,12 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
95
106
 
96
107
  constructor(config?: RTCConfiguration, loggerOptions: LoggerOptions = {}) {
97
108
  super();
98
- this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCTransport);
99
109
  this.loggerOptions = loggerOptions;
110
+ this.log = getLogger(
111
+ loggerOptions.loggerName ?? LoggerNames.PCTransport,
112
+ () => this.logContext,
113
+ );
114
+ this.iceLog = getLogger(LoggerNames.ICE, () => this.logContext);
100
115
  this.config = config;
101
116
  this._pc = this.createPC();
102
117
  this.offerLock = new Mutex();
@@ -107,24 +122,33 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
107
122
 
108
123
  pc.onicecandidate = (ev) => {
109
124
  if (!ev.candidate) return;
125
+ this.iceLog.debug('local ICE candidate gathered', { candidate: ev.candidate.candidate });
110
126
  this.onIceCandidate?.(ev.candidate);
111
127
  };
112
128
  pc.onicecandidateerror = (ev) => {
129
+ this.iceLog.debug('ICE candidate error', { event: ev });
113
130
  this.onIceCandidateError?.(ev);
114
131
  };
115
132
 
116
133
  pc.oniceconnectionstatechange = () => {
134
+ this.iceLog.debug(`ICE connection state: ${pc.iceConnectionState}`);
117
135
  this.onIceConnectionStateChange?.(pc.iceConnectionState);
118
136
  };
119
137
 
120
138
  pc.onsignalingstatechange = () => {
139
+ this.log.debug(`signaling state: ${pc.signalingState}`);
121
140
  this.onSignalingStatechange?.(pc.signalingState);
122
141
  };
123
142
 
124
143
  pc.onconnectionstatechange = () => {
144
+ this.log.debug(`connection state: ${pc.connectionState}`);
125
145
  this.onConnectionStateChange?.(pc.connectionState);
126
146
  };
127
147
  pc.ondatachannel = (ev) => {
148
+ this.log.debug('data channel opened by peer', {
149
+ label: ev.channel.label,
150
+ id: ev.channel.id,
151
+ });
128
152
  this.onDataChannel?.(ev);
129
153
  };
130
154
  pc.ontrack = (ev) => {
@@ -150,6 +174,9 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
150
174
  if (this.pc.remoteDescription && !this.restartingIce) {
151
175
  return this.pc.addIceCandidate(candidate);
152
176
  }
177
+ this.iceLog.debug('queuing remote ICE candidate until remote description applied', {
178
+ pendingCount: this.pendingCandidates.length + 1,
179
+ });
153
180
  this.pendingCandidates.push(candidate);
154
181
  }
155
182
 
@@ -161,7 +188,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
161
188
  offerId !== this.latestOfferId
162
189
  ) {
163
190
  this.log.warn('ignoring answer for old offer', {
164
- ...this.logContext,
165
191
  offerId,
166
192
  latestOfferId: this.latestOfferId,
167
193
  });
@@ -180,7 +206,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
180
206
  sdpParsed.media.forEach((media) => {
181
207
  ensureIPAddrMatchVersion(media);
182
208
  });
183
- this.log.debug('setting pending initial offer before processing answer', this.logContext);
209
+ this.log.debug('setting pending initial offer before processing answer');
184
210
  await this.setMungedSDP(initialOffer, write(sdpParsed));
185
211
  }
186
212
  const sdpParsed = parse(sd.sdp ?? '');
@@ -249,6 +275,11 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
249
275
  }
250
276
  await this.setMungedSDP(sd, mungedSDP, true);
251
277
 
278
+ if (this.pendingCandidates.length > 0) {
279
+ this.iceLog.debug('flushing queued ICE candidates', {
280
+ count: this.pendingCandidates.length,
281
+ });
282
+ }
252
283
  this.pendingCandidates.forEach((candidate) => {
253
284
  this.pc.addIceCandidate(candidate);
254
285
  });
@@ -298,10 +329,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
298
329
  const unlock = await this.offerLock.lock();
299
330
  try {
300
331
  if (this.pc.signalingState !== 'stable') {
301
- this.log.warn(
302
- 'signaling state is not stable, cannot create initial offer',
303
- this.logContext,
304
- );
332
+ this.log.warn('signaling state is not stable, cannot create initial offer');
305
333
  return;
306
334
  }
307
335
  const offerId = this.latestOfferId + 1;
@@ -328,7 +356,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
328
356
  }
329
357
 
330
358
  if (options?.iceRestart) {
331
- this.log.debug('restarting ICE', this.logContext);
359
+ this.iceLog.debug('restarting ICE');
332
360
  this.restartingIce = true;
333
361
  }
334
362
 
@@ -345,21 +373,21 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
345
373
  await this._pc.setRemoteDescription(currentSD);
346
374
  } else {
347
375
  this.renegotiate = true;
348
- this.log.debug('requesting renegotiation', { ...this.logContext });
376
+ this.log.debug('requesting renegotiation');
349
377
  return;
350
378
  }
351
379
  } else if (!this._pc || this._pc.signalingState === 'closed') {
352
- this.log.warn('could not createOffer with closed peer connection', this.logContext);
380
+ this.log.warn('could not createOffer with closed peer connection');
353
381
  return;
354
382
  }
355
383
 
356
384
  // actually negotiate
357
- this.log.debug('starting to negotiate', this.logContext);
385
+ this.log.debug('starting to negotiate');
358
386
  // increase the offer id at the start to ensure the offer is always > 0 so that we can use 0 as a default value for legacy behavior
359
387
  const offerId = this.latestOfferId + 1;
360
388
  this.latestOfferId = offerId;
361
389
  const offer = await this.pc.createOffer(options);
362
- this.log.debug('original offer', { sdp: offer.sdp, ...this.logContext });
390
+ this.log.debug('original offer', { sdp: offer.sdp });
363
391
 
364
392
  const sdpParsed = parse(offer.sdp ?? '');
365
393
  sdpParsed.media.forEach((media) => {
@@ -390,14 +418,18 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
390
418
  }
391
419
 
392
420
  // mung sdp for bitrate setting that can't apply by sendEncoding
393
- if (!isSVCCodec(trackbr.codec)) {
394
- return true;
395
- }
396
-
397
- const startBitrate = Math.round(trackbr.maxbr * startBitrateForSVC);
421
+ // Use 90% of target bitrate, capped at 1 Mbps for camera to prevent BWE from starting too aggressively
422
+ // Screen share is not capped since text/UI clarity requires high bitrate from the start
423
+ // TODO: dynamically adjust start bitrate based on network conditions (e.g., use previous BWE estimate)
424
+ const calculatedStartBitrate = Math.round(trackbr.maxbr * startBitrateMultiplier);
425
+ const startBitrate = trackbr.isScreenShare
426
+ ? calculatedStartBitrate
427
+ : Math.min(calculatedStartBitrate, maxStartBitrateKbps);
398
428
 
429
+ let fmtpFound = false;
399
430
  for (const fmtp of media.fmtp) {
400
431
  if (fmtp.payload === codecPayload) {
432
+ fmtpFound = true;
401
433
  // if another track's fmtp already is set, we cannot override the bitrate
402
434
  // this has the unfortunate consequence of being forced to use the
403
435
  // initial track's bitrate for all tracks
@@ -407,6 +439,13 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
407
439
  break;
408
440
  }
409
441
  }
442
+ // VP8 and some codecs may not have an existing fmtp line - create one
443
+ if (!fmtpFound) {
444
+ media.fmtp.push({
445
+ payload: codecPayload,
446
+ config: `x-google-start-bitrate=${startBitrate}`,
447
+ });
448
+ }
410
449
  return true;
411
450
  });
412
451
  }
@@ -424,7 +463,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
424
463
  }
425
464
  if (this.latestOfferId > offerId) {
426
465
  this.log.warn('latestOfferId mismatch', {
427
- ...this.logContext,
428
466
  latestOfferId: this.latestOfferId,
429
467
  offerId,
430
468
  });
@@ -576,6 +614,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
576
614
  if (!this._pc) {
577
615
  return;
578
616
  }
617
+ this.log.debug('closing peer connection');
579
618
  this.pendingInitialOffer = undefined;
580
619
  this._pc.close();
581
620
  this._pc.onconnectionstatechange = null;
@@ -597,10 +636,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
597
636
  if (munged) {
598
637
  sd.sdp = munged;
599
638
  try {
600
- this.log.debug(
601
- `setting munged ${remote ? 'remote' : 'local'} description`,
602
- this.logContext,
603
- );
639
+ this.log.debug(`setting munged ${remote ? 'remote' : 'local'} description`);
604
640
  if (remote) {
605
641
  await this.pc.setRemoteDescription(sd);
606
642
  } else {
@@ -609,7 +645,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
609
645
  return;
610
646
  } catch (e) {
611
647
  this.log.warn(`not able to set ${sd.type}, falling back to unmodified sdp`, {
612
- ...this.logContext,
613
648
  error: e,
614
649
  mungedSdp: munged,
615
650
  originalSdp,
@@ -642,7 +677,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
642
677
  if (!remote && this.pc.remoteDescription) {
643
678
  fields.remoteSdp = this.pc.remoteDescription;
644
679
  }
645
- this.log.error(`unable to set ${sd.type}`, { ...this.logContext, fields });
680
+ this.log.error(`unable to set ${sd.type}`, { fields });
646
681
  throw new NegotiationError(msg);
647
682
  }
648
683
  }
@@ -65,6 +65,8 @@ export class PCTransportManager {
65
65
 
66
66
  private log = log;
67
67
 
68
+ private iceLog = log;
69
+
68
70
  private loggerOptions: LoggerOptions;
69
71
 
70
72
  private _mode: PCMode;
@@ -74,8 +76,9 @@ export class PCTransportManager {
74
76
  }
75
77
 
76
78
  constructor(mode: PCMode, loggerOptions: LoggerOptions, rtcConfig?: RTCConfiguration) {
77
- this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCManager);
78
79
  this.loggerOptions = loggerOptions;
80
+ this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCManager, () => this.logContext);
81
+ this.iceLog = getLogger(LoggerNames.ICE, () => this.logContext);
79
82
 
80
83
  this.isPublisherConnectionRequired = mode !== 'subscriber-primary';
81
84
  this.isSubscriberConnectionRequired = mode === 'subscriber-primary';
@@ -151,7 +154,7 @@ export class PCTransportManager {
151
154
  publisher.removeTrack(sender);
152
155
  }
153
156
  } catch (e) {
154
- this.log.warn('could not removeTrack', { ...this.logContext, error: e });
157
+ this.log.warn('could not removeTrack', { error: e });
155
158
  }
156
159
  }
157
160
  }
@@ -160,6 +163,7 @@ export class PCTransportManager {
160
163
  }
161
164
 
162
165
  async triggerIceRestart() {
166
+ this.iceLog.warn('triggering ICE restart');
163
167
  if (this.subscriber) {
164
168
  this.subscriber.restartingIce = true;
165
169
  }
@@ -170,6 +174,7 @@ export class PCTransportManager {
170
174
  }
171
175
 
172
176
  async addIceCandidate(candidate: RTCIceCandidateInit, target: SignalTarget) {
177
+ this.iceLog.debug('adding remote ICE candidate', { target, candidate });
173
178
  if (target === SignalTarget.PUBLISHER) {
174
179
  await this.publisher.addIceCandidate(candidate);
175
180
  } else {
@@ -179,7 +184,6 @@ export class PCTransportManager {
179
184
 
180
185
  async createSubscriberAnswerFromOffer(sd: RTCSessionDescriptionInit, offerId: number) {
181
186
  this.log.debug('received server offer', {
182
- ...this.logContext,
183
187
  RTCSdpType: sd.type,
184
188
  sdp: sd.sdp,
185
189
  signalingState: this.subscriber?.getSignallingState().toString(),
@@ -200,6 +204,7 @@ export class PCTransportManager {
200
204
  }
201
205
 
202
206
  updateConfiguration(config: RTCConfiguration, iceRestart?: boolean) {
207
+ this.log.debug('updating rtc configuration', { iceRestart });
203
208
  this.publisher.setConfiguration(config);
204
209
  this.subscriber?.setConfiguration(config);
205
210
  if (iceRestart) {
@@ -215,7 +220,7 @@ export class PCTransportManager {
215
220
  this.publisher.getConnectionState() !== 'connected' &&
216
221
  this.publisher.getConnectionState() !== 'connecting'
217
222
  ) {
218
- this.log.debug('negotiation required, start negotiating', this.logContext);
223
+ this.log.debug('negotiation required, start negotiating');
219
224
  this.publisher.negotiate();
220
225
  }
221
226
  await Promise.all(
@@ -368,7 +373,6 @@ export class PCTransportManager {
368
373
  `pc state change: from ${PCTransportState[previousState]} to ${
369
374
  PCTransportState[this.state]
370
375
  }`,
371
- this.logContext,
372
376
  );
373
377
  this.onStateChange?.(
374
378
  this.state,
@@ -390,7 +394,7 @@ export class PCTransportManager {
390
394
 
391
395
  return new Promise<void>(async (resolve, reject) => {
392
396
  const abortHandler = () => {
393
- this.log.warn('abort transport connection', this.logContext);
397
+ this.log.warn('abort transport connection');
394
398
  CriticalTimers.clearTimeout(connectTimeout);
395
399
 
396
400
  reject(ConnectionError.cancelled('room connection has been cancelled'));