livekit-client 2.18.4 → 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 (39) hide show
  1. package/dist/livekit-client.esm.mjs +448 -224
  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/events.ts +2 -0
  33. package/src/room/participant/LocalParticipant.ts +70 -5
  34. package/src/room/token-source/TokenSource.test.ts +337 -0
  35. package/src/room/token-source/test-tokens.ts +28 -0
  36. package/src/room/token-source/utils.test.ts +12 -20
  37. package/src/room/track/LocalTrack.ts +15 -1
  38. package/src/room/track/LocalVideoTrack.ts +126 -2
  39. package/src/room/track/RemoteVideoTrack.ts +8 -2
@@ -21,6 +21,7 @@ import {
21
21
  PublishDataTrackResponse,
22
22
  ReconnectReason,
23
23
  type ReconnectResponse,
24
+ type RegionSettings,
24
25
  RequestResponse,
25
26
  Room as RoomModel,
26
27
  RoomMovedResponse,
@@ -62,7 +63,6 @@ import { TTLMap } from '../utils/ttlmap';
62
63
  import PCTransport, { PCEvents } from './PCTransport';
63
64
  import { PCTransportManager, PCTransportState } from './PCTransportManager';
64
65
  import type { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy';
65
- import { DEFAULT_MAX_AGE_MS, type RegionUrlProvider } from './RegionUrlProvider';
66
66
  import { DataTrackInfo } from './data-track/types';
67
67
  import { roomConnectOptionDefaults } from './defaults';
68
68
  import {
@@ -86,6 +86,7 @@ import type { TrackPublishOptions, VideoCodec } from './track/options';
86
86
  import { getTrackPublicationInfo } from './track/utils';
87
87
  import type { LoggerOptions } from './types';
88
88
  import {
89
+ Future,
89
90
  isCompressionStreamSupported,
90
91
  isVideoCodec,
91
92
  isVideoTrack,
@@ -222,7 +223,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
222
223
 
223
224
  private shouldFailOnV1Path: boolean = false;
224
225
 
225
- private regionUrlProvider?: RegionUrlProvider;
226
+ private regionStrategy?: RegionStrategy;
226
227
 
227
228
  private log = log;
228
229
 
@@ -249,6 +250,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
249
250
  /** used to indicate whether the browser is currently waiting to reconnect */
250
251
  private isWaitingForNetworkReconnect: boolean = false;
251
252
 
253
+ private bufferStatusLowClosingFuture = new Future<never, UnexpectedConnectionState>();
254
+
252
255
  constructor(private options: InternalRoomOptions) {
253
256
  super();
254
257
  this.log = getLogger(options.loggerName ?? LoggerNames.Engine);
@@ -282,6 +285,13 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
282
285
  this.client.onParticipantUpdate = (updates) =>
283
286
  this.emit(EngineEvent.ParticipantUpdate, updates);
284
287
  this.client.onJoined = (joinResponse) => this.emit(EngineEvent.Joined, joinResponse);
288
+
289
+ this.on(EngineEvent.Closing, () => {
290
+ this.bufferStatusLowClosingFuture.reject?.(new UnexpectedConnectionState('engine closed'));
291
+ });
292
+ // Swallow the rejection at the source so it doesn't surface as an unhandled promise rejection
293
+ // when no waitForBufferStatusLow callers are attached.
294
+ this.bufferStatusLowClosingFuture.promise.catch(() => {});
285
295
  }
286
296
 
287
297
  /** @internal */
@@ -332,7 +342,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
332
342
  this.shouldFailOnV1Path = false;
333
343
  throw ConnectionError.serviceNotFound('Simulated v1 path failure', 'v0-rtc');
334
344
  }
335
- log.warn('joining signal with ', url);
336
345
  const joinResponse = await this.client.join(
337
346
  url,
338
347
  token,
@@ -469,6 +478,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
469
478
  async cleanupClient() {
470
479
  await this.client.close();
471
480
  this.client.resetCallbacks();
481
+ // Any in-flight addTrack requests are orphaned by the signal reconnect — the new session
482
+ // won't deliver `trackPublishedResponse` for them, so reject the pending resolvers and
483
+ // clear the map. Otherwise a subsequent `addTrack` call with the same client id (e.g. a
484
+ // publish retry after a `NegotiationError`) throws `TrackInvalidError`.
485
+ for (const cid of Object.keys(this.pendingTrackResolvers)) {
486
+ this.pendingTrackResolvers[cid].reject();
487
+ }
488
+ this.pendingTrackResolvers = {};
472
489
  }
473
490
 
474
491
  addTrack(req: AddTrackRequest): Promise<TrackInfo> {
@@ -532,8 +549,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
532
549
  }
533
550
 
534
551
  /* @internal */
535
- setRegionUrlProvider(provider: RegionUrlProvider) {
536
- this.regionUrlProvider = provider;
552
+ setRegionStrategy(strategy: RegionStrategy | undefined) {
553
+ this.regionStrategy = strategy;
537
554
  }
538
555
 
539
556
  private async configure(joinResponse?: JoinResponse, useSinglePeerConnection?: boolean) {
@@ -684,7 +701,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
684
701
 
685
702
  this.client.onTokenRefresh = (token: string) => {
686
703
  this.token = token;
687
- this.regionUrlProvider?.updateToken(token);
704
+ this.emit(EngineEvent.TokenRefreshed, token);
688
705
  };
689
706
 
690
707
  this.client.onRemoteMuteChanged = (trackSid: string, muted: boolean) => {
@@ -726,13 +743,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
726
743
 
727
744
  this.client.onLeave = (leave: LeaveRequest) => {
728
745
  this.log.debug('client leave request', { ...this.logContext, reason: leave?.reason });
729
- if (leave.regions && this.regionUrlProvider) {
746
+ if (leave.regions) {
730
747
  this.log.debug('updating regions', this.logContext);
731
- this.regionUrlProvider.setServerReportedRegions({
732
- updatedAtInMs: Date.now(),
733
- maxAgeInMs: DEFAULT_MAX_AGE_MS,
734
- regionSettings: leave.regions,
735
- });
748
+ this.emit(EngineEvent.ServerRegionsReported, leave.regions);
736
749
  }
737
750
  switch (leave.action) {
738
751
  case LeaveRequest_Action.DISCONNECT:
@@ -1137,10 +1150,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1137
1150
  this.log.debug(`reconnecting in ${delay}ms`, this.logContext);
1138
1151
 
1139
1152
  this.clearReconnectTimeout();
1140
- if (this.token && this.regionUrlProvider) {
1153
+ if (this.token) {
1141
1154
  // token may have been refreshed, we do not want to recreate the regionUrlProvider
1142
1155
  // since the current engine may have inherited a regional url
1143
- this.regionUrlProvider.updateToken(this.token);
1156
+ this.emit(EngineEvent.TokenRefreshed, this.token);
1144
1157
  }
1145
1158
  this.reconnectTimeout = CriticalTimers.setTimeout(
1146
1159
  () =>
@@ -1273,17 +1286,17 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1273
1286
  throw new SignalReconnectError('Signal connection got severed during reconnect');
1274
1287
  }
1275
1288
 
1276
- this.regionUrlProvider?.resetAttempts();
1289
+ this.regionStrategy?.resetAttempts();
1277
1290
  // reconnect success
1278
1291
  this.emit(EngineEvent.Restarted);
1279
1292
  } catch (error) {
1280
- const nextRegionUrl = await this.regionUrlProvider?.getNextBestRegionUrl();
1293
+ const nextRegionUrl = await this.regionStrategy?.getNextUrl();
1281
1294
  if (nextRegionUrl) {
1282
1295
  await this.restartConnection(nextRegionUrl);
1283
1296
  return;
1284
1297
  } else {
1285
1298
  // no more regions to try (or we're not on cloud)
1286
- this.regionUrlProvider?.resetAttempts();
1299
+ this.regionStrategy?.resetAttempts();
1287
1300
  throw error;
1288
1301
  }
1289
1302
  }
@@ -1578,23 +1591,15 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1578
1591
  if (this.isBufferStatusLow(kind)) {
1579
1592
  resolve();
1580
1593
  } else {
1581
- const onClosing = () => reject(new UnexpectedConnectionState('engine closed'));
1582
- this.once(EngineEvent.Closing, onClosing);
1583
1594
  const dc = this.dataChannelForKind(kind);
1584
1595
  if (!dc) {
1585
1596
  reject(new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`));
1586
1597
  return;
1587
1598
  }
1588
- dc.addEventListener(
1589
- 'bufferedamountlow',
1590
- () => {
1591
- this.off(EngineEvent.Closing, onClosing);
1592
- resolve();
1593
- },
1594
- {
1595
- once: true,
1596
- },
1597
- );
1599
+ this.bufferStatusLowClosingFuture.promise.catch((e) => reject(e));
1600
+ dc.addEventListener('bufferedamountlow', () => resolve(), {
1601
+ once: true,
1602
+ });
1598
1603
  }
1599
1604
  });
1600
1605
  }
@@ -2023,8 +2028,15 @@ export type EngineEventCallbacks = {
2023
2028
  dataTrackSubscriberHandles: (event: DataTrackSubscriberHandles) => void;
2024
2029
  dataTrackPacketReceived: (packet: Uint8Array) => void;
2025
2030
  joined: (joinResponse: JoinResponse) => void;
2031
+ tokenRefreshed: (token: string) => void;
2032
+ serverRegionsReported: (regions: RegionSettings) => void;
2026
2033
  };
2027
2034
 
2035
+ export interface RegionStrategy {
2036
+ getNextUrl(abortSignal?: AbortSignal): Promise<string | null>;
2037
+ resetAttempts(): void;
2038
+ }
2039
+
2028
2040
  function applyUserDataCompat(newObj: DataPacket, oldObj: UserPacket) {
2029
2041
  const participantIdentity = newObj.participantIdentity
2030
2042
  ? newObj.participantIdentity
@@ -189,6 +189,13 @@ export class RegionUrlProvider {
189
189
 
190
190
  updateToken(token: string) {
191
191
  this.token = token;
192
+ const url = this.getServerUrl();
193
+ const settings = RegionUrlProvider.cache.get(url.hostname);
194
+ RegionUrlProvider.scheduleRefetch(
195
+ this.serverUrl,
196
+ this.token,
197
+ settings?.maxAgeInMs ?? DEFAULT_MAX_AGE_MS,
198
+ );
192
199
  }
193
200
 
194
201
  isCloud() {
package/src/room/Room.ts CHANGED
@@ -47,8 +47,8 @@ import TypedPromise from '../utils/TypedPromise';
47
47
  import { getBrowser } from '../utils/browserParser';
48
48
  import { BackOffStrategy } from './BackOffStrategy';
49
49
  import DeviceManager from './DeviceManager';
50
- import RTCEngine, { DataChannelKind } from './RTCEngine';
51
- import { RegionUrlProvider } from './RegionUrlProvider';
50
+ import RTCEngine, { DataChannelKind, type RegionStrategy } from './RTCEngine';
51
+ import { DEFAULT_MAX_AGE_MS, RegionUrlProvider } from './RegionUrlProvider';
52
52
  import IncomingDataStreamManager from './data-stream/incoming/IncomingDataStreamManager';
53
53
  import {
54
54
  type ByteStreamHandler,
@@ -668,6 +668,16 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
668
668
  }),
669
669
  );
670
670
  this.incomingDataTrackManager.receiveSfuPublicationUpdates(mapped);
671
+ })
672
+ .on(EngineEvent.TokenRefreshed, (token) => {
673
+ this.regionUrlProvider?.updateToken(token);
674
+ })
675
+ .on(EngineEvent.ServerRegionsReported, (regions) => {
676
+ this.regionUrlProvider?.setServerReportedRegions({
677
+ regionSettings: regions,
678
+ updatedAtInMs: Date.now(),
679
+ maxAgeInMs: DEFAULT_MAX_AGE_MS,
680
+ });
671
681
  });
672
682
 
673
683
  if (this.localParticipant) {
@@ -681,6 +691,14 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
681
691
  }
682
692
  }
683
693
 
694
+ private createRegionStrategy(): RegionStrategy {
695
+ return {
696
+ getNextUrl: async (signal?: AbortSignal) =>
697
+ this.regionUrlProvider ? this.regionUrlProvider.getNextBestRegionUrl(signal) : null,
698
+ resetAttempts: () => this.regionUrlProvider?.resetAttempts(),
699
+ };
700
+ }
701
+
684
702
  /**
685
703
  * getLocalDevices abstracts navigator.mediaDevices.enumerateDevices.
686
704
  * In particular, it requests device permissions by default if needed
@@ -965,7 +983,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
965
983
  this.maybeCreateEngine();
966
984
  }
967
985
  if (this.regionUrlProvider?.isCloud()) {
968
- this.engine.setRegionUrlProvider(this.regionUrlProvider);
986
+ this.engine.setRegionStrategy(this.createRegionStrategy());
969
987
  }
970
988
 
971
989
  this.acquireAudioContext();
@@ -631,6 +631,8 @@ export enum EngineEvent {
631
631
  DataTrackSubscriberHandles = 'dataTrackSubscriberHandles',
632
632
  DataTrackPacketReceived = 'dataTrackPacketReceived',
633
633
  Joined = 'joined',
634
+ TokenRefreshed = 'tokenRefreshed',
635
+ ServerRegionsReported = 'serverRegionsReported',
634
636
  }
635
637
 
636
638
  export enum TrackEvent {
@@ -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;