livekit-client 2.18.3 → 2.18.5

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 (53) hide show
  1. package/dist/livekit-client.esm.mjs +703 -334
  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 +12 -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 +3 -0
  11. package/dist/src/room/Room.d.ts.map +1 -1
  12. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts +4 -0
  13. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts.map +1 -1
  14. package/dist/src/room/events.d.ts +3 -1
  15. package/dist/src/room/events.d.ts.map +1 -1
  16. package/dist/src/room/participant/LocalParticipant.d.ts +8 -0
  17. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  18. package/dist/src/room/participant/RemoteParticipant.d.ts +4 -3
  19. package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
  20. package/dist/src/room/track/LocalTrack.d.ts +7 -0
  21. package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
  22. package/dist/src/room/track/LocalVideoTrack.d.ts +12 -1
  23. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  24. package/dist/src/room/types.d.ts +1 -1
  25. package/dist/src/room/types.d.ts.map +1 -1
  26. package/dist/ts4.2/room/RTCEngine.d.ts +12 -4
  27. package/dist/ts4.2/room/Room.d.ts +3 -0
  28. package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +4 -0
  29. package/dist/ts4.2/room/events.d.ts +3 -1
  30. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +8 -0
  31. package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +4 -3
  32. package/dist/ts4.2/room/track/LocalTrack.d.ts +7 -0
  33. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
  34. package/dist/ts4.2/room/types.d.ts +1 -1
  35. package/package.json +3 -3
  36. package/src/api/SignalClient.ts +4 -0
  37. package/src/room/PCTransport.ts +10 -8
  38. package/src/room/RTCEngine.ts +59 -28
  39. package/src/room/RegionUrlProvider.ts +7 -0
  40. package/src/room/Room.ts +93 -23
  41. package/src/room/data-track/incoming/IncomingDataTrackManager.test.ts +331 -16
  42. package/src/room/data-track/incoming/IncomingDataTrackManager.ts +92 -41
  43. package/src/room/events.ts +2 -0
  44. package/src/room/participant/LocalParticipant.ts +70 -5
  45. package/src/room/participant/RemoteParticipant.ts +14 -2
  46. package/src/room/token-source/TokenSource.test.ts +337 -0
  47. package/src/room/token-source/test-tokens.ts +28 -0
  48. package/src/room/token-source/utils.test.ts +12 -20
  49. package/src/room/track/LocalTrack.ts +15 -1
  50. package/src/room/track/LocalVideoTrack.ts +126 -2
  51. package/src/room/track/RemoteVideoTrack.ts +8 -2
  52. package/src/room/types.ts +2 -1
  53. package/src/utils/deferrable-map.ts +2 -2
