@stream-io/video-client 1.52.1-beta.0 → 1.53.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 (67) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/index.browser.es.js +801 -123
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +801 -122
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +801 -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/errors/SfuTimeoutError.d.ts +8 -0
  12. package/dist/src/errors/index.d.ts +1 -0
  13. package/dist/src/gen/google/protobuf/struct.d.ts +1 -3
  14. package/dist/src/gen/google/protobuf/timestamp.d.ts +1 -3
  15. package/dist/src/gen/video/sfu/event/events.d.ts +1 -22
  16. package/dist/src/gen/video/sfu/models/models.d.ts +0 -4
  17. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +2 -23
  18. package/dist/src/helpers/firstVideoFrame.d.ts +7 -0
  19. package/dist/src/reporting/ClientEventReporter.d.ts +84 -0
  20. package/dist/src/reporting/index.d.ts +1 -0
  21. package/dist/src/rtc/BasePeerConnection.d.ts +5 -2
  22. package/dist/src/rtc/Publisher.d.ts +1 -4
  23. package/dist/src/rtc/Subscriber.d.ts +0 -7
  24. package/dist/src/rtc/types.d.ts +24 -1
  25. package/dist/src/types.d.ts +5 -0
  26. package/package.json +1 -1
  27. package/src/Call.ts +185 -106
  28. package/src/StreamSfuClient.ts +3 -3
  29. package/src/StreamVideoClient.ts +18 -3
  30. package/src/__tests__/Call.autodrop.test.ts +4 -1
  31. package/src/__tests__/Call.lifecycle.test.ts +4 -1
  32. package/src/__tests__/Call.publishing.test.ts +4 -1
  33. package/src/__tests__/Call.test.ts +23 -0
  34. package/src/coordinator/connection/client.ts +5 -0
  35. package/src/devices/__tests__/CameraManager.test.ts +10 -1
  36. package/src/devices/__tests__/DeviceManager.test.ts +10 -1
  37. package/src/devices/__tests__/DeviceManagerFilters.test.ts +4 -1
  38. package/src/devices/__tests__/MicrophoneManager.test.ts +10 -1
  39. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +4 -1
  40. package/src/devices/__tests__/ScreenShareManager.test.ts +4 -1
  41. package/src/devices/__tests__/SpeakerManager.test.ts +13 -4
  42. package/src/errors/SfuTimeoutError.ts +7 -0
  43. package/src/errors/index.ts +1 -0
  44. package/src/events/__tests__/call.test.ts +2 -0
  45. package/src/events/__tests__/mutes.test.ts +4 -1
  46. package/src/events/call.ts +8 -0
  47. package/src/gen/google/protobuf/struct.ts +12 -7
  48. package/src/gen/google/protobuf/timestamp.ts +7 -6
  49. package/src/gen/video/sfu/event/events.ts +25 -23
  50. package/src/gen/video/sfu/models/models.ts +1 -11
  51. package/src/gen/video/sfu/signal_rpc/signal.client.ts +29 -25
  52. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -1
  53. package/src/helpers/__tests__/AudioBindingsWatchdog.test.ts +6 -3
  54. package/src/helpers/__tests__/DynascaleManager.test.ts +6 -3
  55. package/src/helpers/__tests__/ViewportTracker.test.ts +6 -3
  56. package/src/helpers/__tests__/firstVideoFrame.test.ts +91 -0
  57. package/src/helpers/client-details.ts +1 -1
  58. package/src/helpers/firstVideoFrame.ts +38 -0
  59. package/src/reporting/ClientEventReporter.ts +859 -0
  60. package/src/reporting/__tests__/ClientEventReporter.test.ts +620 -0
  61. package/src/reporting/index.ts +1 -0
  62. package/src/rtc/BasePeerConnection.ts +30 -0
  63. package/src/rtc/Publisher.ts +0 -4
  64. package/src/rtc/Subscriber.ts +2 -28
  65. package/src/rtc/__tests__/Call.reconnect.test.ts +3 -0
  66. package/src/rtc/types.ts +34 -0
  67. package/src/types.ts +6 -0
