@stream-io/video-client 1.52.1-beta.0 → 1.53.1

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 (69) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/index.browser.es.js +819 -123
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +819 -122
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +819 -123
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +6 -14
  9. package/dist/src/StreamVideoClient.d.ts +2 -0
  10. package/dist/src/coordinator/connection/client.d.ts +1 -0
  11. package/dist/src/devices/MicrophoneManager.d.ts +6 -0
  12. package/dist/src/errors/SfuTimeoutError.d.ts +8 -0
  13. package/dist/src/errors/index.d.ts +1 -0
  14. package/dist/src/gen/google/protobuf/struct.d.ts +1 -3
  15. package/dist/src/gen/google/protobuf/timestamp.d.ts +1 -3
  16. package/dist/src/gen/video/sfu/event/events.d.ts +1 -22
  17. package/dist/src/gen/video/sfu/models/models.d.ts +0 -4
  18. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +2 -23
  19. package/dist/src/helpers/firstVideoFrame.d.ts +7 -0
  20. package/dist/src/reporting/ClientEventReporter.d.ts +84 -0
  21. package/dist/src/reporting/index.d.ts +1 -0
  22. package/dist/src/rtc/BasePeerConnection.d.ts +5 -2
  23. package/dist/src/rtc/Publisher.d.ts +1 -4
  24. package/dist/src/rtc/Subscriber.d.ts +0 -7
  25. package/dist/src/rtc/types.d.ts +24 -1
  26. package/dist/src/types.d.ts +16 -0
  27. package/package.json +1 -1
  28. package/src/Call.ts +185 -106
  29. package/src/StreamSfuClient.ts +3 -3
  30. package/src/StreamVideoClient.ts +18 -3
  31. package/src/__tests__/Call.autodrop.test.ts +4 -1
  32. package/src/__tests__/Call.lifecycle.test.ts +4 -1
  33. package/src/__tests__/Call.publishing.test.ts +4 -1
  34. package/src/__tests__/Call.test.ts +23 -0
  35. package/src/coordinator/connection/client.ts +5 -0
  36. package/src/devices/MicrophoneManager.ts +16 -0
  37. package/src/devices/__tests__/CameraManager.test.ts +10 -1
  38. package/src/devices/__tests__/DeviceManager.test.ts +10 -1
  39. package/src/devices/__tests__/DeviceManagerFilters.test.ts +4 -1
  40. package/src/devices/__tests__/MicrophoneManager.test.ts +10 -1
  41. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +78 -2
  42. package/src/devices/__tests__/ScreenShareManager.test.ts +4 -1
  43. package/src/devices/__tests__/SpeakerManager.test.ts +13 -4
  44. package/src/errors/SfuTimeoutError.ts +7 -0
  45. package/src/errors/index.ts +1 -0
  46. package/src/events/__tests__/call.test.ts +2 -0
  47. package/src/events/__tests__/mutes.test.ts +4 -1
  48. package/src/events/call.ts +8 -0
  49. package/src/gen/google/protobuf/struct.ts +12 -7
  50. package/src/gen/google/protobuf/timestamp.ts +7 -6
  51. package/src/gen/video/sfu/event/events.ts +25 -23
  52. package/src/gen/video/sfu/models/models.ts +1 -11
  53. package/src/gen/video/sfu/signal_rpc/signal.client.ts +29 -25
  54. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -1
  55. package/src/helpers/__tests__/AudioBindingsWatchdog.test.ts +6 -3
  56. package/src/helpers/__tests__/DynascaleManager.test.ts +6 -3
  57. package/src/helpers/__tests__/ViewportTracker.test.ts +6 -3
  58. package/src/helpers/__tests__/firstVideoFrame.test.ts +91 -0
  59. package/src/helpers/client-details.ts +1 -1
  60. package/src/helpers/firstVideoFrame.ts +38 -0
  61. package/src/reporting/ClientEventReporter.ts +864 -0
  62. package/src/reporting/__tests__/ClientEventReporter.test.ts +620 -0
  63. package/src/reporting/index.ts +1 -0
  64. package/src/rtc/BasePeerConnection.ts +30 -0
  65. package/src/rtc/Publisher.ts +0 -4
  66. package/src/rtc/Subscriber.ts +2 -28
  67. package/src/rtc/__tests__/Call.reconnect.test.ts +3 -0
  68. package/src/rtc/types.ts +34 -0
  69. package/src/types.ts +18 -0
