livekit-client 2.22.1 → 2.22.2

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 (42) 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 +177 -39
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +85 -60
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.umd.js +1 -1
  8. package/dist/livekit-client.umd.js.map +1 -1
  9. package/dist/src/api/SignalClient.d.ts.map +1 -1
  10. package/dist/src/e2ee/E2eeManager.d.ts +7 -0
  11. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  12. package/dist/src/e2ee/constants.d.ts +5 -0
  13. package/dist/src/e2ee/constants.d.ts.map +1 -1
  14. package/dist/src/e2ee/types.d.ts +9 -2
  15. package/dist/src/e2ee/types.d.ts.map +1 -1
  16. package/dist/src/e2ee/worker/FrameCryptor.d.ts +42 -1
  17. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  18. package/dist/src/room/PCTransportManager.d.ts +12 -0
  19. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  20. package/dist/src/room/data-stream/incoming/StreamReader.d.ts +17 -17
  21. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  22. package/dist/ts4.2/e2ee/E2eeManager.d.ts +7 -0
  23. package/dist/ts4.2/e2ee/constants.d.ts +5 -0
  24. package/dist/ts4.2/e2ee/types.d.ts +9 -2
  25. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +42 -1
  26. package/dist/ts4.2/room/PCTransportManager.d.ts +12 -0
  27. package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +17 -17
  28. package/package.json +1 -1
  29. package/src/api/SignalClient.ts +2 -1
  30. package/src/e2ee/E2eeManager.ts +38 -14
  31. package/src/e2ee/constants.ts +6 -0
  32. package/src/e2ee/subscriberBlackScreen.test.ts +544 -0
  33. package/src/e2ee/types.ts +9 -2
  34. package/src/e2ee/worker/FrameCryptor.race.test.ts +9 -26
  35. package/src/e2ee/worker/FrameCryptor.test.ts +0 -1
  36. package/src/e2ee/worker/FrameCryptor.ts +185 -52
  37. package/src/e2ee/worker/e2ee.worker.ts +38 -6
  38. package/src/room/PCTransportManager.test.ts +35 -0
  39. package/src/room/PCTransportManager.ts +12 -4
  40. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +171 -0
  41. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +17 -18
  42. package/src/room/data-stream/incoming/StreamReader.ts +20 -50
