livekit-client 2.18.4 → 2.18.6

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 (41) hide show
  1. package/dist/livekit-client.esm.mjs +451 -227
  2. package/dist/livekit-client.esm.mjs.map +1 -1
  3. package/dist/livekit-client.umd.js +1 -1
  4. package/dist/livekit-client.umd.js.map +1 -1
  5. package/dist/src/api/SignalClient.d.ts.map +1 -1
  6. package/dist/src/room/PCTransport.d.ts.map +1 -1
  7. package/dist/src/room/RTCEngine.d.ts +10 -4
  8. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  9. package/dist/src/room/RegionUrlProvider.d.ts.map +1 -1
  10. package/dist/src/room/Room.d.ts +1 -0
  11. package/dist/src/room/Room.d.ts.map +1 -1
  12. package/dist/src/room/events.d.ts +3 -1
  13. package/dist/src/room/events.d.ts.map +1 -1
  14. package/dist/src/room/participant/LocalParticipant.d.ts +8 -0
  15. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  16. package/dist/src/room/track/LocalTrack.d.ts +7 -0
  17. package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
  18. package/dist/src/room/track/LocalVideoTrack.d.ts +12 -1
  19. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  20. package/dist/ts4.2/room/RTCEngine.d.ts +10 -4
  21. package/dist/ts4.2/room/Room.d.ts +1 -0
  22. package/dist/ts4.2/room/events.d.ts +3 -1
  23. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +8 -0
  24. package/dist/ts4.2/room/track/LocalTrack.d.ts +7 -0
  25. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
  26. package/package.json +2 -2
  27. package/src/api/SignalClient.ts +4 -0
  28. package/src/room/PCTransport.ts +6 -5
  29. package/src/room/RTCEngine.ts +41 -29
  30. package/src/room/RegionUrlProvider.ts +7 -0
  31. package/src/room/Room.ts +21 -3
  32. package/src/room/data-track/packet/index.test.ts +16 -21
  33. package/src/room/data-track/packet/index.ts +3 -3
  34. package/src/room/events.ts +2 -0
  35. package/src/room/participant/LocalParticipant.ts +70 -5
  36. package/src/room/token-source/TokenSource.test.ts +337 -0
  37. package/src/room/token-source/test-tokens.ts +28 -0
  38. package/src/room/token-source/utils.test.ts +12 -20
  39. package/src/room/track/LocalTrack.ts +15 -1
  40. package/src/room/track/LocalVideoTrack.ts +126 -2
  41. package/src/room/track/RemoteVideoTrack.ts +8 -2