@@ -0,0 +1,859 @@
1
+ import { ErrorCode, PeerType, TrackType } from '../gen/video/sfu/models/models';
2
+ import { ErrorFromResponse } from '../coordinator/connection/types';
3
+ import type { StreamClient } from '../coordinator/connection/client';
4
+ import {
5
+ generateUUIDv4,
6
+ retryInterval,
7
+ sleep,
8
+ } from '../coordinator/connection/utils';
9
+ import { SfuJoinError, SfuTimeoutError } from '../errors';
10
+ import { isReactNative } from '../helpers/platforms';
11
+ import { videoLoggerSystem } from '../logger';
12
+ import type { PeerConnectionStateChangeEvent } from '../rtc';
13
+ import {
14
+ getAudioBrowserPermission,
15
+ getVideoBrowserPermission,
16
+ } from '../devices';
17
+ import type {
18
+ BrowserPermission,
19
+ BrowserPermissionState,
20
+ } from '../devices/BrowserPermission';
21
+ import { getCurrentValue } from '../store/rxUtils';
22
+
23
+ export type ClientEventPeerConnection = 'publish' | 'subscribe';
24
+
25
+ export type ClientEventStage =
26
+ | 'JoinInitiated'
27
+ | 'CoordinatorWS'
28
+ | 'MediaDevicePermission'
29
+ | 'CoordinatorJoin'
30
+ | 'WSJoin'
31
+ | 'PeerConnectionConnect'
32
+ | 'FirstVideoFrame'
33
+ | 'FirstAudioFrame';
34
+
35
+ export type MediaPermissionState =
36
+ | 'INITIATED'
37
+ | 'FAILED'
38
+ | 'GRANTED'
39
+ | 'NOT_INITIATED';
40
+
41
+ export type JoinReason =
42
+ | 'first-attempt'
43
+ | 'network-available'
44
+ | 'migration'
45
+ | 'full-rejoin';
46
+
47
+ export type ClientEventStandardCode =
48
+ | 'CLIENT_ABORTED'
49
+ | 'BACKEND_LEAVE'
50
+ | 'REQUEST_TIMEOUT'
51
+ | 'NETWORK_ERROR'
52
+ | 'SFU_ERROR'
53
+ | 'SFU_GO_AWAY'
54
+ | 'ICE_CONNECTIVITY_FAILED'
55
+ | 'DTLS_CONNECTIVITY_FAILED';
56
+
57
+ export type CallReportContext = {
58
+ callType: string;
59
+ callId: string;
60
+ getSfuId: () => string;
61
+ getCallSessionId: () => string;
62
+ getUserSessionId: () => string;
63
+ };
64
+
65
+ export type ClientEventReporterOptions = {
66
+ streamClient: StreamClient;
67
+ };
68
+
69
+ type StageError = {
70
+ reason: string;
71
+ code: string;
72
+ };
73
+
74
+ type StagePairState = {
75
+ sid: string;
76
+ attempts: number;
77
+ startedAt: number;
78
+ joinAttemptIdSnapshot?: string;
79
+ joinReasonSnapshot?: JoinReason;
80
+ userIdSnapshot?: string;
81
+ lastError?: StageError;
82
+ };
83
+
84
+ type PeerConnectionPairState = StagePairState & {
85
+ sfuId: string;
86
+ userSessionId: string;
87
+ wasPreviouslyConnected: boolean;
88
+ };
89
+
90
+ const pcKey = (cid: string, role: ClientEventPeerConnection): string =>
91
+ `${cid}:${role}`;
92
+
93
+ export class ClientEventReporter {
94
+ private readonly logger = videoLoggerSystem.getLogger('ClientEventReporter');
95
+
96
+ private streamClient: StreamClient;
97
+
98
+ private coordinatorConnectId?: string;
99
+ private coordinatorConnectUserId?: string;
100
+ private coordinatorWsPair?: StagePairState;
101
+
102
+ private callContexts = new Map<string, CallReportContext>();
103
+ private joinAttemptIds = new Map<string, string>();
104
+ private joinReasons = new Map<string, JoinReason>();
105
+ private coordinatorPairs = new Map<string, StagePairState>();
106
+ private wsPairs = new Map<string, StagePairState>();
107
+
108
+ private peerConnectionPairs = new Map<string, PeerConnectionPairState>();
109
+ private pcEverConnected = new Map<string, boolean>();
110
+
111
+ private firstFrameReported = new Set<string>();
112
+
113
+ constructor(options: ClientEventReporterOptions) {
114
+ this.streamClient = options.streamClient;
115
+ }
116
+
117
+ /**
118
+ * Starts a new coordinator connection correlation scope.
119
+ *
120
+ * @param userId the id of the user being connected. Captured here because
121
+ * the `CoordinatorWS` stage emits before the connection flow assigns
122
+ * the user to the client, so it can't be read from the client yet.
123
+ */
124
+ startCoordinatorConnection = (userId?: string): string => {
125
+ this.coordinatorConnectId = generateUUIDv4();
126
+ this.coordinatorConnectUserId = userId;
127
+ return this.coordinatorConnectId;
128
+ };
129
+
130
+ trackCoordinatorWs = async <T>(op: () => Promise<T>): Promise<T> => {
131
+ this.beginCoordinatorWs();
132
+ try {
133
+ const result = await op();
134
+ this.succeedCoordinatorWs();
135
+
136
+ return result;
137
+ } catch (err) {
138
+ applyError(this.coordinatorWsPair, mapCoordinatorWsError(err));
139
+ throw err;
140
+ }
141
+ };
142
+
143
+ private beginCoordinatorWs = () => {
144
+ if (!this.coordinatorWsPair) {
145
+ this.coordinatorWsPair = {
146
+ sid: generateUUIDv4(),
147
+ attempts: 0,
148
+ startedAt: Date.now(),
149
+ userIdSnapshot: this.coordinatorConnectUserId,
150
+ };
151
+ this.send({
152
+ ...this.buildCoordinatorWsCommon(this.coordinatorWsPair),
153
+ event_type: 'initiated',
154
+ });
155
+ }
156
+
157
+ this.coordinatorWsPair.attempts++;
158
+ };
159
+
160
+ private succeedCoordinatorWs = () => {
161
+ const pair = this.coordinatorWsPair;
162
+ if (!pair) return;
163
+ this.send({
164
+ ...this.buildCoordinatorWsCommon(pair),
165
+ event_type: 'completed',
166
+ outcome: 'success',
167
+ retry_count_attempt: pair.attempts - 1,
168
+ elapsed_time: Date.now() - pair.startedAt,
169
+ });
170
+
171
+ this.coordinatorWsPair = undefined;
172
+ };
173
+
174
+ closeCoordinatorWs = () => {
175
+ const pair = this.coordinatorWsPair;
176
+ if (!pair || !pair.lastError) {
177
+ this.coordinatorWsPair = undefined;
178
+ return;
179
+ }
180
+
181
+ const { reason, code } = pair.lastError;
182
+ this.send({
183
+ ...this.buildCoordinatorWsCommon(pair),
184
+ event_type: 'completed',
185
+ outcome: 'failure',
186
+ retry_count_attempt: pair.attempts - 1,
187
+ elapsed_time: Date.now() - pair.startedAt,
188
+ retry_failure_reason: reason,
189
+ retry_failure_code: code,
190
+ });
191
+
192
+ this.coordinatorWsPair = undefined;
193
+ };
194
+
195
+ private buildCoordinatorWsCommon = (
196
+ pair: StagePairState,
197
+ ): Record<string, unknown> => ({
198
+ user_id: pair.userIdSnapshot ?? this.streamClient.userID,
199
+ stage: 'CoordinatorWS',
200
+ stage_id: pair.sid,
201
+ ...(this.coordinatorConnectId && {
202
+ coordinator_connect_id: this.coordinatorConnectId,
203
+ }),
204
+ timestamp: new Date().toISOString(),
205
+ user_agent: this.streamClient.getUserAgent(),
206
+ sdk_version: this.streamClient.getSdkVersion(),
207
+ });
208
+
209
+ private emitMediaPermission = (cid: string) => {
210
+ if (isReactNative() || !this.callContexts.has(cid)) return;
211
+
212
+ const pair: StagePairState = {
213
+ sid: generateUUIDv4(),
214
+ attempts: 0,
215
+ startedAt: Date.now(),
216
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
217
+ };
218
+
219
+ this.send({
220
+ ...this.buildCommon(cid, 'MediaDevicePermission', pair),
221
+ ...this.sessionIdField(cid),
222
+ microphone_permission_status: readPermissionStatus(
223
+ getAudioBrowserPermission(),
224
+ ),
225
+ camera_permission_status: readPermissionStatus(
226
+ getVideoBrowserPermission(),
227
+ ),
228
+ event_type: 'initiated',
229
+ });
230
+ };
231
+
232
+ registerCall = (cid: string, ctx: CallReportContext) => {
233
+ this.callContexts.set(cid, ctx);
234
+ };
235
+
236
+ unregisterCall = (cid: string) => {
237
+ this.callContexts.delete(cid);
238
+ this.joinAttemptIds.delete(cid);
239
+ this.joinReasons.delete(cid);
240
+ this.coordinatorPairs.delete(cid);
241
+ this.wsPairs.delete(cid);
242
+
243
+ this.firstFrameReported.delete(`${cid}:FirstVideoFrame`);
244
+ this.firstFrameReported.delete(`${cid}:FirstAudioFrame`);
245
+
246
+ for (const role of ['publish', 'subscribe'] as const) {
247
+ const key = pcKey(cid, role);
248
+ this.peerConnectionPairs.delete(key);
249
+ this.pcEverConnected.delete(key);
250
+ }
251
+ };
252
+
253
+ startCorrelation = (cid: string, joinReason: JoinReason) => {
254
+ try {
255
+ this.closeCallPairs(cid);
256
+
257
+ this.joinAttemptIds.set(cid, generateUUIDv4());
258
+ this.joinReasons.set(cid, joinReason);
259
+ this.firstFrameReported.delete(`${cid}:FirstVideoFrame`);
260
+ this.firstFrameReported.delete(`${cid}:FirstAudioFrame`);
261
+
262
+ this.emitJoinInitiated(cid);
263
+ this.emitMediaPermission(cid);
264
+ } catch (err) {
265
+ this.logger.warn('Failed to start join correlation', err);
266
+ }
267
+ };
268
+
269
+ withJoinLifecycle = async <T>(
270
+ cid: string,
271
+ joinReason: JoinReason,
272
+ op: () => Promise<T>,
273
+ ): Promise<T> => {
274
+ this.startCorrelation(cid, joinReason);
275
+ try {
276
+ return await op();
277
+ } catch (err) {
278
+ this.closeCallPairs(cid);
279
+ throw err;
280
+ }
281
+ };
282
+
283
+ track = async <T>(
284
+ cid: string,
285
+ stage: 'CoordinatorJoin' | 'WSJoin',
286
+ op: () => Promise<T>,
287
+ ): Promise<T> => {
288
+ this.beginAttempt(cid, stage);
289
+ try {
290
+ const result = await op();
291
+ this.succeedAttempt(cid, stage);
292
+ return result;
293
+ } catch (err) {
294
+ this.applyStageError(cid, stage, err);
295
+ throw err;
296
+ }
297
+ };
298
+
299
+ reportFirstFrame = (cid: string, trackType: TrackType, trackId: string) => {
300
+ const stage =
301
+ trackType === TrackType.VIDEO
302
+ ? 'FirstVideoFrame'
303
+ : trackType === TrackType.AUDIO
304
+ ? 'FirstAudioFrame'
305
+ : undefined;
306
+
307
+ if (!stage) return;
308
+
309
+ const key = `${cid}:${stage}`;
310
+ if (this.firstFrameReported.has(key)) return;
311
+
312
+ this.firstFrameReported.add(key);
313
+
314
+ const pair: StagePairState = {
315
+ sid: generateUUIDv4(),
316
+ attempts: 0,
317
+ startedAt: Date.now(),
318
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
319
+ };
320
+
321
+ const resolvedSfuId = this.getSfuId(cid);
322
+ this.send({
323
+ ...this.buildCommon(cid, stage, pair),
324
+ ...this.sessionIdField(cid),
325
+ ...(resolvedSfuId && { sfu_id: resolvedSfuId }),
326
+ track_id: trackId,
327
+ event_type: 'initiated',
328
+ });
329
+ };
330
+
331
+ captureWsError = (cid: string, opts: { code: string; reason: string }) => {
332
+ const pair = this.wsPairs.get(cid);
333
+ if (!pair) return;
334
+
335
+ applyError(pair, { reason: opts.reason, code: opts.code });
336
+ };
337
+
338
+ close = (cid: string) => {
339
+ this.closeCallPairs(cid);
340
+ };
341
+
342
+ abort = (
343
+ cid: string,
344
+ opts: { code: 'CLIENT_ABORTED' | 'BACKEND_LEAVE'; reason: string },
345
+ ) => {
346
+ try {
347
+ const { code, reason } = opts;
348
+ const stageError: StageError = { code, reason };
349
+
350
+ applyErrorIfAbsent(this.coordinatorPairs.get(cid), stageError);
351
+ applyErrorIfAbsent(this.wsPairs.get(cid), stageError);
352
+
353
+ this.failCoordinator(cid);
354
+ this.failWs(cid);
355
+
356
+ this.emitPeerConnectionFailure(
357
+ cid,
358
+ 'publish',
359
+ code,
360
+ reason,
361
+ 'NOT_CONNECTED',
362
+ );
363
+ this.emitPeerConnectionFailure(
364
+ cid,
365
+ 'subscribe',
366
+ code,
367
+ reason,
368
+ 'NOT_CONNECTED',
369
+ );
370
+ } catch (err) {
371
+ this.logger.warn('Failed to report abort', err);
372
+ }
373
+ };
374
+
375
+ private closeCallPairs = (cid: string) => {
376
+ if (this.coordinatorPairs.get(cid)) this.failCoordinator(cid);
377
+ if (this.wsPairs.get(cid)) this.failWs(cid);
378
+ for (const role of ['publish', 'subscribe'] as const) {
379
+ this.emitPeerConnectionFailure(
380
+ cid,
381
+ role,
382
+ 'CLIENT_ABORTED',
383
+ 'superseded by a new join attempt',
384
+ 'NOT_CONNECTED',
385
+ );
386
+ }
387
+ };
388
+
389
+ private emitJoinInitiated = (cid: string) => {
390
+ const joinAttemptId = this.joinAttemptIds.get(cid);
391
+ if (!joinAttemptId) return;
392
+ const coordinatorConnectId = this.coordinatorConnectId;
393
+ this.send({
394
+ user_id: this.streamClient.userID,
395
+ stage: 'JoinInitiated',
396
+ join_attempt_id: joinAttemptId,
397
+ ...(coordinatorConnectId && {
398
+ coordinator_connect_id: coordinatorConnectId,
399
+ }),
400
+ timestamp: new Date().toISOString(),
401
+ user_agent: this.streamClient.getUserAgent(),
402
+ sdk_version: this.streamClient.getSdkVersion(),
403
+ event_type: 'initiated',
404
+ });
405
+ };
406
+
407
+ private beginAttempt = (cid: string, stage: 'CoordinatorJoin' | 'WSJoin') => {
408
+ if (stage === 'CoordinatorJoin') this.beginCoordinatorAttempt(cid);
409
+ else this.beginWsAttempt(cid);
410
+ };
411
+
412
+ private succeedAttempt = (
413
+ cid: string,
414
+ stage: 'CoordinatorJoin' | 'WSJoin',
415
+ ) => {
416
+ if (stage === 'CoordinatorJoin') this.succeedCoordinator(cid);
417
+ else this.succeedWs(cid);
418
+ };
419
+
420
+ private applyStageError = (
421
+ cid: string,
422
+ stage: 'CoordinatorJoin' | 'WSJoin',
423
+ err: unknown,
424
+ ) => {
425
+ const pair =
426
+ stage === 'CoordinatorJoin'
427
+ ? this.coordinatorPairs.get(cid)
428
+ : this.wsPairs.get(cid);
429
+
430
+ applyErrorIfAbsent(
431
+ pair,
432
+ stage === 'CoordinatorJoin'
433
+ ? mapCoordinatorHttpError(err)
434
+ : mapWsJoinError(err),
435
+ );
436
+ };
437
+
438
+ private beginCoordinatorAttempt = (cid: string) => {
439
+ let pair = this.coordinatorPairs.get(cid);
440
+ if (!pair) {
441
+ pair = {
442
+ sid: generateUUIDv4(),
443
+ attempts: 0,
444
+ startedAt: Date.now(),
445
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
446
+ joinReasonSnapshot: this.joinReasons.get(cid),
447
+ };
448
+ this.coordinatorPairs.set(cid, pair);
449
+ this.send({
450
+ ...this.buildCommon(cid, 'CoordinatorJoin', pair),
451
+ ...(pair.joinReasonSnapshot && {
452
+ join_reason: pair.joinReasonSnapshot,
453
+ }),
454
+ event_type: 'initiated',
455
+ });
456
+ }
457
+ pair.lastError = undefined;
458
+ pair.attempts++;
459
+ };
460
+
461
+ private succeedCoordinator = (cid: string) => {
462
+ const pair = this.coordinatorPairs.get(cid);
463
+ if (!pair) return;
464
+ this.send({
465
+ ...this.buildCommon(cid, 'CoordinatorJoin', pair),
466
+ ...this.sessionIdField(cid),
467
+ ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
468
+ event_type: 'completed',
469
+ outcome: 'success',
470
+ retry_count_attempt: pair.attempts - 1,
471
+ elapsed_time: Date.now() - pair.startedAt,
472
+ });
473
+ this.coordinatorPairs.delete(cid);
474
+ };
475
+
476
+ private failCoordinator = (cid: string) => {
477
+ const pair = this.coordinatorPairs.get(cid);
478
+ if (!pair || !pair.lastError) {
479
+ this.coordinatorPairs.delete(cid);
480
+ return;
481
+ }
482
+ const { reason, code } = pair.lastError;
483
+ this.send({
484
+ ...this.buildCommon(cid, 'CoordinatorJoin', pair),
485
+ ...this.sessionIdField(cid),
486
+ ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
487
+ event_type: 'completed',
488
+ outcome: 'failure',
489
+ retry_count_attempt: pair.attempts - 1,
490
+ elapsed_time: Date.now() - pair.startedAt,
491
+ retry_failure_reason: reason,
492
+ retry_failure_code: code,
493
+ });
494
+ this.coordinatorPairs.delete(cid);
495
+ };
496
+
497
+ private beginWsAttempt = (cid: string) => {
498
+ let pair = this.wsPairs.get(cid);
499
+ if (!pair) {
500
+ pair = {
501
+ sid: generateUUIDv4(),
502
+ attempts: 0,
503
+ startedAt: Date.now(),
504
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
505
+ };
506
+ this.wsPairs.set(cid, pair);
507
+ const sfuId = this.getSfuId(cid);
508
+ this.send({
509
+ ...this.buildCommon(cid, 'WSJoin', pair),
510
+ ...this.sessionIdField(cid),
511
+ ...(sfuId && { sfu_id: sfuId }),
512
+ event_type: 'initiated',
513
+ });
514
+ }
515
+
516
+ pair.lastError = undefined;
517
+ pair.attempts++;
518
+ };
519
+
520
+ private succeedWs = (cid: string) => {
521
+ const pair = this.wsPairs.get(cid);
522
+ if (!pair) return;
523
+ const sfuId = this.getSfuId(cid);
524
+ this.send({
525
+ ...this.buildCommon(cid, 'WSJoin', pair),
526
+ ...this.sessionIdField(cid),
527
+ ...(sfuId && { sfu_id: sfuId }),
528
+ event_type: 'completed',
529
+ outcome: 'success',
530
+ retry_count_attempt: pair.attempts - 1,
531
+ elapsed_time: Date.now() - pair.startedAt,
532
+ });
533
+
534
+ this.wsPairs.delete(cid);
535
+ };
536
+
537
+ private failWs = (cid: string) => {
538
+ const pair = this.wsPairs.get(cid);
539
+ if (!pair || !pair.lastError) {
540
+ this.wsPairs.delete(cid);
541
+ return;
542
+ }
543
+
544
+ const { reason, code } = pair.lastError;
545
+ const sfuId = this.getSfuId(cid);
546
+
547
+ this.send({
548
+ ...this.buildCommon(cid, 'WSJoin', pair),
549
+ ...this.sessionIdField(cid),
550
+ event_type: 'completed',
551
+ outcome: 'failure',
552
+ retry_count_attempt: pair.attempts - 1,
553
+ elapsed_time: Date.now() - pair.startedAt,
554
+ ...(sfuId && { sfu_id: sfuId }),
555
+ retry_failure_reason: reason,
556
+ retry_failure_code: code,
557
+ });
558
+ this.wsPairs.delete(cid);
559
+ };
560
+
561
+ onPeerConnectionStateChange = (
562
+ cid: string,
563
+ event: PeerConnectionStateChangeEvent,
564
+ ) => {
565
+ const role: ClientEventPeerConnection =
566
+ event.peerType === PeerType.SUBSCRIBER ? 'subscribe' : 'publish';
567
+
568
+ if (event.stateType === 'ice' && event.state === 'failed') {
569
+ this.emitPeerConnectionFailure(
570
+ cid,
571
+ role,
572
+ 'ICE_CONNECTIVITY_FAILED',
573
+ 'ICE connectivity checks failed',
574
+ 'FAILED',
575
+ );
576
+ return;
577
+ }
578
+
579
+ if (event.stateType === 'peerConnection' && event.state === 'failed') {
580
+ this.emitPeerConnectionFailure(
581
+ cid,
582
+ role,
583
+ 'DTLS_CONNECTIVITY_FAILED',
584
+ 'DTLS connectivity checks failed',
585
+ 'CONNECTED',
586
+ );
587
+ return;
588
+ }
589
+
590
+ if (event.stateType !== 'peerConnection') return;
591
+
592
+ switch (event.state) {
593
+ case 'connecting':
594
+ if (this.peerConnectionPairs.has(pcKey(cid, role))) return;
595
+ this.openPeerConnectionPair(cid, role);
596
+ break;
597
+ case 'connected':
598
+ this.emitPeerConnectionSuccess(cid, role);
599
+ this.pcEverConnected.set(pcKey(cid, role), true);
600
+ break;
601
+ default:
602
+ break;
603
+ }
604
+ };
605
+
606
+ private openPeerConnectionPair = (
607
+ cid: string,
608
+ role: ClientEventPeerConnection,
609
+ ) => {
610
+ const key = pcKey(cid, role);
611
+ const pair: PeerConnectionPairState = {
612
+ sid: generateUUIDv4(),
613
+ attempts: 0,
614
+ startedAt: Date.now(),
615
+ joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
616
+ sfuId: this.getSfuId(cid),
617
+ userSessionId: this.getUserSessionId(cid),
618
+ wasPreviouslyConnected: this.pcEverConnected.get(key) === true,
619
+ };
620
+ this.peerConnectionPairs.set(key, pair);
621
+
622
+ this.send({
623
+ ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
624
+ ...this.sessionIdField(cid),
625
+ peer_connection: role,
626
+ was_previously_connected: pair.wasPreviouslyConnected,
627
+ ...(pair.sfuId && { sfu_id: pair.sfuId }),
628
+ ...(pair.userSessionId && {
629
+ user_session_id: pair.userSessionId,
630
+ }),
631
+ event_type: 'initiated',
632
+ });
633
+ };
634
+
635
+ private emitPeerConnectionSuccess = (
636
+ cid: string,
637
+ role: ClientEventPeerConnection,
638
+ ) => {
639
+ const key = pcKey(cid, role);
640
+ const pair = this.peerConnectionPairs.get(key);
641
+ if (!pair) return;
642
+
643
+ this.send({
644
+ ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
645
+ ...this.sessionIdField(cid),
646
+ peer_connection: role,
647
+ was_previously_connected: pair.wasPreviouslyConnected,
648
+ ...(pair.sfuId && { sfu_id: pair.sfuId }),
649
+ ...(pair.userSessionId && {
650
+ user_session_id: pair.userSessionId,
651
+ }),
652
+ event_type: 'completed',
653
+ outcome: 'success',
654
+ retry_count_attempt: 0,
655
+ elapsed_time: Date.now() - pair.startedAt,
656
+ });
657
+ this.peerConnectionPairs.delete(key);
658
+ };
659
+
660
+ private emitPeerConnectionFailure = (
661
+ cid: string,
662
+ role: ClientEventPeerConnection,
663
+ code: ClientEventStandardCode,
664
+ reason: string,
665
+ iceState: 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED',
666
+ ) => {
667
+ const key = pcKey(cid, role);
668
+ const pair = this.peerConnectionPairs.get(key);
669
+ if (!pair) return;
670
+
671
+ this.send({
672
+ ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
673
+ ...this.sessionIdField(cid),
674
+ peer_connection: role,
675
+ was_previously_connected: pair.wasPreviouslyConnected,
676
+ ...(pair.userSessionId && {
677
+ user_session_id: pair.userSessionId,
678
+ }),
679
+ ...(pair.sfuId && { sfu_id: pair.sfuId }),
680
+ event_type: 'completed',
681
+ outcome: 'failure',
682
+ retry_count_attempt: 0,
683
+ elapsed_time: Date.now() - pair.startedAt,
684
+ ice_state: iceState,
685
+ retry_failure_reason: reason,
686
+ retry_failure_code: code,
687
+ });
688
+ this.peerConnectionPairs.delete(key);
689
+ };
690
+
691
+ private getSfuId = (cid: string): string =>
692
+ this.callContexts.get(cid)?.getSfuId() ?? '';
693
+
694
+ private getUserSessionId = (cid: string): string =>
695
+ this.callContexts.get(cid)?.getUserSessionId() ?? '';
696
+
697
+ private sessionIdField = (cid: string): Record<string, unknown> => {
698
+ const callSessionId = this.callContexts.get(cid)?.getCallSessionId() ?? '';
699
+ return callSessionId ? { call_session_id: callSessionId } : {};
700
+ };
701
+
702
+ private buildCommon = (
703
+ cid: string,
704
+ stage: ClientEventStage,
705
+ pair: StagePairState,
706
+ ): Record<string, unknown> => {
707
+ const ctx = this.callContexts.get(cid);
708
+ const coordinatorConnectId = this.coordinatorConnectId;
709
+ return {
710
+ user_id: this.streamClient.userID,
711
+ type: ctx?.callType ?? '',
712
+ id: ctx?.callId ?? '',
713
+ call_cid: cid,
714
+ stage,
715
+ stage_id: pair.sid,
716
+ ...(pair.joinAttemptIdSnapshot && {
717
+ join_attempt_id: pair.joinAttemptIdSnapshot,
718
+ }),
719
+ ...(coordinatorConnectId && {
720
+ coordinator_connect_id: coordinatorConnectId,
721
+ }),
722
+ timestamp: new Date().toISOString(),
723
+ user_agent: this.streamClient.getUserAgent(),
724
+ sdk_version: this.streamClient.getSdkVersion(),
725
+ };
726
+ };
727
+
728
+ private send = (body: Record<string, unknown>) => {
729
+ void this.sendWithRetry(body);
730
+ };
731
+
732
+ private sendWithRetry = async (
733
+ body: Record<string, unknown>,
734
+ ): Promise<boolean> => {
735
+ for (let attempt = 0; attempt < 5; attempt++) {
736
+ try {
737
+ await this.streamClient.doAxiosRequest(
738
+ 'post',
739
+ '/call_client_event',
740
+ { events: [body] },
741
+ { publicEndpoint: true },
742
+ );
743
+ return true;
744
+ } catch (err) {
745
+ const status = (err as { response?: { status?: number } })?.response
746
+ ?.status;
747
+ if (typeof status === 'number' && status >= 400 && status < 500) {
748
+ this.logger.debug(
749
+ `Client event rejected (${status}), not retrying`,
750
+ body.stage,
751
+ body.event_type,
752
+ );
753
+ return false;
754
+ }
755
+ if (attempt === 4) {
756
+ this.logger.debug(
757
+ 'Client event delivery failed after retries',
758
+ body.stage,
759
+ body.event_type,
760
+ err,
761
+ );
762
+ return false;
763
+ }
764
+ await sleep(retryInterval(attempt));
765
+ }
766
+ }
767
+ return false;
768
+ };
769
+ }
770
+
771
+ const readPermissionStatus = (
772
+ permission: BrowserPermission,
773
+ ): MediaPermissionState => {
774
+ const state = getCurrentValue<BrowserPermissionState>(
775
+ permission.asStateObservable(),
776
+ );
777
+
778
+ switch (state) {
779
+ case 'granted':
780
+ return 'GRANTED';
781
+ case 'denied':
782
+ return 'FAILED';
783
+ case 'prompting':
784
+ return 'INITIATED';
785
+ case 'prompt':
786
+ default:
787
+ return 'NOT_INITIATED';
788
+ }
789
+ };
790
+
791
+ const errorMessage = (err: unknown): string =>
792
+ err instanceof Error ? err.message : String(err);
793
+
794
+ const applyError = (pair: StagePairState | undefined, next: StageError) => {
795
+ if (!pair) return;
796
+ pair.lastError = next;
797
+ };
798
+
799
+ const applyErrorIfAbsent = (
800
+ pair: StagePairState | undefined,
801
+ next: StageError,
802
+ ) => {
803
+ if (!pair || pair.lastError) return;
804
+ pair.lastError = next;
805
+ };
806
+
807
+ const mapCoordinatorHttpError = (err: unknown): StageError => {
808
+ if (err instanceof ErrorFromResponse) {
809
+ return {
810
+ reason: err.message,
811
+ code: err.code != null ? String(err.code) : 'SERVER_ERROR',
812
+ };
813
+ }
814
+ return { reason: errorMessage(err), code: 'SERVER_ERROR' };
815
+ };
816
+
817
+ const mapCoordinatorWsError = (err: unknown): StageError => {
818
+ if (err instanceof ErrorFromResponse) {
819
+ return {
820
+ reason: err.message,
821
+ code: err.code != null ? String(err.code) : 'SERVER_ERROR',
822
+ };
823
+ }
824
+
825
+ if (err instanceof Error) {
826
+ try {
827
+ const parsed = JSON.parse(err.message);
828
+ if (typeof parsed.isWSFailure === 'boolean') {
829
+ return {
830
+ reason: parsed.message || err.message,
831
+ code:
832
+ !parsed.isWSFailure && parsed.code
833
+ ? String(parsed.code)
834
+ : 'SERVER_ERROR',
835
+ };
836
+ }
837
+ } catch {}
838
+ }
839
+
840
+ return { reason: errorMessage(err), code: 'SERVER_ERROR' };
841
+ };
842
+
843
+ const mapWsJoinError = (err: unknown): StageError => {
844
+ if (err instanceof SfuJoinError) {
845
+ const sfuError = err.errorEvent.error;
846
+
847
+ return {
848
+ reason: sfuError?.message || err.message,
849
+ code: (sfuError && ErrorCode[sfuError.code]) || 'SFU_ERROR',
850
+ };
851
+ }
852
+
853
+ const reason = errorMessage(err);
854
+ if (err instanceof SfuTimeoutError) {
855
+ return { reason, code: 'REQUEST_TIMEOUT' };
856
+ }
857
+
858
+ return { reason, code: 'SFU_ERROR' };
859
+ };