@@ -6694,6 +6694,8 @@ class FrameCryptor extends BaseFrameCryptor {
6694
6694
  this.ERROR_THROTTLE_MS = 1000; // Emit error at most once per second
6695
6695
  this.MAX_ERRORS_PER_MINUTE = 5; // Maximum errors to emit per minute per key
6696
6696
  this.ERROR_WINDOW_MS = 60000; // 1 minute window
6697
+ /** grace period for a teardown or a resubscribe to land before we report a stalled track */
6698
+ this.UNDECRYPTED_TRACK_GRACE_MS = 2000;
6697
6699
  /**
6698
6700
  * Tracks (participant, trackId, payloadType) tuples for which we've already logged a NALU
6699
6701
  * fallback, so a persistent bad state doesn't flood the console (Firefox doesn't filter debug).
@@ -6738,9 +6740,13 @@ class FrameCryptor extends BaseFrameCryptor {
6738
6740
  }
6739
6741
  unsetParticipant() {
6740
6742
  workerLogger.debug('unsetting participant', this.logContext);
6741
- if (this.currentTransform) {
6742
- this.currentTransform = undefined;
6743
- }
6743
+ // NOTE: deliberately does not clear `currentTransform`. Nothing here cancels
6744
+ // the pipe, so the transform really is still running and clearing it would
6745
+ // desync our bookkeeping from reality -- which previously let a later
6746
+ // resubscribe skip setup while frames kept flowing through a cryptor with no
6747
+ // participant assigned.
6748
+ clearTimeout(this.undecryptedTrackTimeout);
6749
+ this.undecryptedTrackTimeout = undefined;
6744
6750
  this.participantIdentity = undefined;
6745
6751
  this.lastErrorTimestamp = new Map();
6746
6752
  this.errorCounts = new Map();
@@ -6758,6 +6764,72 @@ class FrameCryptor extends BaseFrameCryptor {
6758
6764
  getTrackId() {
6759
6765
  return this.trackId;
6760
6766
  }
6767
+ /**
6768
+ * Re-point this cryptor at a new trackId, keeping its (already transferred)
6769
+ * encoded streams. Used when a transceiver is reused for a new track.
6770
+ */
6771
+ setTrackId(trackId) {
6772
+ if (this.trackId === trackId) {
6773
+ return;
6774
+ }
6775
+ workerLogger.debug('re-pointing cryptor at new trackId', Object.assign(Object.assign({}, this.logContext), {
6776
+ newTrackId: trackId
6777
+ }));
6778
+ this.trackId = trackId;
6779
+ }
6780
+ hasActiveTransform() {
6781
+ return !!this.currentTransform;
6782
+ }
6783
+ /**
6784
+ * A track that is subscribed and known to be encrypted, but has no transform to
6785
+ * decrypt it, will never produce a decodable frame again. That state used to be
6786
+ * completely silent (a black tile and endless PLIs), so report it.
6787
+ *
6788
+ * Deferred, because on teardown the encoded streams routinely close before the
6789
+ * 'removeTransform' message arrives -- checking immediately would cry wolf on
6790
+ * every unsubscribe.
6791
+ *
6792
+ * Only meaningful while decoding: a sender's pipe closing on unpublish is
6793
+ * routine and has no 'removeTransform' equivalent to quiet it down.
6794
+ */
6795
+ scheduleUndecryptedTrackWatchdog(operation) {
6796
+ clearTimeout(this.undecryptedTrackTimeout);
6797
+ this.undecryptedTrackTimeout = undefined;
6798
+ if (operation !== 'decode') {
6799
+ return;
6800
+ }
6801
+ this.undecryptedTrackTimeout = setTimeout(() => {
6802
+ if (this.currentTransform || !this.isEnabled()) {
6803
+ return;
6804
+ }
6805
+ workerLogger.warn('encrypted track has no active decrypt transform', this.logContext);
6806
+ this.emitThrottledError(new CryptorError("no active decrypt transform for encrypted track ".concat(this.trackId), CryptorErrorReason.InternalError, this.participantIdentity));
6807
+ }, this.UNDECRYPTED_TRACK_GRACE_MS);
6808
+ }
6809
+ /**
6810
+ * Re-establish the transform if it is gone while we still own the encoded
6811
+ * streams, e.g. after a pipe died on its own (the swallowed
6812
+ * 'Destination stream closed') or when a reused transceiver never got a new
6813
+ * pipeline. Without this the frames pile up in a readable nobody reads and the
6814
+ * decoder starves, which shows up as a permanently black tile.
6815
+ */
6816
+ ensureTransform() {
6817
+ if (this.currentTransform) {
6818
+ return true;
6819
+ }
6820
+ if (!this.retainedStreams || this.trackId === undefined) {
6821
+ workerLogger.warn('no streams retained, cannot re-establish transform', this.logContext);
6822
+ return false;
6823
+ }
6824
+ const _this$retainedStreams = this.retainedStreams,
6825
+ readable = _this$retainedStreams.readable,
6826
+ writable = _this$retainedStreams.writable,
6827
+ operation = _this$retainedStreams.operation;
6828
+ workerLogger.info('re-establishing transform', Object.assign(Object.assign({}, this.logContext), {
6829
+ operation
6830
+ }));
6831
+ return this.setupTransform(operation, readable, writable, this.trackId);
6832
+ }
6761
6833
  /**
6762
6834
  * Update the video codec used by the mediaStreamTrack
6763
6835
  * @param codec
@@ -6784,7 +6856,8 @@ class FrameCryptor extends BaseFrameCryptor {
6784
6856
  this.frameMetadataOpts = frameMetadata;
6785
6857
  this.frameMetadataFrameId = 0;
6786
6858
  }
6787
- setupTransform(operation, readable, writable, trackId, isReuse, codec, frameMetadata) {
6859
+ setupTransform(operation, readable, writable, trackId, codec, frameMetadata) {
6860
+ var _a;
6788
6861
  if (codec) {
6789
6862
  workerLogger.info('setting codec on cryptor to', {
6790
6863
  codec
@@ -6798,18 +6871,17 @@ class FrameCryptor extends BaseFrameCryptor {
6798
6871
  operation,
6799
6872
  passedTrackId: trackId,
6800
6873
  codec,
6801
- isReuse,
6802
6874
  hasCurrentTransform: !!this.currentTransform
6803
6875
  }, this.logContext));
6804
- // Always update trackId, even on reuse
6805
6876
  this.trackId = trackId;
6806
- // If we're reusing and have an active transform skip setup
6807
- if (isReuse && this.currentTransform && readable === this.currentTransform.readable && writable === this.currentTransform.writable) {
6808
- workerLogger.debug('reusing existing transform', Object.assign(Object.assign({}, this.logContext), {
6809
- trackId
6810
- }));
6811
- return;
6812
- }
6877
+ // Retain the streams so we can rebuild the pipe later even if this cryptor
6878
+ // gets detached from its participant in between (see `ensureTransform`).
6879
+ this.retainedStreams = {
6880
+ readable,
6881
+ writable,
6882
+ operation
6883
+ };
6884
+ clearTimeout(this.undecryptedTrackTimeout);
6813
6885
  const symbol = Symbol('transform');
6814
6886
  const transformFn = operation === 'encode' ? this.encodeFunction : this.decodeFunction;
6815
6887
  const transformStream = new TransformStream({
@@ -6823,26 +6895,43 @@ class FrameCryptor extends BaseFrameCryptor {
6823
6895
  trackId,
6824
6896
  symbol
6825
6897
  };
6826
- readable.pipeThrough(transformStream).pipeTo(writable).catch(e => {
6827
- if (e instanceof TypeError && e.message === 'Destination stream closed') {
6828
- // this can happen when subscriptions happen in quick successions, but doesn't influence functionality
6829
- workerLogger.debug('destination stream closed');
6830
- } else {
6831
- workerLogger.warn('transform error', Object.assign({
6832
- error: e
6833
- }, this.logContext));
6834
- this.emit(CryptorEvent.Error, e instanceof CryptorError ? e : new CryptorError(e.message, undefined, this.participantIdentity));
6835
- }
6836
- }).finally(() => {
6837
- var _a;
6838
- // Only clear currentTransform if it's still the same one we started
6898
+ // pipeThrough/pipeTo throw synchronously on an already locked stream, which
6899
+ // would bypass the .catch below and leave us with no transform at all.
6900
+ try {
6901
+ readable.pipeThrough(transformStream).pipeTo(writable).catch(e => {
6902
+ if (e instanceof TypeError && e.message === 'Destination stream closed') {
6903
+ // this can happen when subscriptions happen in quick successions, but doesn't influence functionality
6904
+ workerLogger.debug('destination stream closed');
6905
+ } else {
6906
+ workerLogger.warn('transform error', Object.assign({
6907
+ error: e
6908
+ }, this.logContext));
6909
+ this.emit(CryptorEvent.Error, e instanceof CryptorError ? e : new CryptorError(e.message, undefined, this.participantIdentity));
6910
+ }
6911
+ }).finally(() => {
6912
+ var _a;
6913
+ // Only clear currentTransform if it's still the same one we started
6914
+ if (((_a = this.currentTransform) === null || _a === void 0 ? void 0 : _a.symbol) === symbol) {
6915
+ workerLogger.debug('transform completed', Object.assign(Object.assign({}, this.logContext), {
6916
+ trackId
6917
+ }));
6918
+ this.currentTransform = undefined;
6919
+ }
6920
+ // A pipe ending while the track is still assigned to a participant that
6921
+ // publishes encrypted media means we have silently stopped decrypting.
6922
+ this.scheduleUndecryptedTrackWatchdog(operation);
6923
+ });
6924
+ } catch (e) {
6839
6925
  if (((_a = this.currentTransform) === null || _a === void 0 ? void 0 : _a.symbol) === symbol) {
6840
- workerLogger.debug('transform completed', Object.assign(Object.assign({}, this.logContext), {
6841
- trackId
6842
- }));
6843
6926
  this.currentTransform = undefined;
6844
6927
  }
6845
- });
6928
+ workerLogger.error('failed to set up transform', Object.assign({
6929
+ error: e
6930
+ }, this.logContext));
6931
+ this.emit(CryptorEvent.Error, e instanceof CryptorError ? e : new CryptorError(e.message, CryptorErrorReason.InternalError, this.participantIdentity));
6932
+ return false;
6933
+ }
6934
+ return true;
6846
6935
  }
6847
6936
  setSifTrailer(trailer) {
6848
6937
  workerLogger.debug('setting SIF trailer', Object.assign(Object.assign({}, this.logContext), {
@@ -7023,9 +7112,29 @@ class FrameCryptor extends BaseFrameCryptor {
7023
7112
  // best-effort: never break the media pipeline if trailer parsing fails
7024
7113
  }
7025
7114
  }
7026
- if (!this.isEnabled() ||
7027
- // skip for decryption for empty dtx frames
7028
- encodedFrame.data.byteLength === 0) {
7115
+ // skip decryption for empty dtx frames
7116
+ if (encodedFrame.data.byteLength === 0) {
7117
+ return controller.enqueue(encodedFrame);
7118
+ }
7119
+ const encryptionEnabled = this.isEnabled();
7120
+ if (encryptionEnabled === undefined) {
7121
+ // Either way we drop: forwarding would hand ciphertext straight to the
7122
+ // decoder, which renders as a black frame and logs nothing.
7123
+ if (this.participantIdentity === undefined) {
7124
+ // The pipe outlived its subscription. Expected on unsubscribe/disconnect:
7125
+ // we deliberately leave the pipe running so a reused transceiver can be
7126
+ // re-pointed at a new track, so in-flight frames still arrive here for a
7127
+ // moment. Not worth reporting -- the watchdog covers a track that stays
7128
+ // subscribed without a transform.
7129
+ workerLogger.debug('dropping frame for unassigned cryptor', this.logContext);
7130
+ return;
7131
+ }
7132
+ // A live subscription whose encryption state we were never told about.
7133
+ this.emitThrottledError(new CryptorError("encryption state unknown for track ".concat(this.trackId, ", dropping frame"), CryptorErrorReason.InternalError, this.participantIdentity));
7134
+ return;
7135
+ }
7136
+ if (!encryptionEnabled) {
7137
+ // participant is known to publish unencrypted media
7029
7138
  return controller.enqueue(encodedFrame);
7030
7139
  }
7031
7140
  if (isFrameServerInjected(encodedFrame.data, this.sifTrailer)) {
@@ -7527,12 +7636,12 @@ onmessage = ev => {
7527
7636
  case 'decode':
7528
7637
  let cryptor = getTrackCryptor(data.participantIdentity, data.trackId);
7529
7638
  cryptor.setHasFrameMetadata(data.hasPacketTrailer);
7530
- cryptor.setupTransform(kind, data.readableStream, data.writableStream, data.trackId, data.isReuse, data.codec);
7639
+ cryptor.setupTransform(kind, data.readableStream, data.writableStream, data.trackId, data.codec);
7531
7640
  break;
7532
7641
  case 'encode':
7533
7642
  let pubCryptor = getTrackCryptor(data.participantIdentity, data.trackId);
7534
7643
  pubCryptor.setHasFrameMetadata(data.hasPacketTrailer);
7535
- pubCryptor.setupTransform(kind, data.readableStream, data.writableStream, data.trackId, data.isReuse, data.codec, data.packetTrailer);
7644
+ pubCryptor.setupTransform(kind, data.readableStream, data.writableStream, data.trackId, data.codec, data.packetTrailer);
7536
7645
  break;
7537
7646
  case 'encryptDataRequest':
7538
7647
  const _yield$DataCryptor$en = yield DataCryptor.encrypt(data.payload, getParticipantKeyHandler(data.participantIdentity)),
@@ -7595,15 +7704,28 @@ onmessage = ev => {
7595
7704
  unsetCryptorParticipant(data.trackId, data.participantIdentity);
7596
7705
  break;
7597
7706
  case 'updateCodec':
7598
- const trackCryptor = getTrackCryptor(data.participantIdentity, data.trackId);
7599
- trackCryptor.setVideoCodec(data.codec);
7707
+ const trackCryptor = getTrackCryptor(data.participantIdentity, data.trackId, data.previousTrackId);
7708
+ if (data.codec) {
7709
+ trackCryptor.setVideoCodec(data.codec);
7710
+ }
7600
7711
  trackCryptor.setHasFrameMetadata(data.hasPacketTrailer);
7601
7712
  workerLogger.info('updated codec', {
7602
7713
  participantIdentity: data.participantIdentity,
7603
7714
  trackId: data.trackId,
7715
+ previousTrackId: data.previousTrackId,
7604
7716
  codec: data.codec,
7605
7717
  hasPacketTrailer: data.hasPacketTrailer
7606
7718
  });
7719
+ // Sent on transceiver reuse, where the main thread cannot hand us a new
7720
+ // pipeline. If the previous one died in the meantime we have to rebuild it
7721
+ // here or no frame ever gets decrypted again.
7722
+ if (data.previousTrackId !== undefined && !trackCryptor.ensureTransform()) {
7723
+ workerLogger.error('could not re-establish transform for reused receiver', {
7724
+ participantIdentity: data.participantIdentity,
7725
+ trackId: data.trackId,
7726
+ previousTrackId: data.previousTrackId
7727
+ });
7728
+ }
7607
7729
  break;
7608
7730
  case 'setRTPMap':
7609
7731
  // this is only used for the local participant
@@ -7638,8 +7760,24 @@ function handleRatchetRequest(data) {
7638
7760
  }
7639
7761
  });
7640
7762
  }
7641
- function getTrackCryptor(participantIdentity, trackId) {
7763
+ function getTrackCryptor(participantIdentity, trackId, previousTrackId) {
7642
7764
  let cryptors = participantCryptors.filter(c => c.getTrackId() === trackId);
7765
+ if (cryptors.length === 0 && previousTrackId !== undefined && previousTrackId !== trackId) {
7766
+ // A reused transceiver carries a new trackId, but the cryptor that owns this
7767
+ // receiver's encoded streams is still keyed on the old one. Re-point it instead
7768
+ // of creating a fresh cryptor that has no pipeline (and leaving the old one
7769
+ // piping frames with no participant assigned).
7770
+ const previous = participantCryptors.filter(c => c.getTrackId() === previousTrackId);
7771
+ if (previous.length > 0) {
7772
+ workerLogger.info('reusing cryptor from previous trackId', {
7773
+ participantIdentity,
7774
+ trackId,
7775
+ previousTrackId
7776
+ });
7777
+ previous[0].setTrackId(trackId);
7778
+ cryptors = previous;
7779
+ }
7780
+ }
7643
7781
  if (cryptors.length > 1) {
7644
7782
  const debugInfo = cryptors.map(c => {
7645
7783
  return {
@@ -7775,7 +7913,7 @@ if (self.RTCTransformEvent) {
7775
7913
  trackId,
7776
7914
  codec
7777
7915
  });
7778
- cryptor.setupTransform(kind, transformer.readable, transformer.writable, trackId, false, codec, kind === 'encode' ? options.packetTrailer : undefined);
7916
+ cryptor.setupTransform(kind, transformer.readable, transformer.writable, trackId, codec, kind === 'encode' ? options.packetTrailer : undefined);
7779
7917
  }));
7780
7918
  };
7781
7919
  }