@@ -0,0 +1,337 @@
1
+ /* eslint-disable @typescript-eslint/no-unused-vars */
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { sleep } from '../utils';
4
+ import { TokenSource } from './TokenSource';
5
+ import { TOKENS } from './test-tokens';
6
+ import { TokenSourceFetchOptions, TokenSourceResponseObject } from './types';
7
+
8
+ const EXAMPLE_FETCH_OPTIONS: TokenSourceFetchOptions = {
9
+ roomName: 'room name',
10
+ participantName: 'participant name',
11
+ participantIdentity: 'participant identity',
12
+ participantMetadata: '{"example": "metadata here"}',
13
+ participantAttributes: {},
14
+
15
+ agentName: 'agent name',
16
+ agentMetadata: '{"example": "agent metadata here"}',
17
+ };
18
+
19
+ const EXAMPLE_TOKEN_ENDPOINT_RESPONSE_JSON = {
20
+ server_url: 'wss://localhost:7000',
21
+ participant_token: 'bogus token',
22
+ };
23
+
24
+ function makeResponseObject(token: string = TOKENS.VALID): TokenSourceResponseObject {
25
+ return {
26
+ serverUrl: 'wss://localhost:7000',
27
+ participantToken: token,
28
+ };
29
+ }
30
+
31
+ function mockGlobalFetchResponse(
32
+ responseJson: any = EXAMPLE_TOKEN_ENDPOINT_RESPONSE_JSON,
33
+ responseOptions?: ResponseInit,
34
+ ) {
35
+ return mockGlobalFetchResponses([{ responseJson, responseOptions }]);
36
+ }
37
+
38
+ function mockGlobalFetchResponses(
39
+ responses: Array<{
40
+ responseJson?: any;
41
+ responseOptions?: ResponseInit;
42
+ }>,
43
+ ) {
44
+ const oldFetch = globalThis.fetch;
45
+
46
+ const fetchMock = vi.fn();
47
+ for (const {
48
+ responseJson = EXAMPLE_TOKEN_ENDPOINT_RESPONSE_JSON,
49
+ responseOptions,
50
+ } of responses) {
51
+ const response = new Response(JSON.stringify(responseJson), {
52
+ status: 200,
53
+ headers: { 'Content-Type': 'application/json' },
54
+ ...responseOptions,
55
+ });
56
+ fetchMock.mockResolvedValueOnce(response);
57
+ }
58
+ globalThis.fetch = fetchMock;
59
+
60
+ const teardown = () => {
61
+ globalThis.fetch = oldFetch;
62
+ };
63
+
64
+ return { fetchMock: fetchMock.mock, teardown };
65
+ }
66
+
67
+ describe('TokenSource.endpoint', () => {
68
+ it('tests happy path with all options', async () => {
69
+ const { teardown, fetchMock } = mockGlobalFetchResponse();
70
+
71
+ try {
72
+ const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
73
+ await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
74
+ expect(fetchMock.lastCall).toStrictEqual([
75
+ 'https://example.com/my/token/endpoint',
76
+ {
77
+ method: 'POST',
78
+ body: JSON.stringify({
79
+ room_name: 'room name',
80
+ participant_name: 'participant name',
81
+ participant_identity: 'participant identity',
82
+ participant_metadata: '{"example": "metadata here"}',
83
+ room_config: {
84
+ agents: [
85
+ {
86
+ agent_name: 'agent name',
87
+ metadata: '{"example": "agent metadata here"}',
88
+ },
89
+ ],
90
+ },
91
+ }),
92
+ headers: { 'Content-Type': 'application/json' },
93
+ },
94
+ ]);
95
+ } finally {
96
+ teardown();
97
+ }
98
+ });
99
+
100
+ it('tests happy path with no options', async () => {
101
+ const { teardown, fetchMock } = mockGlobalFetchResponse();
102
+
103
+ try {
104
+ const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
105
+ await tokenSource.fetch({});
106
+ expect(fetchMock.lastCall).toStrictEqual([
107
+ 'https://example.com/my/token/endpoint',
108
+ {
109
+ method: 'POST',
110
+ body: JSON.stringify({}),
111
+ headers: { 'Content-Type': 'application/json' },
112
+ },
113
+ ]);
114
+ } finally {
115
+ teardown();
116
+ }
117
+ });
118
+
119
+ it('throws on non-200 response', async () => {
120
+ const { teardown } = mockGlobalFetchResponse(
121
+ { error: 'forbidden' },
122
+ { status: 403, headers: { 'Content-Type': 'application/json' } },
123
+ );
124
+
125
+ try {
126
+ const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
127
+ await expect(tokenSource.fetch(EXAMPLE_FETCH_OPTIONS)).rejects.toThrow(/received 403/);
128
+ } finally {
129
+ teardown();
130
+ }
131
+ });
132
+
133
+ it('merges custom headers from EndpointOptions', async () => {
134
+ const { teardown, fetchMock } = mockGlobalFetchResponse();
135
+
136
+ try {
137
+ const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint', {
138
+ headers: { Authorization: 'Bearer my-token', 'X-Custom': 'value' },
139
+ });
140
+ await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
141
+ expect((fetchMock.lastCall![1] as RequestInit).headers).toStrictEqual({
142
+ 'Content-Type': 'application/json',
143
+ Authorization: 'Bearer my-token',
144
+ 'X-Custom': 'value',
145
+ });
146
+ } finally {
147
+ teardown();
148
+ }
149
+ });
150
+
151
+ it('sends only provided fields in request body', async () => {
152
+ const { teardown, fetchMock } = mockGlobalFetchResponse();
153
+
154
+ try {
155
+ const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
156
+ await tokenSource.fetch({ roomName: 'my-room' });
157
+ const body = JSON.parse((fetchMock.lastCall![1] as RequestInit).body as string);
158
+ expect(body.room_name).toStrictEqual('my-room');
159
+ // Agent-related fields should not be present since they weren't provided
160
+ expect(body.room_config).toBeUndefined();
161
+ } finally {
162
+ teardown();
163
+ }
164
+ });
165
+
166
+ it('deserializes response with extra unknown fields without error', async () => {
167
+ const { teardown } = mockGlobalFetchResponse({
168
+ server_url: 'wss://localhost:7000',
169
+ participant_token: TOKENS.VALID,
170
+ some_future_field: 'should be ignored',
171
+ another_unknown: 42,
172
+ });
173
+
174
+ try {
175
+ const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
176
+ const result = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
177
+ expect(result.serverUrl).toStrictEqual('wss://localhost:7000');
178
+ expect(result.participantToken).toStrictEqual(TOKENS.VALID);
179
+ } finally {
180
+ teardown();
181
+ }
182
+ });
183
+ });
184
+
185
+ describe('TokenSource.custom', () => {
186
+ it('calls custom function and resolves result', async () => {
187
+ const customFn = vi.fn().mockResolvedValue(makeResponseObject());
188
+
189
+ const tokenSource = TokenSource.custom(customFn);
190
+ const result = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
191
+
192
+ expect(customFn).toHaveBeenCalledWith(EXAMPLE_FETCH_OPTIONS);
193
+ expect(result.serverUrl).toStrictEqual('wss://localhost:7000');
194
+ expect(result.participantToken).toStrictEqual(TOKENS.VALID);
195
+ });
196
+
197
+ it('deserializes response with extra unknown fields without error', async () => {
198
+ const customFn = vi.fn().mockResolvedValue({
199
+ ...makeResponseObject(),
200
+ someFutureField: 'should be ignored',
201
+ anotherUnknown: 42,
202
+ });
203
+
204
+ const tokenSource = TokenSource.custom(customFn);
205
+ const result = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
206
+
207
+ expect(result.serverUrl).toStrictEqual('wss://localhost:7000');
208
+ expect(result.participantToken).toStrictEqual(TOKENS.VALID);
209
+ });
210
+ });
211
+
212
+ describe('TokenSourceConfigurable caching behavior (via TokenSource.custom)', () => {
213
+ it('returns cached value on second call with same options', async () => {
214
+ const customFn = vi.fn().mockResolvedValue(makeResponseObject());
215
+
216
+ const tokenSource = TokenSource.custom(customFn);
217
+ const result1 = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
218
+ const result2 = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
219
+
220
+ expect(customFn).toHaveBeenCalledTimes(1);
221
+ expect(result1).toStrictEqual(result2);
222
+ });
223
+
224
+ it('refetches when fetch options change', async () => {
225
+ const customFn = vi.fn().mockResolvedValue(makeResponseObject());
226
+
227
+ const tokenSource = TokenSource.custom(customFn);
228
+ await tokenSource.fetch({ roomName: 'room-1' });
229
+ await tokenSource.fetch({ roomName: 'room-2' });
230
+
231
+ expect(customFn).toHaveBeenCalledTimes(2);
232
+ expect(customFn).toHaveBeenNthCalledWith(1, { roomName: 'room-1' });
233
+ expect(customFn).toHaveBeenNthCalledWith(2, { roomName: 'room-2' });
234
+ });
235
+
236
+ it('refetches when force is true even with same options', async () => {
237
+ const customFn = vi.fn().mockResolvedValue(makeResponseObject());
238
+
239
+ const tokenSource = TokenSource.custom(customFn);
240
+ await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
241
+ await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS, true);
242
+
243
+ expect(customFn).toHaveBeenCalledTimes(2);
244
+ });
245
+
246
+ it('refetches when cached token is expired', async () => {
247
+ const customFn = vi
248
+ .fn()
249
+ .mockResolvedValueOnce(makeResponseObject(TOKENS.EXP_IN_PAST))
250
+ .mockResolvedValueOnce(makeResponseObject(TOKENS.VALID));
251
+
252
+ const tokenSource = TokenSource.custom(customFn);
253
+ await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
254
+ await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
255
+
256
+ // Should have called twice because the first token was expired
257
+ expect(customFn).toHaveBeenCalledTimes(2);
258
+ });
259
+
260
+ it('caches across multiple calls when token remains valid', async () => {
261
+ const customFn = vi.fn().mockResolvedValue(makeResponseObject());
262
+
263
+ const tokenSource = TokenSource.custom(customFn);
264
+ await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
265
+ await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
266
+ await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
267
+ await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
268
+
269
+ expect(customFn).toHaveBeenCalledTimes(1);
270
+ });
271
+
272
+ it('refetches when any single option field changes', async () => {
273
+ const customFn = vi.fn().mockResolvedValue(makeResponseObject());
274
+ const tokenSource = TokenSource.custom(customFn);
275
+
276
+ const baseOptions: TokenSourceFetchOptions = {
277
+ roomName: 'room',
278
+ participantName: 'name',
279
+ participantIdentity: 'identity',
280
+ participantMetadata: 'meta',
281
+ participantAttributes: { key: 'value' },
282
+ agentName: 'agent',
283
+ agentMetadata: 'agent-meta',
284
+ };
285
+
286
+ await tokenSource.fetch(baseOptions);
287
+ expect(customFn).toHaveBeenCalledTimes(1);
288
+
289
+ // Changing participantIdentity should invalidate cache
290
+ await tokenSource.fetch({ ...baseOptions, participantIdentity: 'different-identity' });
291
+ expect(customFn).toHaveBeenCalledTimes(2);
292
+ });
293
+
294
+ it('getCachedResponseJwtPayload returns null before first fetch', () => {
295
+ const tokenSource = TokenSource.custom(async () => makeResponseObject());
296
+ expect(tokenSource.getCachedResponseJwtPayload()).toBeNull();
297
+ });
298
+
299
+ it('getCachedResponseJwtPayload returns decoded payload after fetch', async () => {
300
+ const tokenSource = TokenSource.custom(async () => makeResponseObject());
301
+ await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
302
+
303
+ const payload = tokenSource.getCachedResponseJwtPayload();
304
+ expect(payload).not.toBeNull();
305
+ expect(payload!.sub).toStrictEqual('1234567890');
306
+ expect(payload!.roomConfig?.name).toStrictEqual('test room name');
307
+ });
308
+
309
+ it('serializes concurrent fetches via mutex', async () => {
310
+ let concurrentCalls = 0;
311
+ let maxConcurrentCalls = 0;
312
+
313
+ const customFn = vi.fn().mockImplementation(async () => {
314
+ concurrentCalls += 1;
315
+ maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
316
+
317
+ // Simulate async work
318
+ await sleep(10);
319
+
320
+ concurrentCalls -= 1;
321
+ return makeResponseObject();
322
+ });
323
+
324
+ const tokenSource = TokenSource.custom(customFn);
325
+
326
+ // Launch concurrent fetches with different options so caching doesn't short-circuit
327
+ await Promise.all([
328
+ tokenSource.fetch({ roomName: 'room-1' }),
329
+ tokenSource.fetch({ roomName: 'room-2' }),
330
+ tokenSource.fetch({ roomName: 'room-3' }),
331
+ ]);
332
+
333
+ // The mutex should ensure only one fetch runs at a time
334
+ expect(maxConcurrentCalls).toStrictEqual(1);
335
+ expect(customFn).toHaveBeenCalledTimes(3);
336
+ });
337
+ });
@@ -0,0 +1,28 @@
1
+ // Test JWTs created for test purposes only.
2
+ // None of these actually auth against anything.
3
+ export const TOKENS = {
4
+ // Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
5
+ // Exp date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
6
+ // A dummy roomConfig value is also set, with room_config.name = "test room name", and room_config.agents = [{"agentName": "test agent name","metadata":"test agent metadata"}]
7
+ VALID:
8
+ 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwicm9vbUNvbmZpZyI6eyJuYW1lIjoidGVzdCByb29tIG5hbWUiLCJlbXB0eVRpbWVvdXQiOjAsImRlcGFydHVyZVRpbWVvdXQiOjAsIm1heFBhcnRpY2lwYW50cyI6MCwibWluUGxheW91dERlbGF5IjowLCJtYXhQbGF5b3V0RGVsYXkiOjAsInN5bmNTdHJlYW1zIjpmYWxzZSwiYWdlbnRzIjpbeyJhZ2VudE5hbWUiOiJ0ZXN0IGFnZW50IG5hbWUiLCJtZXRhZGF0YSI6InRlc3QgYWdlbnQgbWV0YWRhdGEifV0sIm1ldGFkYXRhIjoiIn19.EDetpHG8cSubaApzgWJaQrpCiSy9KDBlfCfVdIydbQ-_CHiNnXOK_f_mCJbTf9A-duT1jmvPOkLrkkWFT60XPQ',
9
+
10
+ // Nbf date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
11
+ // Exp date set at 9876543211 seconds (Fri Dec 22 2282 20:13:31 GMT+0000)
12
+ NBF_IN_FUTURE:
13
+ 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjExLCJuYmYiOjk4NzY1NDMyMTAsImlhdCI6MTIzNDU2Nzg5MH0.DcMmdKrD76eJg7IUBZqoTRDvBaXtCcwtuE5h7IwVXhG_6nvgxN_ix30_AmLgnYhvhkN-x9dTRPoHg-CME72AbQ',
14
+
15
+ // Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
16
+ // Exp date set at 1234567891 seconds (Fri Feb 13 2009 23:31:31 GMT+0000)
17
+ EXP_IN_PAST:
18
+ 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxMjM0NTY3ODkxLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MH0.OYP1NITayotBYt0mioInLJmaIM0bHyyR-yG6iwKyQDzhoGha15qbsc7dOJlzz4za1iW5EzCgjc2_xGxqaSu5XA',
19
+
20
+ // This token contains extra fields embedded within which aren't part of the protobuf data
21
+ // structure. These could be new fields added in a future protocol version, etc.
22
+ //
23
+ // Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
24
+ // Exp date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
25
+ // A dummy roomConfig value is also set, with room_config.name = "test room name", room_config.extraField = "extra field value", and room_config.agents = [{"agentName": "test agent name","metadata":"test agent metadata","extraField":"extra field value"}]
26
+ EXTRA_FIELDS:
27
+ 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwicm9vbUNvbmZpZyI6eyJuYW1lIjoidGVzdCByb29tIG5hbWUiLCJlbXB0eVRpbWVvdXQiOjAsImRlcGFydHVyZVRpbWVvdXQiOjAsIm1heFBhcnRpY2lwYW50cyI6MCwibWluUGxheW91dERlbGF5IjowLCJtYXhQbGF5b3V0RGVsYXkiOjAsInN5bmNTdHJlYW1zIjpmYWxzZSwiYWdlbnRzIjpbeyJhZ2VudE5hbWUiOiJ0ZXN0IGFnZW50IG5hbWUiLCJtZXRhZGF0YSI6InRlc3QgYWdlbnQgbWV0YWRhdGEiLCJleHRyYUZpZWxkIjoiZXh0cmEgZmllbGQgdmFsdWUifV0sIm1ldGFkYXRhIjoiIiwiZXh0cmFGaWVsZCI6ImV4dHJhIGZpZWxkIHZhbHVlIn19Cg.EDetpHG8cSubaApzgWJaQrpCiSy9KDBlfCfVdIydbQ-_CHiNnXOK_f_mCJbTf9A-duT1jmvPOkLrkkWFT60XPQ',
28
+ };
@@ -1,27 +1,8 @@
1
1
  import { TokenSourceResponse } from '@livekit/protocol';
