@stream-io/video-client 1.57.0 → 1.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/e2ee-worker.browser.es.js +1342 -0
  3. package/dist/e2ee-worker.browser.es.js.map +1 -0
  4. package/dist/index.browser.es.js +1141 -117
  5. package/dist/index.browser.es.js.map +1 -1
  6. package/dist/index.cjs.js +1196 -115
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +5 -0
  9. package/dist/index.es.js +1174 -117
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/src/Call.d.ts +66 -9
  12. package/dist/src/StreamVideoClient.d.ts +9 -1
  13. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  14. package/dist/src/devices/CameraManager.d.ts +8 -1
  15. package/dist/src/devices/DeviceManager.d.ts +2 -1
  16. package/dist/src/devices/MicrophoneManager.d.ts +8 -1
  17. package/dist/src/devices/SpeakerManager.d.ts +2 -1
  18. package/dist/src/events/participant.d.ts +10 -3
  19. package/dist/src/gen/coordinator/index.d.ts +1386 -240
  20. package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
  21. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  22. package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
  23. package/dist/src/helpers/concurrency.d.ts +8 -0
  24. package/dist/src/helpers/ensureExhausted.d.ts +1 -1
  25. package/dist/src/helpers/loopback.d.ts +55 -0
  26. package/dist/src/permissions/PermissionsContext.d.ts +2 -2
  27. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  28. package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
  29. package/dist/src/rtc/Subscriber.d.ts +5 -0
  30. package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
  31. package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
  32. package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
  33. package/dist/src/rtc/e2ee/events.d.ts +163 -0
  34. package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
  35. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
  36. package/dist/src/rtc/index.d.ts +1 -0
  37. package/dist/src/rtc/mediaEngine.d.ts +23 -0
  38. package/dist/src/rtc/types.d.ts +2 -0
  39. package/dist/src/store/CallState.d.ts +21 -0
  40. package/dist/src/types.d.ts +15 -6
  41. package/index.ts +5 -0
  42. package/package.json +1 -1
  43. package/src/Call.ts +311 -155
  44. package/src/StreamVideoClient.ts +35 -8
  45. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  46. package/src/__tests__/Call.publishing.test.ts +42 -1
  47. package/src/__tests__/Call.test.ts +15 -0
  48. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  49. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  50. package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
  51. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  52. package/src/coordinator/connection/connection.ts +6 -3
  53. package/src/devices/AudioDeviceManager.ts +6 -0
  54. package/src/devices/CameraManager.ts +67 -8
  55. package/src/devices/DeviceManager.ts +24 -4
  56. package/src/devices/MicrophoneManager.ts +66 -8
  57. package/src/devices/ScreenShareManager.ts +3 -0
  58. package/src/devices/SpeakerManager.ts +42 -12
  59. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  60. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  61. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  62. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
  63. package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
  64. package/src/devices/__tests__/mocks.ts +36 -3
  65. package/src/events/__tests__/participant.test.ts +134 -1
  66. package/src/events/call.ts +6 -3
  67. package/src/events/callEventHandlers.ts +9 -3
  68. package/src/events/participant.ts +42 -7
  69. package/src/gen/coordinator/index.ts +1402 -236
  70. package/src/gen/video/sfu/models/models.ts +15 -0
  71. package/src/helpers/MediaStreamRecorder.ts +104 -0
  72. package/src/helpers/TypedEventEmitter.ts +71 -0
  73. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  74. package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
  75. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  76. package/src/helpers/__tests__/loopback.test.ts +367 -0
  77. package/src/helpers/concurrency.ts +24 -0
  78. package/src/helpers/ensureExhausted.ts +4 -2
  79. package/src/helpers/loopback.ts +200 -0
  80. package/src/permissions/PermissionsContext.ts +10 -5
  81. package/src/reporting/ClientEventReporter.ts +15 -28
  82. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  83. package/src/rtc/BasePeerConnection.ts +12 -1
  84. package/src/rtc/Publisher.ts +8 -0
  85. package/src/rtc/Subscriber.ts +13 -0
  86. package/src/rtc/__tests__/Publisher.test.ts +101 -0
  87. package/src/rtc/__tests__/Subscriber.test.ts +190 -0
  88. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
  89. package/src/rtc/e2ee/E2EEManager.ts +12 -0
  90. package/src/rtc/e2ee/EncryptionManager.ts +336 -0
  91. package/src/rtc/e2ee/SPEC.md +586 -0
  92. package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
  93. package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
  94. package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
  95. package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
  96. package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
  97. package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
  98. package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
  99. package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
  100. package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
  101. package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
  102. package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
  103. package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
  104. package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
  105. package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
  106. package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
  107. package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
  108. package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
  109. package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
  110. package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
  111. package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
  112. package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
  113. package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
  114. package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
  115. package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
  116. package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
  117. package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
  118. package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
  119. package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
  120. package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
  121. package/src/rtc/e2ee/e2ee-worker.ts +27 -0
  122. package/src/rtc/e2ee/events.ts +179 -0
  123. package/src/rtc/e2ee/transformSupport.ts +35 -0
  124. package/src/rtc/helpers/rtcConfiguration.ts +4 -2
  125. package/src/rtc/index.ts +1 -0
  126. package/src/rtc/mediaEngine.ts +44 -0
  127. package/src/rtc/types.ts +2 -0
  128. package/src/store/CallState.ts +33 -1
  129. package/src/store/__tests__/CallState.test.ts +82 -1
  130. package/src/types.ts +23 -5