@@ -0,0 +1,620 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { fromPartial } from '@total-typescript/shoehorn';
3
+ import { of } from 'rxjs';
4
+ import { ClientEventReporter, CallReportContext } from '../ClientEventReporter';
5
+ import type { StreamClient } from '../../coordinator/connection/client';
6
+ import { ErrorFromResponse } from '../../coordinator/connection/types';
7
+ import { SfuTimeoutError } from '../../errors';
8
+ import type { AxiosResponse } from 'axios';
9
+ import { PeerType, TrackType } from '../../gen/video/sfu/models/models';
10
+
11
+ vi.mock('../../devices', () => ({
12
+ getAudioBrowserPermission: () => ({ asStateObservable: () => of('granted') }),
13
+ getVideoBrowserPermission: () => ({ asStateObservable: () => of('granted') }),
14
+ }));
15
+
16
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
17
+
18
+ describe('ClientEventReporter', () => {
19
+ const cid = 'default:call-1';
20
+ let doAxiosRequest: ReturnType<typeof vi.fn>;
21
+ let reporter: ClientEventReporter;
22
+ let connectId: string;
23
+
24
+ const postedEvents = (): Array<Record<string, any>> =>
25
+ doAxiosRequest.mock.calls.map((call) => call[2].events[0]);
26
+
27
+ beforeEach(() => {
28
+ doAxiosRequest = vi.fn().mockResolvedValue({});
29
+ const streamClient = fromPartial<StreamClient>({
30
+ userID: 'user-1',
31
+ doAxiosRequest,
32
+ getUserAgent: () => 'test-agent',
33
+ getSdkVersion: () => '1.0.0',
34
+ });
35
+ reporter = new ClientEventReporter({ streamClient });
36
+ connectId = reporter.startCoordinatorConnection('user-1');
37
+
38
+ const ctx: CallReportContext = {
39
+ callType: 'default',
40
+ callId: 'call-1',
41
+ getCallSessionId: () => 'session-1',
42
+ getSfuId: () => 'sfu-1',
43
+ getUserSessionId: () => 'user-session-1',
44
+ };
45
+ reporter.registerCall(cid, ctx);
46
+ });
47
+
48
+ it('emits an initiated then a completed event on success', async () => {
49
+ reporter.startCorrelation(cid, 'first-attempt');
50
+ await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok'));
51
+ await flush();
52
+
53
+ const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
54
+ expect(events).toHaveLength(2);
55
+ expect(events[0]).toMatchObject({
56
+ event_type: 'initiated',
57
+ call_cid: cid,
58
+ user_id: 'user-1',
59
+ coordinator_connect_id: connectId,
60
+ join_reason: 'first-attempt',
61
+ });
62
+ expect(events[1]).toMatchObject({
63
+ event_type: 'completed',
64
+ outcome: 'success',
65
+ retry_count_attempt: 0,
66
+ join_reason: 'first-attempt',
67
+ });
68
+
69
+ expect(events[0].stage_id).toBe(events[1].stage_id);
70
+ expect(events[0].join_attempt_id).toBeTruthy();
71
+ });
72
+
73
+ it('carries the join_reason given to startCorrelation', async () => {
74
+ reporter.startCorrelation(cid, 'migration');
75
+ await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok'));
76
+ await flush();
77
+
78
+ const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
79
+ expect(events).toHaveLength(2);
80
+ expect(events[0]).toMatchObject({ join_reason: 'migration' });
81
+ expect(events[1]).toMatchObject({ join_reason: 'migration' });
82
+ });
83
+
84
+ it('folds in-stage retries into a single pair', async () => {
85
+ reporter.startCorrelation(cid, 'first-attempt');
86
+ await expect(
87
+ reporter.track(cid, 'CoordinatorJoin', () =>
88
+ Promise.reject(new Error('boom')),
89
+ ),
90
+ ).rejects.toThrow('boom');
91
+ await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok'));
92
+ await flush();
93
+
94
+ const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
95
+ const initiated = events.filter((e) => e.event_type === 'initiated');
96
+ const completed = events.filter((e) => e.event_type === 'completed');
97
+
98
+ expect(initiated).toHaveLength(1);
99
+ expect(completed).toHaveLength(1);
100
+ expect(completed[0]).toMatchObject({
101
+ outcome: 'success',
102
+ retry_count_attempt: 1,
103
+ });
104
+ });
105
+
106
+ it('reports the latest error when folded retries fail differently', async () => {
107
+ reporter.startCorrelation(cid, 'first-attempt');
108
+ const attempt = (err: Error) =>
109
+ expect(
110
+ reporter.track(cid, 'CoordinatorJoin', () => Promise.reject(err)),
111
+ ).rejects.toThrow();
112
+
113
+ await attempt(new Error('error A'));
114
+ await attempt(new Error('error B'));
115
+ await attempt(new Error('error B'));
116
+ reporter.close(cid);
117
+ await flush();
118
+
119
+ const completed = postedEvents().filter(
120
+ (e) => e.stage === 'CoordinatorJoin' && e.event_type === 'completed',
121
+ );
122
+ expect(completed).toHaveLength(1);
123
+ expect(completed[0]).toMatchObject({
124
+ outcome: 'failure',
125
+ retry_count_attempt: 2,
126
+ retry_failure_reason: 'error B',
127
+ });
128
+ });
129
+
130
+ it('emits a failure completion on abort', async () => {
131
+ reporter.startCorrelation(cid, 'first-attempt');
132
+ void reporter.track(cid, 'CoordinatorJoin', () => new Promise(() => {}));
133
+ reporter.abort(cid, { code: 'CLIENT_ABORTED', reason: 'user left' });
134
+ await flush();
135
+
136
+ const completed = postedEvents().filter(
137
+ (e) => e.stage === 'CoordinatorJoin' && e.event_type === 'completed',
138
+ );
139
+
140
+ expect(completed).toHaveLength(1);
141
+ expect(completed[0]).toMatchObject({
142
+ outcome: 'failure',
143
+ retry_failure_code: 'CLIENT_ABORTED',
144
+ retry_failure_reason: 'user left',
145
+ });
146
+ });
147
+
148
+ it('emits a JoinInitiated event when correlation starts', async () => {
149
+ reporter.startCorrelation(cid, 'first-attempt');
150
+ await flush();
151
+
152
+ const join = postedEvents().filter((e) => e.stage === 'JoinInitiated');
153
+ expect(join).toHaveLength(1);
154
+ expect(join[0]).toMatchObject({
155
+ event_type: 'initiated',
156
+ coordinator_connect_id: connectId,
157
+ });
158
+ expect(join[0].join_attempt_id).toBeTruthy();
159
+ });
160
+
161
+ it('does not retry events rejected with a 4xx', async () => {
162
+ doAxiosRequest.mockRejectedValue({ response: { status: 400 } });
163
+ reporter.reportFirstFrame(cid, TrackType.VIDEO, 'track-1');
164
+ await flush();
165
+
166
+ expect(doAxiosRequest).toHaveBeenCalledTimes(1);
167
+ });
168
+
169
+ it('tracks the coordinator websocket connection', async () => {
170
+ await reporter.trackCoordinatorWs(() => Promise.resolve('ok'));
171
+ await flush();
172
+
173
+ const events = postedEvents().filter((e) => e.stage === 'CoordinatorWS');
174
+ expect(events).toHaveLength(2);
175
+ expect(events[0]).toMatchObject({
176
+ event_type: 'initiated',
177
+ coordinator_connect_id: connectId,
178
+ });
179
+ expect(events[1]).toMatchObject({
180
+ event_type: 'completed',
181
+ outcome: 'success',
182
+ });
183
+ });
184
+
185
+ it('reports a failed coordinator websocket connection on close', async () => {
186
+ await expect(
187
+ reporter.trackCoordinatorWs(() => Promise.reject(new Error('ws down'))),
188
+ ).rejects.toThrow('ws down');
189
+ reporter.closeCoordinatorWs();
190
+ await flush();
191
+
192
+ const completed = postedEvents().filter(
193
+ (e) => e.stage === 'CoordinatorWS' && e.event_type === 'completed',
194
+ );
195
+ expect(completed).toHaveLength(1);
196
+ expect(completed[0]).toMatchObject({
197
+ outcome: 'failure',
198
+ retry_failure_code: 'SERVER_ERROR',
199
+ retry_failure_reason: 'ws down',
200
+ });
201
+ });
202
+
203
+ it('reports an API-rejected websocket connection with the backend code', async () => {
204
+ const rejection = new Error(
205
+ JSON.stringify({
206
+ code: 41,
207
+ StatusCode: 401,
208
+ message: 'bad token',
209
+ isWSFailure: false,
210
+ }),
211
+ );
212
+ await expect(
213
+ reporter.trackCoordinatorWs(() => Promise.reject(rejection)),
214
+ ).rejects.toThrow();
215
+ reporter.closeCoordinatorWs();
216
+ await flush();
217
+
218
+ const completed = postedEvents().filter(
219
+ (e) => e.stage === 'CoordinatorWS' && e.event_type === 'completed',
220
+ );
221
+ expect(completed).toHaveLength(1);
222
+ expect(completed[0]).toMatchObject({
223
+ outcome: 'failure',
224
+ retry_failure_code: '41',
225
+ retry_failure_reason: 'bad token',
226
+ });
227
+ });
228
+
229
+ it('reports a transient websocket failure with the default code', async () => {
230
+ const failure = new Error(
231
+ JSON.stringify({
232
+ code: '',
233
+ StatusCode: '',
234
+ message: 'initial WS connection could not be established',
235
+ isWSFailure: true,
236
+ }),
237
+ );
238
+ await expect(
239
+ reporter.trackCoordinatorWs(() => Promise.reject(failure)),
240
+ ).rejects.toThrow();
241
+ reporter.closeCoordinatorWs();
242
+ await flush();
243
+
244
+ const completed = postedEvents().filter(
245
+ (e) => e.stage === 'CoordinatorWS' && e.event_type === 'completed',
246
+ );
247
+ expect(completed).toHaveLength(1);
248
+ expect(completed[0]).toMatchObject({
249
+ outcome: 'failure',
250
+ retry_failure_code: 'SERVER_ERROR',
251
+ retry_failure_reason: 'initial WS connection could not be established',
252
+ });
253
+ });
254
+
255
+ it('forwards the backend error code and status on a server error', async () => {
256
+ const err = new ErrorFromResponse({
257
+ message: 'server boom',
258
+ code: 16,
259
+ status: 500,
260
+ response: fromPartial<AxiosResponse>({}),
261
+ unrecoverable: false,
262
+ });
263
+ await expect(
264
+ reporter.trackCoordinatorWs(() => Promise.reject(err)),
265
+ ).rejects.toBe(err);
266
+ reporter.closeCoordinatorWs();
267
+ await flush();
268
+
269
+ const completed = postedEvents().filter(
270
+ (e) => e.stage === 'CoordinatorWS' && e.event_type === 'completed',
271
+ );
272
+ expect(completed[0]).toMatchObject({
273
+ outcome: 'failure',
274
+ retry_failure_code: '16',
275
+ retry_failure_reason: 'server boom',
276
+ });
277
+ });
278
+
279
+ it('includes sfu_id and call_session_id on a WSJoin completion', async () => {
280
+ reporter.startCorrelation(cid, 'first-attempt');
281
+ await reporter.track(cid, 'WSJoin', () => Promise.resolve('ok'));
282
+ await flush();
283
+
284
+ const events = postedEvents().filter((e) => e.stage === 'WSJoin');
285
+ expect(events).toHaveLength(2);
286
+ expect(events[1]).toMatchObject({
287
+ event_type: 'completed',
288
+ outcome: 'success',
289
+ sfu_id: 'sfu-1',
290
+ call_session_id: 'session-1',
291
+ });
292
+ });
293
+
294
+ it('folds in-stage WSJoin retries into a single pair', async () => {
295
+ reporter.startCorrelation(cid, 'first-attempt');
296
+ await expect(
297
+ reporter.track(cid, 'WSJoin', () => Promise.reject(new Error('boom'))),
298
+ ).rejects.toThrow('boom');
299
+ await reporter.track(cid, 'WSJoin', () => Promise.resolve('ok'));
300
+ await flush();
301
+
302
+ const events = postedEvents().filter((e) => e.stage === 'WSJoin');
303
+ const initiated = events.filter((e) => e.event_type === 'initiated');
304
+ const completed = events.filter((e) => e.event_type === 'completed');
305
+
306
+ expect(initiated).toHaveLength(1);
307
+ expect(completed).toHaveLength(1);
308
+ expect(completed[0]).toMatchObject({
309
+ outcome: 'success',
310
+ retry_count_attempt: 1,
311
+ });
312
+ });
313
+
314
+ it('reports a WSJoin failure with the captured SFU error', async () => {
315
+ reporter.startCorrelation(cid, 'first-attempt');
316
+ void reporter.track(cid, 'WSJoin', () => new Promise(() => {}));
317
+ reporter.captureWsError(cid, { code: 'SFU_ERROR', reason: 'sfu closed' });
318
+ reporter.close(cid);
319
+ await flush();
320
+
321
+ const completed = postedEvents().filter(
322
+ (e) => e.stage === 'WSJoin' && e.event_type === 'completed',
323
+ );
324
+ expect(completed).toHaveLength(1);
325
+ expect(completed[0]).toMatchObject({
326
+ outcome: 'failure',
327
+ retry_failure_code: 'SFU_ERROR',
328
+ retry_failure_reason: 'sfu closed',
329
+ });
330
+ });
331
+
332
+ it('keeps the captured SFU error over CLIENT_ABORTED on abort', async () => {
333
+ reporter.startCorrelation(cid, 'first-attempt');
334
+ void reporter.track(cid, 'WSJoin', () => new Promise(() => {}));
335
+ reporter.captureWsError(cid, {
336
+ code: 'SFU_ERROR',
337
+ reason: 'sfu disconnect',
338
+ });
339
+ reporter.abort(cid, {
340
+ code: 'CLIENT_ABORTED',
341
+ reason: 'SFU instructed to disconnect',
342
+ });
343
+ await flush();
344
+
345
+ const completed = postedEvents().filter(
346
+ (e) => e.stage === 'WSJoin' && e.event_type === 'completed',
347
+ );
348
+ expect(completed).toHaveLength(1);
349
+ expect(completed[0]).toMatchObject({
350
+ outcome: 'failure',
351
+ retry_failure_code: 'SFU_ERROR',
352
+ retry_failure_reason: 'sfu disconnect',
353
+ });
354
+ });
355
+
356
+ it('keeps a captured SFU error over a later WSJoin timeout', async () => {
357
+ reporter.startCorrelation(cid, 'first-attempt');
358
+ await expect(
359
+ reporter.track(cid, 'WSJoin', async () => {
360
+ reporter.captureWsError(cid, {
361
+ code: 'SFU_ERROR',
362
+ reason: 'unauthenticated',
363
+ });
364
+ throw new SfuTimeoutError(
365
+ 'SFU WS connection failed to open after 5000ms',
366
+ );
367
+ }),
368
+ ).rejects.toThrow();
369
+ reporter.close(cid);
370
+ await flush();
371
+
372
+ const completed = postedEvents().filter(
373
+ (e) => e.stage === 'WSJoin' && e.event_type === 'completed',
374
+ );
375
+ expect(completed).toHaveLength(1);
376
+ expect(completed[0]).toMatchObject({
377
+ outcome: 'failure',
378
+ retry_failure_code: 'SFU_ERROR',
379
+ retry_failure_reason: 'unauthenticated',
380
+ });
381
+ });
382
+
383
+ it('lets a later WSJoin attempt override a previous attempt captured error', async () => {
384
+ reporter.startCorrelation(cid, 'first-attempt');
385
+ await expect(
386
+ reporter.track(cid, 'WSJoin', async () => {
387
+ reporter.captureWsError(cid, {
388
+ code: 'SFU_ERROR',
389
+ reason: 'attempt 1 sfu error',
390
+ });
391
+ throw new SfuTimeoutError('attempt 1 timeout');
392
+ }),
393
+ ).rejects.toThrow();
394
+ await expect(
395
+ reporter.track(cid, 'WSJoin', () =>
396
+ Promise.reject(new SfuTimeoutError('attempt 2 timeout')),
397
+ ),
398
+ ).rejects.toThrow();
399
+ reporter.close(cid);
400
+ await flush();
401
+
402
+ const completed = postedEvents().filter(
403
+ (e) => e.stage === 'WSJoin' && e.event_type === 'completed',
404
+ );
405
+ expect(completed).toHaveLength(1);
406
+ expect(completed[0]).toMatchObject({
407
+ outcome: 'failure',
408
+ retry_count_attempt: 1,
409
+ retry_failure_code: 'REQUEST_TIMEOUT',
410
+ retry_failure_reason: 'attempt 2 timeout',
411
+ });
412
+ });
413
+
414
+ it('reports a WSJoin timeout as REQUEST_TIMEOUT', async () => {
415
+ reporter.startCorrelation(cid, 'first-attempt');
416
+ await expect(
417
+ reporter.track(cid, 'WSJoin', () =>
418
+ Promise.reject(
419
+ new SfuTimeoutError('SFU WS connection failed to open after 5000ms'),
420
+ ),
421
+ ),
422
+ ).rejects.toThrow();
423
+ reporter.close(cid);
424
+ await flush();
425
+
426
+ const completed = postedEvents().filter(
427
+ (e) => e.stage === 'WSJoin' && e.event_type === 'completed',
428
+ );
429
+ expect(completed).toHaveLength(1);
430
+ expect(completed[0]).toMatchObject({
431
+ outcome: 'failure',
432
+ retry_failure_code: 'REQUEST_TIMEOUT',
433
+ });
434
+ });
435
+
436
+ it('reports media device permission status on correlation start', async () => {
437
+ reporter.startCorrelation(cid, 'first-attempt');
438
+ await flush();
439
+
440
+ const perm = postedEvents().filter(
441
+ (e) => e.stage === 'MediaDevicePermission',
442
+ );
443
+ expect(perm).toHaveLength(1);
444
+ expect(perm[0]).toMatchObject({
445
+ event_type: 'initiated',
446
+ microphone_permission_status: 'GRANTED',
447
+ camera_permission_status: 'GRANTED',
448
+ });
449
+ });
450
+
451
+ it('reports the first video frame only once', async () => {
452
+ reporter.startCorrelation(cid, 'first-attempt');
453
+ reporter.reportFirstFrame(cid, TrackType.VIDEO, 'track-1');
454
+ reporter.reportFirstFrame(cid, TrackType.VIDEO, 'track-1');
455
+ await flush();
456
+
457
+ const frames = postedEvents().filter((e) => e.stage === 'FirstVideoFrame');
458
+ expect(frames).toHaveLength(1);
459
+ expect(frames[0]).toMatchObject({
460
+ event_type: 'initiated',
461
+ track_id: 'track-1',
462
+ sfu_id: 'sfu-1',
463
+ });
464
+ });
465
+
466
+ it('tracks a publisher peer connection connect', async () => {
467
+ reporter.startCorrelation(cid, 'first-attempt');
468
+ reporter.onPeerConnectionStateChange(cid, {
469
+ peerType: PeerType.PUBLISHER_UNSPECIFIED,
470
+ stateType: 'peerConnection',
471
+ state: 'connecting',
472
+ });
473
+ reporter.onPeerConnectionStateChange(cid, {
474
+ peerType: PeerType.PUBLISHER_UNSPECIFIED,
475
+ stateType: 'peerConnection',
476
+ state: 'connected',
477
+ });
478
+ await flush();
479
+
480
+ const events = postedEvents().filter(
481
+ (e) => e.stage === 'PeerConnectionConnect',
482
+ );
483
+ expect(events).toHaveLength(2);
484
+ expect(events[0]).toMatchObject({
485
+ event_type: 'initiated',
486
+ peer_connection: 'publish',
487
+ was_previously_connected: false,
488
+ sfu_id: 'sfu-1',
489
+ user_session_id: 'user-session-1',
490
+ });
491
+ expect(events[1]).toMatchObject({
492
+ event_type: 'completed',
493
+ outcome: 'success',
494
+ peer_connection: 'publish',
495
+ });
496
+ });
497
+
498
+ it('reports an ICE failure on the peer connection', async () => {
499
+ reporter.startCorrelation(cid, 'first-attempt');
500
+ reporter.onPeerConnectionStateChange(cid, {
501
+ peerType: PeerType.SUBSCRIBER,
502
+ stateType: 'peerConnection',
503
+ state: 'connecting',
504
+ });
505
+ reporter.onPeerConnectionStateChange(cid, {
506
+ peerType: PeerType.SUBSCRIBER,
507
+ stateType: 'ice',
508
+ state: 'failed',
509
+ });
510
+ await flush();
511
+
512
+ const completed = postedEvents().filter(
513
+ (e) =>
514
+ e.stage === 'PeerConnectionConnect' && e.event_type === 'completed',
515
+ );
516
+ expect(completed).toHaveLength(1);
517
+ expect(completed[0]).toMatchObject({
518
+ outcome: 'failure',
519
+ peer_connection: 'subscribe',
520
+ retry_failure_code: 'ICE_CONNECTIVITY_FAILED',
521
+ ice_state: 'FAILED',
522
+ });
523
+ });
524
+
525
+ it('opens a fresh peer connection pair after a new correlation', async () => {
526
+ reporter.startCorrelation(cid, 'first-attempt');
527
+ reporter.onPeerConnectionStateChange(cid, {
528
+ peerType: PeerType.PUBLISHER_UNSPECIFIED,
529
+ stateType: 'peerConnection',
530
+ state: 'connecting',
531
+ });
532
+
533
+ reporter.startCorrelation(cid, 'full-rejoin');
534
+ reporter.onPeerConnectionStateChange(cid, {
535
+ peerType: PeerType.PUBLISHER_UNSPECIFIED,
536
+ stateType: 'peerConnection',
537
+ state: 'connecting',
538
+ });
539
+ reporter.onPeerConnectionStateChange(cid, {
540
+ peerType: PeerType.PUBLISHER_UNSPECIFIED,
541
+ stateType: 'peerConnection',
542
+ state: 'connected',
543
+ });
544
+ await flush();
545
+
546
+ const pc = postedEvents().filter(
547
+ (e) => e.stage === 'PeerConnectionConnect',
548
+ );
549
+ const initiated = pc.filter((e) => e.event_type === 'initiated');
550
+ const completed = pc.filter((e) => e.event_type === 'completed');
551
+
552
+ expect(initiated).toHaveLength(2);
553
+ // the pair superseded by the new correlation closes with a failure,
554
+ // the fresh pair closes with a success
555
+ expect(completed).toHaveLength(2);
556
+ expect(completed[0]).toMatchObject({
557
+ outcome: 'failure',
558
+ retry_failure_code: 'CLIENT_ABORTED',
559
+ retry_failure_reason: 'superseded by a new join attempt',
560
+ });
561
+ expect(completed[0].stage_id).toBe(initiated[0].stage_id);
562
+ expect(completed[1]).toMatchObject({ outcome: 'success' });
563
+ expect(completed[1].stage_id).toBe(initiated[1].stage_id);
564
+ });
565
+
566
+ it('marks a peer connection as previously connected on reconnect', async () => {
567
+ reporter.startCorrelation(cid, 'first-attempt');
568
+ const connect = () => {
569
+ reporter.onPeerConnectionStateChange(cid, {
570
+ peerType: PeerType.PUBLISHER_UNSPECIFIED,
571
+ stateType: 'peerConnection',
572
+ state: 'connecting',
573
+ });
574
+ reporter.onPeerConnectionStateChange(cid, {
575
+ peerType: PeerType.PUBLISHER_UNSPECIFIED,
576
+ stateType: 'peerConnection',
577
+ state: 'connected',
578
+ });
579
+ };
580
+ connect();
581
+ connect();
582
+ await flush();
583
+
584
+ const initiated = postedEvents().filter(
585
+ (e) =>
586
+ e.stage === 'PeerConnectionConnect' && e.event_type === 'initiated',
587
+ );
588
+ expect(initiated).toHaveLength(2);
589
+ expect(initiated[0]).toMatchObject({ was_previously_connected: false });
590
+ expect(initiated[1]).toMatchObject({ was_previously_connected: true });
591
+ });
592
+
593
+ it('keeps reporting state isolated between concurrent calls', async () => {
594
+ const cid2 = 'default:call-2';
595
+ reporter.registerCall(cid2, {
596
+ callType: 'default',
597
+ callId: 'call-2',
598
+ getCallSessionId: () => 'session-2',
599
+ getSfuId: () => 'sfu-2',
600
+ getUserSessionId: () => 'user-session-2',
601
+ });
602
+
603
+ reporter.startCorrelation(cid, 'first-attempt');
604
+ reporter.startCorrelation(cid2, 'first-attempt');
605
+ await reporter.track(cid, 'WSJoin', () => Promise.resolve('ok'));
606
+ await reporter.track(cid2, 'WSJoin', () => Promise.resolve('ok'));
607
+ await flush();
608
+
609
+ const ws1 = postedEvents().filter(
610
+ (e) => e.stage === 'WSJoin' && e.call_cid === cid,
611
+ );
612
+ const ws2 = postedEvents().filter(
613
+ (e) => e.stage === 'WSJoin' && e.call_cid === cid2,
614
+ );
615
+ expect(ws1).toHaveLength(2);
616
+ expect(ws2).toHaveLength(2);
617
+ expect(ws1.every((e) => e.sfu_id === 'sfu-1')).toBe(true);
618
+ expect(ws2.every((e) => e.sfu_id === 'sfu-2')).toBe(true);
619
+ });
620
+ });
@@ -0,0 +1 @@
1
+ export * from './ClientEventReporter';
@@ -16,7 +16,9 @@ import { StatsTracer, Tracer, traceRTCPeerConnection } from '../stats';
16
16
  import {
17
17
  BasePeerConnectionOpts,
18
18
  OnIceConnected,
19
+ OnPeerConnectionStateChange,
19
20
  OnReconnectionNeeded,
21
+ OnRemoteTrackUnmute,
20
22
  ReconnectReason,
21
23
  } from './types';