@@ -39,6 +39,7 @@ import { defaultVideoCodec } from '../defaults';
39
39
  import {
40
40
  DeviceUnsupportedError,
41
41
  LivekitError,
42
+ NegotiationError,
42
43
  PublishTrackError,
43
44
  SignalRequestError,
44
45
  TrackInvalidError,
@@ -656,11 +657,12 @@ export default class LocalParticipant extends Participant {
656
657
  if (track && track.track) {
657
658
  // screenshare cannot be muted, unpublish instead
658
659
  if (source === Track.Source.ScreenShare) {
659
- track = await this.unpublishTrack(track.track);
660
+ const unpublishPromises = [this.unpublishTrack(track.track)];
660
661
  const screenAudioTrack = this.getTrackPublication(Track.Source.ScreenShareAudio);
661
662
  if (screenAudioTrack && screenAudioTrack.track) {
662
- this.unpublishTrack(screenAudioTrack.track);
663
+ unpublishPromises.push(this.unpublishTrack(screenAudioTrack.track));
663
664
  }
665
+ [track] = await Promise.all(unpublishPromises);
664
666
  } else {
665
667
  await track.mute();
666
668
  }
@@ -806,10 +808,42 @@ export default class LocalParticipant extends Participant {
806
808
  return this.publishOrRepublishTrack(track, options);
807
809
  }
808
810
 
811
+ /**
812
+ * Waits for the engine's next `Restarted` event. Unlike `engine.waitForRestarted`, this does
813
+ * not short-circuit when `pcState === Connected` — at the point this is called (right after a
814
+ * `NegotiationError`) the PC transport is still connected, but `fullReconnectOnNext` has been
815
+ * set and `attemptReconnect` is queued via setTimeout. We need to wait for that restart to
816
+ * actually complete (which clears `pendingTrackResolvers` via `cleanupClient`) before retrying.
817
+ */
818
+ private waitForNextEngineRestart(timeoutMs = 15_000): Promise<void> {
819
+ return new Promise<void>((resolve, reject) => {
820
+ const cleanup = () => {
821
+ clearTimeout(timeout);
822
+ this.engine.off(EngineEvent.Restarted, onRestarted);
823
+ this.engine.off(EngineEvent.Closing, onClosing);
824
+ };
825
+ const onRestarted = () => {
826
+ cleanup();
827
+ resolve();
828
+ };
829
+ const onClosing = () => {
830
+ cleanup();
831
+ reject(new Error('engine closed before restart completed'));
832
+ };
833
+ const timeout = setTimeout(() => {
834
+ cleanup();
835
+ reject(new Error('timed out waiting for engine restart'));
836
+ }, timeoutMs);
837
+ this.engine.once(EngineEvent.Restarted, onRestarted);
838
+ this.engine.once(EngineEvent.Closing, onClosing);
839
+ });
840
+ }
841
+
809
842
  private async publishOrRepublishTrack(
810
843
  track: LocalTrack | MediaStreamTrack,
811
844
  options?: TrackPublishOptions,
812
845
  isRepublish = false,
846
+ hasRetriedAfterNegotiationError = false,
813
847
  ): Promise<LocalTrackPublication> {
814
848
  if (isLocalAudioTrack(track)) {
815
849
  track.setAudioContext(this.audioContext);
@@ -978,6 +1012,15 @@ export default class LocalParticipant extends Participant {
978
1012
  const publication = await publishPromise;
979
1013
  return publication;
980
1014
  } catch (e) {
1015
+ if (!hasRetriedAfterNegotiationError && e instanceof NegotiationError) {
1016
+ this.log.warn('negotiation due to track publish failed, retrying after reconnect', {
1017
+ ...this.logContext,
1018
+ error: e,
1019
+ });
1020
+ this.pendingPublishPromises.delete(track);
1021
+ await this.waitForNextEngineRestart();
1022
+ return await this.publishOrRepublishTrack(track, options, isRepublish, true);
1023
+ }
981
1024
  throw e;
982
1025
  } finally {
983
1026
  this.pendingPublishPromises.delete(track);
@@ -1272,7 +1315,11 @@ export default class LocalParticipant extends Participant {
1272
1315
  resolve(ti);
1273
1316
  } catch (err) {
1274
1317
  if (track.sender && this.engine.pcManager?.publisher) {
1275
- this.engine.pcManager.publisher.removeTrack(track.sender);
1318
+ try {
1319
+ this.engine.pcManager.publisher.removeTrack(track.sender);
1320
+ } catch (e) {
1321
+ this.log.error(e, this.logContext);
1322
+ }
1276
1323
  await this.engine.negotiate().catch((negotiateErr) => {
1277
1324
  this.log.error(
1278
1325
  'failed to negotiate after removing track due to failed add track request',
@@ -1333,6 +1380,17 @@ export default class LocalParticipant extends Participant {
1333
1380
  publication.options = opts;
1334
1381
  track.sid = ti.sid;
1335
1382
 
1383
+ // keep publish options on the video track so that it can recompute encoding
1384
+ // parameters when the MediaStreamTrack is restarted (e.g. after switching cameras).
1385
+ // Seed the dimensions we encoded at publish time so the first no-op restart
1386
+ // (e.g. unmute with unchanged constraints) can skip the recompute.
1387
+ if (isLocalVideoTrack(track)) {
1388
+ track.publishOptions = opts;
1389
+ if (req.width && req.height) {
1390
+ track.lastEncodedDimensions = { width: req.width, height: req.height };
1391
+ }
1392
+ }
1393
+
1336
1394
  this.log.debug(`publishing ${track.kind} with encodings`, {
1337
1395
  ...this.logContext,
1338
1396
  encodings,
@@ -1587,13 +1645,20 @@ export default class LocalParticipant extends Participant {
1587
1645
  negotiationNeeded = true;
1588
1646
  }
1589
1647
  }
1590
- if (this.engine.removeTrack(trackSender)) {
1648
+ try {
1649
+ negotiationNeeded = this.engine.removeTrack(trackSender);
1650
+ } catch (e) {
1651
+ this.log.warn(e, this.logContext);
1591
1652
  negotiationNeeded = true;
1592
1653
  }
1654
+
1593
1655
  if (isLocalVideoTrack(track)) {
1594
1656
  for (const [, trackInfo] of track.simulcastCodecs) {
1595
1657
  if (trackInfo.sender) {
1596
- if (this.engine.removeTrack(trackInfo.sender)) {
1658
+ try {
1659
+ negotiationNeeded = this.engine.removeTrack(trackInfo.sender);
1660
+ } catch (e) {
1661
+ this.log.warn(e, this.logContext);
1597
1662
  negotiationNeeded = true;
1598
1663
  }
1599
1664
  trackInfo.sender = undefined;
@@ -6,7 +6,9 @@ import type {
6
6
  } from '@livekit/protocol';
7
7
  import type { SignalClient } from '../../api/SignalClient';
8
8
  import { DeferrableMap } from '../../utils/deferrable-map';
9
- import type RemoteDataTrack from '../data-track/RemoteDataTrack';
9
+ import RemoteDataTrack from '../data-track/RemoteDataTrack';
10
+ import type IncomingDataTrackManager from '../data-track/incoming/IncomingDataTrackManager';
11
+ import { DataTrackInfo } from '../data-track/types';
10
12
  import { ParticipantEvent, TrackEvent } from '../events';
11
13
  import RemoteAudioTrack from '../track/RemoteAudioTrack';
12
14
  import type RemoteTrack from '../track/RemoteTrack';
@@ -48,6 +50,7 @@ export default class RemoteParticipant extends Participant {
48
50
  signalClient: SignalClient,
49
51
  pi: ParticipantInfo,
50
52
  loggerOptions: LoggerOptions,
53
+ manager: IncomingDataTrackManager,
51
54
  ): RemoteParticipant {
52
55
  return new RemoteParticipant(
53
56
  signalClient,
@@ -58,6 +61,10 @@ export default class RemoteParticipant extends Participant {
58
61
  pi.attributes,
59
62
  loggerOptions,
60
63
  pi.kind,
64
+ pi.dataTracks.map((dti) => {
65
+ const info = DataTrackInfo.from(dti);
66
+ return new RemoteDataTrack(info, manager, { publisherIdentity: pi.identity });
67
+ }),
61
68
  );
62
69
  }
63
70
 
@@ -79,13 +86,18 @@ export default class RemoteParticipant extends Participant {
79
86
  attributes?: Record<string, string>,
80
87
  loggerOptions?: LoggerOptions,
81
88
  kind: ParticipantKind = ParticipantKind.STANDARD,
89
+ remoteDataTracks: Array<RemoteDataTrack> = [],
82
90
  ) {
83
91
  super(sid, identity || '', name, metadata, attributes, loggerOptions, kind);
84
92
  this.signalClient = signalClient;
85
93
  this.trackPublications = new Map();
86
94
  this.audioTrackPublications = new Map();
87
95
  this.videoTrackPublications = new Map();
88
- this.dataTracks = new DeferrableMap();
96
+ this.dataTracks = new DeferrableMap(
97
+ remoteDataTracks.map((remoteDataTrack) => {
98
+ return [remoteDataTrack.info.name, remoteDataTrack];
99
+ }),
100
+ );
89
101
  this.volumeMap = new Map();
90
102
  }
91
103
 
@@ -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
  /**