@@ -1,4 +1,8 @@
1
- import { CallSettingsResponse, OwnCapability } from '../gen/coordinator';
1
+ import {
2
+ CallSettingsResponse,
3
+ OwnCapability,
4
+ RequestPermissionRequestPermissionsEnum,
5
+ } from '../gen/coordinator';
2
6
  import { TrackType } from '../gen/video/sfu/models/models';
3
7
  import { ensureExhausted } from '../helpers/ensureExhausted';
4
8
 
@@ -70,20 +74,21 @@ export class PermissionsContext {
70
74
  * @param settings the call settings to check against (optional).
71
75
  */
72
76
  canRequest = (
73
- permission: OwnCapability,
77
+ permission: RequestPermissionRequestPermissionsEnum,
74
78
  settings: CallSettingsResponse | undefined = this.settings,
75
79
  ) => {
76
80
  if (!settings) return false;
77
81
 
78
82
  const { audio, video, screensharing } = settings;
79
83
  switch (permission) {
80
- case OwnCapability.SEND_AUDIO:
84
+ case RequestPermissionRequestPermissionsEnum.SEND_AUDIO:
81
85
  return audio.access_request_enabled;
82
- case OwnCapability.SEND_VIDEO:
86
+ case RequestPermissionRequestPermissionsEnum.SEND_VIDEO:
83
87
  return video.access_request_enabled;
84
- case OwnCapability.SCREENSHARE:
88
+ case RequestPermissionRequestPermissionsEnum.SCREENSHARE:
85
89
  return screensharing.access_request_enabled;
86
90
  default:
91
+ ensureExhausted(permission);
87
92
  return false;
88
93
  }
89
94
  };
@@ -19,6 +19,11 @@ import type {
19
19
  BrowserPermissionState,
20
20
  } from '../devices/BrowserPermission';
21
21
  import { getCurrentValue } from '../store/rxUtils';
22
+ import type {
23
+ ClientEvent,
24
+ ReportClientEventRequest,
25
+ ReportClientEventResponse,
26
+ } from '../gen/coordinator';
22
27
 
23
28
  export type ClientEventPeerConnection = 'publish' | 'subscribe';
24
29
 
@@ -55,7 +60,6 @@ export type CallReportContext = {
55
60
  callId: string;
56
61
  getSfuId: () => string;
57
62
  getCallSessionId: () => string;
58
- getUserSessionId: () => string;
59
63
  };
60
64
 
61
65
  export type ClientEventReporterOptions = {
@@ -80,7 +84,6 @@ type StagePairState = {
80
84
 
81
85
  type PeerConnectionPairState = StagePairState & {
82
86
  sfuId: string;
83
- userSessionId: string;
84
87
  wasPreviouslyConnected: boolean;
85
88
  lastIceState?: RTCIceConnectionState;
86
89
  };
@@ -192,9 +195,7 @@ export class ClientEventReporter {
192
195
  this.coordinatorWsPair = undefined;
193
196
  };
194
197
 
195
- private buildCoordinatorWsCommon = (
196
- pair: StagePairState,
197
- ): Record<string, unknown> => ({
198
+ private buildCoordinatorWsCommon = (pair: StagePairState): ClientEvent => ({
198
199
  user_id: pair.userIdSnapshot ?? this.streamClient.userID,
199
200
  stage: 'CoordinatorWS',
200
201
  stage_id: pair.sid,
@@ -383,7 +384,6 @@ export class ClientEventReporter {
383
384
  user_id: this.streamClient.userID || this.coordinatorConnectUserId,
384
385
  type: ctx?.callType,
385
386
  id: ctx?.callId,
386
- call_cid: cid,
387
387
  stage: 'JoinInitiated',
388
388
  join_attempt_id: joinAttemptId,
389
389
  ...(coordinatorConnectId && {
@@ -608,7 +608,6 @@ export class ClientEventReporter {
608
608
  startedAt: Date.now(),
609
609
  joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
610
610
  sfuId: this.getSfuId(cid),
611
- userSessionId: this.getUserSessionId(cid),
612
611
  wasPreviouslyConnected: this.pcEverConnected.get(key) === true,
613
612
  lastIceState: iceConnectionState,
614
613
  };
@@ -620,9 +619,6 @@ export class ClientEventReporter {
620
619
  peer_connection: role,
621
620
  was_previously_connected: pair.wasPreviouslyConnected,
622
621
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
623
- ...(pair.userSessionId && {
624
- user_session_id: pair.userSessionId,
625
- }),
626
622
  event_type: 'initiated',
627
623
  });
628
624
  };
@@ -641,9 +637,6 @@ export class ClientEventReporter {
641
637
  peer_connection: role,
642
638
  was_previously_connected: pair.wasPreviouslyConnected,
643
639
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
644
- ...(pair.userSessionId && {
645
- user_session_id: pair.userSessionId,
646
- }),
647
640
  event_type: 'completed',
648
641
  outcome: 'success',
649
642
  retry_count_attempt: 0,
@@ -667,9 +660,6 @@ export class ClientEventReporter {
667
660
  ...this.sessionIdField(cid),
668
661
  peer_connection: role,
669
662
  was_previously_connected: pair.wasPreviouslyConnected,
670
- ...(pair.userSessionId && {
671
- user_session_id: pair.userSessionId,
672
- }),
673
663
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
674
664
  event_type: 'completed',
675
665
  outcome: 'failure',
@@ -685,10 +675,7 @@ export class ClientEventReporter {
685
675
  private getSfuId = (cid: string): string =>
686
676
  this.callContexts.get(cid)?.getSfuId() ?? '';
687
677
 
688
- private getUserSessionId = (cid: string): string =>
689
- this.callContexts.get(cid)?.getUserSessionId() ?? '';
690
-
691
- private sessionIdField = (cid: string): Record<string, unknown> => {
678
+ private sessionIdField = (cid: string): { call_session_id?: string } => {
692
679
  const callSessionId = this.callContexts.get(cid)?.getCallSessionId() ?? '';
693
680
  return callSessionId ? { call_session_id: callSessionId } : {};
694
681
  };
@@ -697,14 +684,13 @@ export class ClientEventReporter {
697
684
  cid: string,
698
685
  stage: ClientEventStage,
699
686
  pair: StagePairState,
700
- ): Record<string, unknown> => {
687
+ ): ClientEvent => {
701
688
  const ctx = this.callContexts.get(cid);
702
689
  const coordinatorConnectId = this.coordinatorConnectId;
703
690
  return {
704
691
  user_id: this.streamClient.userID || this.coordinatorConnectUserId,
705
692
  type: ctx?.callType ?? '',
706
693
  id: ctx?.callId ?? '',
707
- call_cid: cid,
708
694
  stage,
709
695
  stage_id: pair.sid,
710
696
  ...(pair.joinAttemptIdSnapshot && {
@@ -719,22 +705,23 @@ export class ClientEventReporter {
719
705
  };
720
706
  };
721
707
 
722
- private send = (body: Record<string, unknown>) => {
708
+ private send = (body: ClientEvent) => {
723
709
  if (!this.enabled) return;
724
710
  void this.sendWithRetry(body);
725
711
  };
726
712
 
727
- private sendForCall = (cid: string, body: Record<string, unknown>) => {
713
+ private sendForCall = (cid: string, body: ClientEvent) => {
728
714
  if (!this.callContexts.has(cid)) return;
729
715
  this.send(body);
730
716
  };
731
717
 
732
- private sendWithRetry = async (
733
- body: Record<string, unknown>,
734
- ): Promise<boolean> => {
718
+ private sendWithRetry = async (body: ClientEvent): Promise<boolean> => {
735
719
  for (let attempt = 0; attempt < 5; attempt++) {
736
720
  try {
737
- await this.streamClient.doAxiosRequest(
721
+ await this.streamClient.doAxiosRequest<
722
+ ReportClientEventResponse,
723
+ ReportClientEventRequest
724
+ >(
738
725
  'post',
739
726
  '/call_client_event',
740
727
  { events: [body] },
@@ -77,7 +77,6 @@ describe('ClientEventReporter', () => {
77
77
  callId: 'call-1',
78
78
  getCallSessionId: () => 'session-1',
79
79
  getSfuId: () => 'sfu-1',
80
- getUserSessionId: () => 'user-session-1',
81
80
  };
82
81
  reporter.registerCall(cid, ctx);
83
82
  });
@@ -91,7 +90,7 @@ describe('ClientEventReporter', () => {
91
90
  expect(events).toHaveLength(2);
92
91
  expect(events[0]).toMatchObject({
93
92
  event_type: 'initiated',
94
- call_cid: cid,
93
+ id: 'call-1',
95
94
  user_id: 'user-1',
96
95
  coordinator_connect_id: connectId,
97
96
  join_reason: 'first-attempt',
@@ -523,7 +522,6 @@ describe('ClientEventReporter', () => {
523
522
  peer_connection: 'publish',
524
523
  was_previously_connected: false,
525
524
  sfu_id: 'sfu-1',
526
- user_session_id: 'user-session-1',
527
525
  });
528
526
  expect(events[1]).toMatchObject({
529
527
  event_type: 'completed',
@@ -712,7 +710,6 @@ describe('ClientEventReporter', () => {
712
710
  callId: 'call-2',
713
711
  getCallSessionId: () => 'session-2',
714
712
  getSfuId: () => 'sfu-2',
715
- getUserSessionId: () => 'user-session-2',
716
713
  });
717
714
 
718
715
  reporter.startCorrelation(cid, 'first-attempt');
@@ -722,10 +719,10 @@ describe('ClientEventReporter', () => {
722
719
  await flush();
723
720
 
724
721
  const ws1 = postedEvents().filter(
725
- (e) => e.stage === 'WSJoin' && e.call_cid === cid,
722
+ (e) => e.stage === 'WSJoin' && e.id === 'call-1',
726
723
  );
727
724
  const ws2 = postedEvents().filter(
728
- (e) => e.stage === 'WSJoin' && e.call_cid === cid2,
725
+ (e) => e.stage === 'WSJoin' && e.id === 'call-2',
729
726
  );
730
727
  expect(ws1).toHaveLength(2);
731
728
  expect(ws2).toHaveLength(2);
@@ -806,7 +803,6 @@ describe('ClientEventReporter (disabled)', () => {
806
803
  callId: 'call-1',
807
804
  getCallSessionId: () => 'session-1',
808
805
  getSfuId: () => 'sfu-1',
809
- getUserSessionId: () => 'user-session-1',
810
806
  });
811
807
  });
812
808
 
@@ -8,6 +8,8 @@ import {
8
8
  TrackType,
9
9
  WebsocketReconnectStrategy,
10
10
  } from '../gen/video/sfu/models/models';
11
+ import type { E2EEManager } from './e2ee/E2EEManager';
12
+ import { hasInsertableStreams } from './e2ee/transformSupport';
11
13
  import { NegotiationError } from './NegotiationError';
12
14
  import { StreamSfuClient } from '../StreamSfuClient';
13
15
  import { AllSfuEvents, Dispatcher } from './Dispatcher';
@@ -35,6 +37,7 @@ export abstract class BasePeerConnection {
35
37
  protected readonly state: CallState;
36
38
  protected readonly dispatcher: Dispatcher;
37
39
  protected readonly clientPublishOptions?: ClientPublishOptions;
40
+ protected readonly e2ee?: E2EEManager;
38
41
  protected tag: string;
39
42
  protected sfuClient: StreamSfuClient;
40
43
 
@@ -75,6 +78,7 @@ export abstract class BasePeerConnection {
75
78
  tag,
76
79
  enableTracing,
77
80
  clientPublishOptions,
81
+ e2ee,
78
82
  iceRestartDelay = 2500,
79
83
  statsTimestampDriftThresholdMs = 0,
80
84
  }: BasePeerConnectionOpts,
@@ -85,6 +89,7 @@ export abstract class BasePeerConnection {
85
89
  this.dispatcher = dispatcher;
86
90
  this.iceRestartDelay = iceRestartDelay;
87
91
  this.clientPublishOptions = clientPublishOptions;
92
+ this.e2ee = e2ee;
88
93
  this.tag = tag;
89
94
  this.onReconnectionNeeded = onReconnectionNeeded;
90
95
  this.onIceConnected = onIceConnected;
@@ -114,7 +119,13 @@ export abstract class BasePeerConnection {
114
119
  }
115
120
 
116
121
  private createPeerConnection = (connectionConfig?: RTCConfiguration) => {
117
- const pc = new RTCPeerConnection(connectionConfig);
122
+ const config: RTCConfiguration = { ...connectionConfig };
123
+ // The legacy Insertable Streams path requires this non-standard flag.
124
+ if (this.e2ee && hasInsertableStreams()) {
125
+ // @ts-expect-error not part of the standard lib yet
126
+ config.encodedInsertableStreams = true;
127
+ }
128
+ const pc = new RTCPeerConnection(config);
118
129
  pc.addEventListener('icecandidate', this.onIceCandidate);
119
130
  pc.addEventListener('icecandidateerror', this.onIceCandidateError);
120
131
  pc.addEventListener(
@@ -166,6 +166,14 @@ export class Publisher extends BasePeerConnection {
166
166
  toRTCDegradationPreference(publishOption.degradationPreference) ??
167
167
  'maintain-framerate';
168
168
  await transceiver.sender.setParameters(params);
169
+ if (this.e2ee) {
170
+ this.e2ee.encrypt(
171
+ transceiver.sender,
172
+ publishOption.codec?.name.toLowerCase(),
173
+ TrackType[publishOption.trackType],
174
+ );
175
+ this.logger.debug('E2EE encryptor attached to sender');
176
+ }
169
177
 
170
178
  await this.negotiate();
171
179
  };
@@ -94,6 +94,13 @@ export class Subscriber extends BasePeerConnection {
94
94
  }
95
95
  };
96
96
 
97
+ /**
98
+ * Whether the given stream is one the SFU echoed back to us
99
+ * (`allowOwnTracksLoopback`) rather than a local capture stream.
100
+ */
101
+ isSelfSubscribedStream = (stream: MediaStream | undefined): boolean =>
102
+ !!stream && !!this.trackedStreams?.has(stream);
103
+
97
104
  private handleOnTrack = (e: RTCTrackEvent) => {
98
105
  const { streams, track } = e;
99
106
  const [primaryStream] = streams;
@@ -151,8 +158,13 @@ export class Subscriber extends BasePeerConnection {
151
158
  trackLookupPrefix: trackId,
152
159
  track: primaryStream,
153
160
  trackType,
161
+ receiver: this.e2ee ? e.receiver : undefined,
154
162
  });
155
163
  return;
164
+ } else if (this.e2ee) {
165
+ const { userId } = participantToUpdate;
166
+ this.e2ee.decrypt(e.receiver, userId, TrackType[trackType]);
167
+ this.logger.debug('E2EE decryptor attached to receiver', userId);
156
168
  }
157
169
 
158
170
  const streamKindProp = trackTypeToParticipantStreamKey(trackType);
@@ -207,6 +219,7 @@ export class Subscriber extends BasePeerConnection {
207
219
  (p) => p.trackLookupPrefix === trackId,
208
220
  );
209
221
  if (!target) return;
222
+ if (target.isLocalParticipant) return;
210
223
  this.state.updateParticipant(target.sessionId, (p) => {
211
224
  const current = p.interruptedTracks ?? [];
212
225
  const has = current.includes(trackType);
@@ -146,6 +146,50 @@ describe('Publisher', () => {
146
146
  expect(negotiateSpy).toHaveBeenCalled();
147
147
  });
148
148
 
149
+ it('should attach an encryptor when E2EE manager is provided', async () => {
150
+ const e2eeMock = {
151
+ encrypt: vi.fn(),
152
+ decrypt: vi.fn(),
153
+ };
154
+ publisher.dispose();
155
+ publisher = new Publisher(
156
+ {
157
+ sfuClient,
158
+ dispatcher,
159
+ state,
160
+ tag: 'test',
161
+ enableTracing: false,
162
+ e2ee: e2eeMock,
163
+ },
164
+ [
165
+ {
166
+ id: 1,
167
+ trackType: TrackType.VIDEO,
168
+ bitrate: 1000,
169
+ // @ts-expect-error - incomplete data
170
+ codec: { name: 'vp9' },
171
+ fps: 30,
172
+ maxTemporalLayers: 3,
173
+ maxSpatialLayers: 3,
174
+ },
175
+ ],
176
+ );
177
+
178
+ const track = new MediaStreamTrack();
179
+ const clone = new MediaStreamTrack();
180
+ vi.spyOn(track, 'clone').mockReturnValue(clone);
181
+ // @ts-expect-error - private method
182
+ vi.spyOn(publisher, 'negotiate').mockResolvedValue();
183
+
184
+ await publisher.publish(track, TrackType.VIDEO);
185
+
186
+ expect(e2eeMock.encrypt).toHaveBeenCalledWith(
187
+ expect.anything(), // sender
188
+ 'vp9',
189
+ 'VIDEO',
190
+ );
191
+ });
192
+
149
193
  it('should update an existing transceiver for a new track', async () => {
150
194
  const track = new MediaStreamTrack();
151
195
  const clone = new MediaStreamTrack();
@@ -239,6 +283,63 @@ describe('Publisher', () => {
239
283
  });
240
284
  });
241
285
 
286
+ describe('E2EE peer connection config', () => {
287
+ const e2ee = { encrypt: vi.fn(), decrypt: vi.fn() };
288
+
289
+ /**
290
+ * Build a publisher and return the RTCConfiguration it constructed its peer
291
+ * connection with. The publisher from the outer `beforeEach` already
292
+ * consumed a call, hence the last one rather than the first.
293
+ */
294
+ const configOf = async (opts: { e2ee?: typeof e2ee }) => {
295
+ await publisher.dispose();
296
+ publisher = new Publisher(
297
+ {
298
+ sfuClient,
299
+ dispatcher,
300
+ state,
301
+ tag: 'test',
302
+ enableTracing: false,
303
+ ...opts,
304
+ },
305
+ [],
306
+ );
307
+ const calls = vi.mocked(globalThis.RTCPeerConnection).mock.calls;
308
+ return calls[calls.length - 1][0] as Record<string, unknown> | undefined;
309
+ };
310
+
311
+ const withInsertableStreams = (available: boolean) => {
312
+ if (available) {
313
+ Object.assign(RTCRtpSender.prototype, {
314
+ createEncodedStreams: vi.fn(),
315
+ });
316
+ } else {
317
+ // @ts-expect-error - non-standard property from the mock prototype
318
+ delete RTCRtpSender.prototype.createEncodedStreams;
319
+ }
320
+ };
321
+
322
+ afterEach(() => withInsertableStreams(false));
323
+
324
+ it('enables encodedInsertableStreams when a manager is attached', async () => {
325
+ withInsertableStreams(true);
326
+ const config = await configOf({ e2ee });
327
+ expect(config?.encodedInsertableStreams).toBe(true);
328
+ });
329
+
330
+ it('leaves the flag off when no manager is attached', async () => {
331
+ withInsertableStreams(true);
332
+ const config = await configOf({});
333
+ expect(config?.encodedInsertableStreams).toBeUndefined();
334
+ });
335
+
336
+ it('leaves the flag off on browsers without Insertable Streams', async () => {
337
+ withInsertableStreams(false);
338
+ const config = await configOf({ e2ee });
339
+ expect(config?.encodedInsertableStreams).toBeUndefined();
340
+ });
341
+ });
342
+
242
343
  describe('Event Handling', () => {
243
344
  it('handles changePublishQuality events', () => {
244
345
  publisher['changePublishQuality'] = vi.fn();
@@ -17,6 +17,7 @@ import { NegotiationError } from '../NegotiationError';
17
17
  import { ReconnectReason } from '../types';
18
18
  import { IceTrickleBuffer } from '../IceTrickleBuffer';
19
19
  import { StreamClient } from '../../coordinator/connection/client';
20
+ import { fromPartial } from '@total-typescript/shoehorn';
20
21
 
21
22
  vi.mock('../../StreamSfuClient', () => {
22
23
  console.log('MOCKING StreamSfuClient');
@@ -446,6 +447,65 @@ describe('Subscriber', () => {
446
447
  });
447
448
 
448
449
  describe('OnTrack', () => {
450
+ const audioStreamNamed = (trackLookupPrefix: string) => {
451
+ const stream = new MediaStream();
452
+ // @ts-expect-error - mock
453
+ stream.id = `${trackLookupPrefix}:TRACK_TYPE_AUDIO`;
454
+ return stream;
455
+ };
456
+
457
+ const audioTrack = () => {
458
+ const track = new MediaStreamTrack();
459
+ // @ts-expect-error - mock
460
+ track.kind = 'audio';
461
+ return track;
462
+ };
463
+
464
+ it('replaces the local capture stream with the echoed one, leaving the capture tracks live', () => {
465
+ const captureTrack = audioTrack();
466
+ const captureStream = new MediaStream();
467
+ captureStream.getTracks = () => [captureTrack];
468
+ const echoedStream = audioStreamNamed('self-lookup');
469
+ // @ts-expect-error - incomplete mock
470
+ state.updateOrAddParticipant('self-session', {
471
+ sessionId: 'self-session',
472
+ trackLookupPrefix: 'self-lookup',
473
+ isLocalParticipant: true,
474
+ audioStream: captureStream,
475
+ });
476
+
477
+ const onTrack = subscriber['handleOnTrack'];
478
+ // @ts-expect-error - incomplete mock
479
+ onTrack({ streams: [echoedStream], track: audioTrack() });
480
+
481
+ const participant = state.participants.find(
482
+ (p) => p.sessionId === 'self-session',
483
+ );
484
+ expect(participant?.audioStream).toBe(echoedStream);
485
+ // the device manager owns the capture stream and is still publishing it
486
+ expect(captureTrack.stop).not.toHaveBeenCalled();
487
+ expect(captureStream.removeTrack).not.toHaveBeenCalled();
488
+ });
489
+
490
+ it('reports streams it received as self-subscribed, and capture streams as not', () => {
491
+ const echoedStream = audioStreamNamed('self-lookup');
492
+ const captureStream = new MediaStream();
493
+ // @ts-expect-error - incomplete mock
494
+ state.updateOrAddParticipant('self-session', {
495
+ sessionId: 'self-session',
496
+ trackLookupPrefix: 'self-lookup',
497
+ isLocalParticipant: true,
498
+ });
499
+
500
+ const onTrack = subscriber['handleOnTrack'];
501
+ // @ts-expect-error - incomplete mock
502
+ onTrack({ streams: [echoedStream], track: audioTrack() });
503
+
504
+ expect(subscriber.isSelfSubscribedStream(echoedStream)).toBe(true);
505
+ expect(subscriber.isSelfSubscribedStream(captureStream)).toBe(false);
506
+ expect(subscriber.isSelfSubscribedStream(undefined)).toBe(false);
507
+ });
508
+
449
509
  it('should add unknown tracks to the to the call state', () => {
450
510
  const mediaStream = new MediaStream();
451
511
  const mediaStreamTrack = new MediaStreamTrack();
@@ -522,6 +582,94 @@ describe('Subscriber', () => {
522
582
  expect(baseTrack.stop).toHaveBeenCalled();
523
583
  expect(baseStream.removeTrack).toHaveBeenCalledWith(baseTrack);
524
584
  });
585
+
586
+ it('should store receiver in orphaned track when E2EE is enabled', () => {
587
+ const e2eeMock = {
588
+ encrypt: vi.fn(),
589
+ decrypt: vi.fn(),
590
+ };
591
+ subscriber.dispose();
592
+ const e2eeState = new CallState();
593
+ subscriber = new Subscriber({
594
+ sfuClient,
595
+ dispatcher,
596
+ state: e2eeState,
597
+ connectionConfig: { iceServers: [] },
598
+ tag: 'test',
599
+ enableTracing: true,
600
+ e2ee: e2eeMock,
601
+ });
602
+
603
+ const mediaStream = new MediaStream();
604
+ const mediaStreamTrack = new MediaStreamTrack();
605
+ const receiver = {};
606
+ // @ts-expect-error - mock
607
+ mediaStream.id = 'orphan-prefix:TRACK_TYPE_VIDEO';
608
+
609
+ const registerOrphanedTrackSpy = vi.spyOn(
610
+ e2eeState,
611
+ 'registerOrphanedTrack',
612
+ );
613
+ const onTrack = subscriber['handleOnTrack'];
614
+ // @ts-expect-error - incomplete mock
615
+ onTrack({ streams: [mediaStream], track: mediaStreamTrack, receiver });
616
+
617
+ // Decrypt is NOT called immediately for orphaned tracks
618
+ expect(e2eeMock.decrypt).not.toHaveBeenCalled();
619
+ // Receiver is stored with the orphaned track for later reconciliation
620
+ expect(registerOrphanedTrackSpy).toHaveBeenCalledWith({
621
+ id: mediaStream.id,
622
+ trackLookupPrefix: 'orphan-prefix',
623
+ track: mediaStream,
624
+ trackType: TrackType.VIDEO,
625
+ receiver,
626
+ });
627
+ });
628
+
629
+ it('should decrypt with userId when participant is found', () => {
630
+ const e2eeMock = {
631
+ encrypt: vi.fn(),
632
+ decrypt: vi.fn(),
633
+ };
634
+ subscriber.dispose();
635
+ const e2eeState = new CallState();
636
+ subscriber = new Subscriber({
637
+ sfuClient,
638
+ dispatcher,
639
+ state: e2eeState,
640
+ connectionConfig: { iceServers: [] },
641
+ tag: 'test',
642
+ enableTracing: true,
643
+ e2ee: e2eeMock,
644
+ });
645
+
646
+ // Register a participant whose trackLookupPrefix matches the stream
647
+ e2eeState.updateOrAddParticipant(
648
+ 'session-id',
649
+ fromPartial({
650
+ sessionId: 'session-id',
651
+ userId: 'real-user-id',
652
+ trackLookupPrefix: '123',
653
+ }),
654
+ );
655
+
656
+ const mediaStream = new MediaStream();
657
+ const mediaStreamTrack = new MediaStreamTrack();
658
+ const receiver = {};
659
+ // @ts-expect-error - mock
660
+ mediaStream.id = '123:TRACK_TYPE_VIDEO';
661
+
662
+ const onTrack = subscriber['handleOnTrack'];
663
+ // @ts-expect-error - incomplete mock
664
+ onTrack({ streams: [mediaStream], track: mediaStreamTrack, receiver });
665
+
666
+ // Uses the participant's userId (not trackLookupPrefix) for key lookup
667
+ expect(e2eeMock.decrypt).toHaveBeenCalledWith(
668
+ receiver,
669
+ 'real-user-id',
670
+ 'VIDEO',
671
+ );
672
+ });
525
673
  });
526
674
 
527
675
  describe('interruptedTracks', () => {
@@ -594,6 +742,48 @@ describe('Subscriber', () => {
594
742
  expect(interruptedFor('session-id')).toEqual([]);
595
743
  });
596
744
 
745
+ it('leaves the local participant alone on a self-sub track', () => {
746
+ const mediaStream = new MediaStream();
747
+ const track = new MediaStreamTrack();
748
+ // @ts-expect-error - mock
749
+ mediaStream.id = 'self-lookup:TRACK_TYPE_AUDIO';
750
+ // @ts-expect-error - mock
751
+ track.kind = 'audio';
752
+ Object.defineProperty(track, 'muted', {
753
+ configurable: true,
754
+ get: () => true,
755
+ });
756
+ // @ts-expect-error - incomplete mock
757
+ state.updateOrAddParticipant('self-session', {
758
+ sessionId: 'self-session',
759
+ trackLookupPrefix: 'self-lookup',
760
+ isLocalParticipant: true,
761
+ interruptedTracks: [],
762
+ });
763
+
764
+ const onTrack = subscriber['handleOnTrack'];
765
+ // @ts-expect-error - incomplete mock
766
+ onTrack({ streams: [mediaStream], track });
767
+
768
+ expect(interruptedFor('self-session')).toEqual([]);
769
+
770
+ const handlers: Record<string, () => void> = {};
771
+ for (const [event, handler] of (
772
+ track.addEventListener as ReturnType<typeof vi.fn>
773
+ ).mock.calls) {
774
+ handlers[event] = handler as () => void;
775
+ }
776
+
777
+ handlers['mute']();
778
+ expect(interruptedFor('self-session')).toEqual([]);
779
+
780
+ state.updateParticipant('self-session', {
781
+ interruptedTracks: [TrackType.AUDIO],
782
+ });
783
+ handlers['unmute']();
784
+ expect(interruptedFor('self-session')).toEqual([TrackType.AUDIO]);
785
+ });
786
+
597
787
  it('ignores non-audio remote tracks to avoid Dynascale false positives', () => {
598
788
  // Remote video track.muted is dominated by viewport-driven
599
789
  // SFU unsubscriptions, so we deliberately only track audio