22
24
  import type { ClientPublishOptions } from '../types';
@@ -37,6 +39,8 @@ export abstract class BasePeerConnection {
37
39
 
38
40
  private onReconnectionNeeded?: OnReconnectionNeeded;
39
41
  private onIceConnected?: OnIceConnected;
42
+ private onPeerConnectionStateChange?: OnPeerConnectionStateChange;
43
+ protected onRemoteTrackUnmute?: OnRemoteTrackUnmute;
40
44
  private readonly iceRestartDelay: number;
41
45
  private iceHasEverConnected = false;
42
46
  private iceRestartTimeout?: NodeJS.Timeout;
@@ -65,6 +69,8 @@ export abstract class BasePeerConnection {
65
69
  dispatcher,
66
70
  onReconnectionNeeded,
67
71
  onIceConnected,
72
+ onPeerConnectionStateChange,
73
+ onRemoteTrackUnmute,
68
74
  tag,
69
75
  enableTracing,
70
76
  clientPublishOptions,
@@ -81,6 +87,8 @@ export abstract class BasePeerConnection {
81
87
  this.tag = tag;
82
88
  this.onReconnectionNeeded = onReconnectionNeeded;
83
89
  this.onIceConnected = onIceConnected;
90
+ this.onPeerConnectionStateChange = onPeerConnectionStateChange;
91
+ this.onRemoteTrackUnmute = onRemoteTrackUnmute;
84
92
  this.logger = videoLoggerSystem.getLogger(
85
93
  peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher',
86
94
  { tags: [tag] },
@@ -128,6 +136,8 @@ export abstract class BasePeerConnection {
128
136
  this.preConnectStuckTimeout = undefined;
129
137
  this.onReconnectionNeeded = undefined;
130
138
  this.onIceConnected = undefined;
139
+ this.onPeerConnectionStateChange = undefined;
140
+ this.onRemoteTrackUnmute = undefined;
131
141
  this.isDisposed = true;
132
142
  this.detachEventHandlers();
133
143
  this.pc.close();
@@ -327,6 +337,10 @@ export abstract class BasePeerConnection {
327
337
  private onConnectionStateChange = async () => {
328
338
  const state = this.pc.connectionState;
329
339
  this.logger.debug(`Connection state changed`, state);
340
+ this.fireOnPeerConnectionStateChange({
341
+ stateType: 'peerConnection',
342
+ state,
343
+ });
330
344
  if (this.tracer && (state === 'connected' || state === 'failed')) {
331
345
  try {
332
346
  const stats = await this.stats.get();
@@ -355,9 +369,25 @@ export abstract class BasePeerConnection {
355
369
  private onIceConnectionStateChange = () => {
356
370
  const state = this.pc.iceConnectionState;
357
371
  this.logger.debug(`ICE connection state changed`, state);
372
+ this.fireOnPeerConnectionStateChange({ stateType: 'ice', state });
358
373
  this.handleConnectionStateUpdate(state);
359
374
  };
360
375
 
376
+ private fireOnPeerConnectionStateChange = (
377
+ event:
378
+ | { stateType: 'ice'; state: RTCIceConnectionState }
379
+ | { stateType: 'peerConnection'; state: RTCPeerConnectionState },
380
+ ) => {
381
+ try {
382
+ this.onPeerConnectionStateChange?.({
383
+ peerType: this.peerType,
384
+ ...event,
385
+ });
386
+ } catch (err) {
387
+ this.logger.warn('onPeerConnectionStateChange listener threw', err);
388
+ }
389
+ };
390
+
361
391
  private handleConnectionStateUpdate = (
362
392
  state: RTCIceConnectionState | RTCPeerConnectionState,
363
393
  ) => {