2
2
  import { describe, expect, it } from 'vitest';
3
+ import { TOKENS } from './test-tokens';
3
4
  import { areTokenSourceFetchOptionsEqual, decodeTokenPayload, isResponseTokenValid } from './utils';
4
5
 
5
- // Test JWTs created for test purposes only.
6
- // None of these actually auth against anything.
7
- const TOKENS = {
8
- // Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
9
- // Exp date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
10
- // A dummy roomConfig value is also set, with room_config.name = "test room name", and room_config.agents = [{"agentName": "test agent name","metadata":"test agent metadata"}]
11
- VALID:
12
- 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwicm9vbUNvbmZpZyI6eyJuYW1lIjoidGVzdCByb29tIG5hbWUiLCJlbXB0eVRpbWVvdXQiOjAsImRlcGFydHVyZVRpbWVvdXQiOjAsIm1heFBhcnRpY2lwYW50cyI6MCwibWluUGxheW91dERlbGF5IjowLCJtYXhQbGF5b3V0RGVsYXkiOjAsInN5bmNTdHJlYW1zIjpmYWxzZSwiYWdlbnRzIjpbeyJhZ2VudE5hbWUiOiJ0ZXN0IGFnZW50IG5hbWUiLCJtZXRhZGF0YSI6InRlc3QgYWdlbnQgbWV0YWRhdGEifV0sIm1ldGFkYXRhIjoiIn19.EDetpHG8cSubaApzgWJaQrpCiSy9KDBlfCfVdIydbQ-_CHiNnXOK_f_mCJbTf9A-duT1jmvPOkLrkkWFT60XPQ',
13
-
14
- // Nbf date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
15
- // Exp date set at 9876543211 seconds (Fri Dec 22 2282 20:13:31 GMT+0000)
16
- NBF_IN_FUTURE:
17
- 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjExLCJuYmYiOjk4NzY1NDMyMTAsImlhdCI6MTIzNDU2Nzg5MH0.DcMmdKrD76eJg7IUBZqoTRDvBaXtCcwtuE5h7IwVXhG_6nvgxN_ix30_AmLgnYhvhkN-x9dTRPoHg-CME72AbQ',
18
-
19
- // Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
20
- // Exp date set at 1234567891 seconds (Fri Feb 13 2009 23:31:31 GMT+0000)
21
- EXP_IN_PAST:
22
- 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxMjM0NTY3ODkxLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MH0.OYP1NITayotBYt0mioInLJmaIM0bHyyR-yG6iwKyQDzhoGha15qbsc7dOJlzz4za1iW5EzCgjc2_xGxqaSu5XA',
23
- };
24
-
25
6
  describe('isResponseTokenValid', () => {
26
7
  it('should find a valid jwt not expired', () => {
27
8
  const isValid = isResponseTokenValid(
@@ -60,6 +41,17 @@ describe('decodeTokenPayload', () => {
60
41
  expect(payload.roomConfig?.agents![0].agentName).toBe('test agent name');
61
42
  expect(payload.roomConfig?.agents![0].metadata).toBe('test agent metadata');
62
43
  });
44
+ it('should extract roomconfig metadata from a token with extra fields', () => {
45
+ const payload = decodeTokenPayload(TOKENS.EXTRA_FIELDS);
46
+ expect(payload.roomConfig?.name).toBe('test room name');
47
+ expect(payload.roomConfig?.agents).toHaveLength(1);
48
+ expect(payload.roomConfig?.agents![0].agentName).toBe('test agent name');
49
+ expect(payload.roomConfig?.agents![0].metadata).toBe('test agent metadata');
50
+
51
+ // Make sure the extra fields aren't in the payload, just the ones in the protobuf
52
+ expect((payload.roomConfig as any)?.extraField).toBeUndefined();
53
+ expect((payload.roomConfig?.agents![0] as any)?.extraField).toBeUndefined();
54
+ });
63
55
  });
64
56
 
65
57
  describe('areTokenSourceFetchOptionsEqual', () => {
@@ -322,10 +322,22 @@ export default abstract class LocalTrack<
322
322
  if (stopProcessor && this.processor) {
323
323
  await this.internalStopProcessor();
324
324
  }
325
- return this;
326
325
  } finally {
327
326
  unlock();
328
327
  }
328
+ await this.onSenderTrackSwapped();
329
+ return this;
330
+ }
331
+
332
+ /**
333
+ * Hook invoked after the MediaStreamTrack on the sender has been swapped
334
+ * (via replaceTrack, setProcessor, or stopProcessor). Fires outside the
335
+ * trackChangeLock so subclasses can do asynchronous work such as polling
336
+ * for new dimensions without blocking other track operations.
337
+ */
338
+ protected async onSenderTrackSwapped(): Promise<void> {
339
+ // base implementation is a no-op; LocalVideoTrack overrides this to
340
+ // recompute sender encoding parameters.
329
341
  }
330
342
 
331
343
  protected async restart(constraints?: MediaTrackConstraints, isUnmuting?: boolean) {
@@ -589,6 +601,7 @@ export default abstract class LocalTrack<
589
601
  } finally {
590
602
  unlock();
591
603
  }
604
+ await this.onSenderTrackSwapped();
592
605
  }
593
606
 
594
607
  getProcessor() {
@@ -607,6 +620,7 @@ export default abstract class LocalTrack<
607
620
  } finally {
608
621
  unlock();
609
622
  }
623
+ await this.onSenderTrackSwapped();
610
624
  }
611
625
 
612
626
  /**
@@ -8,14 +8,19 @@ import {
8
8
  import type { SignalClient } from '../../api/SignalClient';
9
9
  import type { StructuredLogger } from '../../logger';
10
10
  import { TrackEvent } from '../events';
11
- import { ScalabilityMode } from '../participant/publishUtils';
11
+ import {
12
+ ScalabilityMode,
13
+ computeTrackBackupEncodings,
14
+ computeVideoEncodings,
15
+ } from '../participant/publishUtils';
12
16
  import type { VideoSenderStats } from '../stats';
13
17
  import { computeBitrate, monitorFrequency } from '../stats';
14
18
  import type { LoggerOptions } from '../types';
15
19
  import { isFireFox, isMobile, isSVCCodec, isWeb } from '../utils';
16
20
  import LocalTrack from './LocalTrack';
17
21
  import { Track, VideoQuality } from './Track';
18
- import type { VideoCaptureOptions, VideoCodec } from './options';
22
+ import type { TrackPublishOptions, VideoCaptureOptions, VideoCodec } from './options';
23
+ import { isBackupVideoCodec } from './options';
19
24
  import type { TrackProcessor } from './processor/types';
20
25
  import { constraintsForOptions } from './utils';
21
26
 
@@ -61,6 +66,12 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
61
66
 
62
67
  private optimizeForPerformance: boolean = false;
63
68
 
69
+ /* @internal */
70
+ publishOptions?: TrackPublishOptions;
71
+
72
+ /* @internal */
73
+ lastEncodedDimensions?: Track.Dimensions;
74
+
64
75
  get sender(): RTCRtpSender | undefined {
65
76
  return this._sender;
66
77
  }
@@ -265,6 +276,119 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
265
276
  await sc.sender.replaceTrack(sc.mediaStreamTrack);
266
277
  }
267
278
  }
279
+
280
+ // The new MediaStreamTrack may have different dimensions than the previous one
281
+ // (e.g. switching between cameras with different native resolutions), which would
282
+ // leave the sender's encoding parameters (scaleResolutionDownBy, maxBitrate, etc.)
283
+ // based on the old dimensions. Recompute them so the encoded output matches the
284
+ // new source.
285
+ await this.onSenderTrackSwapped();
286
+ }
287
+
288
+ protected override async onSenderTrackSwapped(): Promise<void> {
289
+ await this.refreshSenderEncodings();
290
+ }
291
+
292
+ /**
293
+ * Recomputes encoding parameters for this track's senders based on the current
294
+ * MediaStreamTrack dimensions and reapplies them via setParameters. This is a no-op
295
+ * if the track hasn't been published yet or if the track is in performance-optimized
296
+ * mode (which manages its own encodings).
297
+ */
298
+ private async refreshSenderEncodings() {
299
+ if (!this.sender || !this.publishOptions || this.optimizeForPerformance) {
300
+ return;
301
+ }
302
+ const unlock = await this.senderLock.lock();
303
+ try {
304
+ let dims: Track.Dimensions;
305
+ try {
306
+ dims = await this.waitForDimensions();
307
+ } catch (e) {
308
+ this.log.warn('could not determine new track dimensions, skipping encoding recompute', {
309
+ ...this.logContext,
310
+ error: e,
311
+ });
312
+ return;
313
+ }
314
+
315
+ if (
316
+ this.lastEncodedDimensions &&
317
+ this.lastEncodedDimensions.width === dims.width &&
318
+ this.lastEncodedDimensions.height === dims.height
319
+ ) {
320
+ return;
321
+ }
322
+
323
+ const isScreenShare = this.source === Track.Source.ScreenShare;
324
+ const newEncodings = computeVideoEncodings(isScreenShare, dims.width, dims.height, {
325
+ ...this.publishOptions,
326
+ });
327
+
328
+ await this.applyEncodingsToSender(this.sender, newEncodings);
329
+ this.encodings = newEncodings;
330
+ this.lastEncodedDimensions = dims;
331
+
332
+ for (const [codec, sc] of this.simulcastCodecs) {
333
+ if (!sc.sender || sc.sender.transport?.state === 'closed') {
334
+ continue;
335
+ }
336
+ if (!isBackupVideoCodec(codec)) {
337
+ continue;
338
+ }
339
+ const backupOpts: TrackPublishOptions = { ...this.publishOptions };
340
+ const backupEncodings = computeTrackBackupEncodings(this, codec, backupOpts);
341
+ if (!backupEncodings) {
342
+ continue;
343
+ }
344
+ await this.applyEncodingsToSender(sc.sender, backupEncodings);
345
+ sc.encodings = backupEncodings;
346
+ }
347
+ } catch (e) {
348
+ this.log.warn('failed to apply recomputed encodings', {
349
+ ...this.logContext,
350
+ error: e,
351
+ });
352
+ } finally {
353
+ unlock();
354
+ }
355
+ }
356
+
357
+ private async applyEncodingsToSender(
358
+ sender: RTCRtpSender,
359
+ encodings: RTCRtpEncodingParameters[],
360
+ ) {
361
+ const params = sender.getParameters();
362
+ if (!params.encodings || params.encodings.length !== encodings.length) {
363
+ return;
364
+ }
365
+ params.encodings.forEach((existing, idx) => {
366
+ // preserve disabled layers (dynacast / Firefox workaround in
367
+ // setPublishingLayersForSender set scaleResolutionDownBy/maxBitrate to sentinel
368
+ // values for disabled layers — don't clobber those).
369
+ if (existing.active === false) {
370
+ return;
371
+ }
372
+ const next = encodings[idx];
373
+ if (next.scaleResolutionDownBy !== undefined) {
374
+ existing.scaleResolutionDownBy = next.scaleResolutionDownBy;
375
+ }
376
+ if (next.maxBitrate !== undefined) {
377
+ existing.maxBitrate = next.maxBitrate;
378
+ }
379
+ if (next.maxFramerate !== undefined) {
380
+ existing.maxFramerate = next.maxFramerate;
381
+ }
382
+ if (next.priority !== undefined) {
383
+ existing.priority = next.priority;
384
+ existing.networkPriority = next.priority;
385
+ }
386
+ });
387
+ this.log.debug('updating sender encodings after track restart', {
388
+ ...this.logContext,
389
+ encodings: params.encodings,
390
+ });
391
+ await sender.setParameters(params);
268
392
  }
269
393
 
270
394
  async setProcessor(
@@ -384,8 +384,14 @@ class HTMLElementInfo implements ElementInfo {
384
384
 
385
385
  private onEnterPiP = () => {
386
386
  window.documentPictureInPicture?.window?.addEventListener('pagehide', this.onLeavePiP);
387
- this.isPiP = isElementInPiP(this.element);
388
- this.handleVisibilityChanged?.();
387
+ // Document PiP: the browser may fire 'enter' before the app has appended its subtree into
388
+ // documentPictureInPicture.window. Defer so pipWin.document.contains(video) is reliable.
389
+ queueMicrotask(() => {
390
+ requestAnimationFrame(() => {
391
+ this.isPiP = isElementInPiP(this.element);
392
+ this.handleVisibilityChanged?.();
393
+ });
394
+ });
389
395
  };
390
396
 
391
397
  private onLeavePiP = () => {