@stream-io/video-client 1.59.1 → 1.61.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.
- package/CHANGELOG.md +18 -0
- package/dist/index.browser.es.js +744 -156
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +744 -155
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +744 -156
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +34 -8
- package/dist/src/coordinator/connection/types.d.ts +16 -0
- package/dist/src/events/call.d.ts +0 -11
- package/dist/src/gen/coordinator/index.d.ts +73 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
- package/dist/src/ringing/RingStatePoller.d.ts +46 -0
- package/dist/src/ringing/RingTimeout.d.ts +27 -0
- package/dist/src/ringing/index.d.ts +4 -0
- package/dist/src/ringing/reconcileRingState.d.ts +18 -0
- package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
- package/dist/src/rtc/Publisher.d.ts +18 -0
- package/dist/src/rtc/TransceiverCache.d.ts +10 -0
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
- package/dist/src/stats/utils.d.ts +6 -0
- package/dist/src/store/CallState.d.ts +10 -1
- package/dist/src/types.d.ts +30 -1
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/Call.ts +272 -108
- package/src/StreamSfuClient.ts +6 -2
- package/src/__tests__/Call.autodrop.test.ts +116 -64
- package/src/__tests__/Call.getRingState.test.ts +87 -0
- package/src/__tests__/Call.lifecycle.test.ts +498 -5
- package/src/__tests__/Call.ringSettled.test.ts +71 -0
- package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
- package/src/__tests__/StreamSfuClient.test.ts +48 -0
- package/src/coordinator/connection/types.ts +19 -0
- package/src/events/__tests__/call.test.ts +2 -217
- package/src/events/call.ts +1 -73
- package/src/events/callEventHandlers.ts +12 -23
- package/src/gen/coordinator/index.ts +67 -0
- package/src/reporting/ClientEventReporter.ts +30 -4
- package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
- package/src/ringing/RingStatePoller.ts +177 -0
- package/src/ringing/RingTimeout.ts +90 -0
- package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
- package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
- package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
- package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
- package/src/ringing/index.ts +4 -0
- package/src/ringing/reconcileRingState.ts +108 -0
- package/src/ringing/resolveOwnRingOutcome.ts +58 -0
- package/src/rtc/Publisher.ts +66 -12
- package/src/rtc/TransceiverCache.ts +17 -0
- package/src/rtc/__tests__/Publisher.test.ts +209 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
- package/src/rtc/e2ee/E2EEManager.ts +10 -1
- package/src/rtc/e2ee/EncryptionManager.ts +1 -1
- package/src/rtc/e2ee/SPEC.md +1 -1
- package/src/stats/__tests__/utils.test.ts +33 -0
- package/src/stats/utils.ts +15 -0
- package/src/store/CallState.ts +25 -0
- package/src/store/__tests__/CallState.test.ts +59 -0
- package/src/types.ts +37 -1
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { Call } from '../Call';
|
|
2
|
+
import { reconcileRingState } from '../ringing';
|
|
2
3
|
import { Dispatcher } from '../rtc';
|
|
3
4
|
import {
|
|
4
5
|
handleRemoteSoftMute,
|
|
5
6
|
watchAudioLevelChanged,
|
|
6
|
-
watchCallAccepted,
|
|
7
7
|
watchCallEnded,
|
|
8
8
|
watchCallGrantsUpdated,
|
|
9
|
-
watchCallRejected,
|
|
10
9
|
watchConnectionQualityChanged,
|
|
11
10
|
watchDominantSpeakerChanged,
|
|
12
11
|
watchInboundStateNotification,
|
|
@@ -21,16 +20,6 @@ import {
|
|
|
21
20
|
watchTrackPublished,
|
|
22
21
|
watchTrackUnpublished,
|
|
23
22
|
} from '../events';
|
|
24
|
-
import {
|
|
25
|
-
AllCallEvents,
|
|
26
|
-
AllClientCallEvents,
|
|
27
|
-
CallEventListener,
|
|
28
|
-
} from '../coordinator/connection/types';
|
|
29
|
-
|
|
30
|
-
type RingCallEvents = Extract<
|
|
31
|
-
AllClientCallEvents,
|
|
32
|
-
'call.accepted' | 'call.rejected' | 'call.missed'
|
|
33
|
-
>;
|
|
34
23
|
|
|
35
24
|
/**
|
|
36
25
|
* Registers the default event handlers for a call during its lifecycle.
|
|
@@ -83,19 +72,19 @@ export const registerEventHandlers = (call: Call, dispatcher: Dispatcher) => {
|
|
|
83
72
|
* @param call the call to register event handlers for.
|
|
84
73
|
*/
|
|
85
74
|
export const registerRingingCallEventHandlers = (call: Call) => {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
call
|
|
89
|
-
)
|
|
90
|
-
} = {
|
|
91
|
-
'call.accepted': watchCallAccepted(call),
|
|
92
|
-
'call.rejected': watchCallRejected(call),
|
|
75
|
+
const reconcile = () => {
|
|
76
|
+
reconcileRingState(call, 'ring-ws').catch((err) => {
|
|
77
|
+
call.logger.error('Failed to reconcile the ring state', err);
|
|
78
|
+
});
|
|
93
79
|
};
|
|
94
80
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
81
|
+
// each event needs its own closure. `call.missed` is deliberately absent:
|
|
82
|
+
// nothing in the reconciler acts on `missed_by`, so the auto-drop owns the
|
|
83
|
+
// "nobody answered" case until the server-owned ring timeout lands.
|
|
84
|
+
const eventHandlers = [
|
|
85
|
+
call.on('call.accepted', () => reconcile()),
|
|
86
|
+
call.on('call.rejected', () => reconcile()),
|
|
87
|
+
];
|
|
99
88
|
|
|
100
89
|
return () => {
|
|
101
90
|
eventHandlers.forEach((unsubscribe) => unsubscribe());
|
|
@@ -5130,6 +5130,73 @@ export interface GetCallResponse {
|
|
|
5130
5130
|
*/
|
|
5131
5131
|
own_capabilities: Array<OwnCapability>;
|
|
5132
5132
|
}
|
|
5133
|
+
/**
|
|
5134
|
+
* The ring state of a call session: who accepted, rejected, or missed the ring.
|
|
5135
|
+
* @export
|
|
5136
|
+
* @interface GetCallRingStateResponse
|
|
5137
|
+
*/
|
|
5138
|
+
export interface GetCallRingStateResponse {
|
|
5139
|
+
/**
|
|
5140
|
+
* Users that accepted the call, mapped to when they accepted
|
|
5141
|
+
* @type {{ [key: string]: string; }}
|
|
5142
|
+
* @memberof GetCallRingStateResponse
|
|
5143
|
+
*/
|
|
5144
|
+
accepted_by: { [key: string]: string };
|
|
5145
|
+
/**
|
|
5146
|
+
* The CID of the call
|
|
5147
|
+
* @type {string}
|
|
5148
|
+
* @memberof GetCallRingStateResponse
|
|
5149
|
+
*/
|
|
5150
|
+
call_cid: string;
|
|
5151
|
+
/**
|
|
5152
|
+
* When the call ended
|
|
5153
|
+
* @type {string}
|
|
5154
|
+
* @memberof GetCallRingStateResponse
|
|
5155
|
+
*/
|
|
5156
|
+
call_ended_at?: string;
|
|
5157
|
+
/**
|
|
5158
|
+
* The user that created the call, i.e. the caller
|
|
5159
|
+
* @type {string}
|
|
5160
|
+
* @memberof GetCallRingStateResponse
|
|
5161
|
+
*/
|
|
5162
|
+
created_by_user_id: string;
|
|
5163
|
+
/**
|
|
5164
|
+
* Duration of the request in milliseconds
|
|
5165
|
+
* @type {string}
|
|
5166
|
+
* @memberof GetCallRingStateResponse
|
|
5167
|
+
*/
|
|
5168
|
+
duration: string;
|
|
5169
|
+
/**
|
|
5170
|
+
* Users that missed the call, mapped to when they were marked as missed
|
|
5171
|
+
* @type {{ [key: string]: string; }}
|
|
5172
|
+
* @memberof GetCallRingStateResponse
|
|
5173
|
+
*/
|
|
5174
|
+
missed_by: { [key: string]: string };
|
|
5175
|
+
/**
|
|
5176
|
+
* Users that rejected the call, mapped to when they rejected
|
|
5177
|
+
* @type {{ [key: string]: string; }}
|
|
5178
|
+
* @memberof GetCallRingStateResponse
|
|
5179
|
+
*/
|
|
5180
|
+
rejected_by: { [key: string]: string };
|
|
5181
|
+
/**
|
|
5182
|
+
* When the call session ended
|
|
5183
|
+
* @type {string}
|
|
5184
|
+
* @memberof GetCallRingStateResponse
|
|
5185
|
+
*/
|
|
5186
|
+
session_ended_at?: string;
|
|
5187
|
+
/**
|
|
5188
|
+
* The call session this state belongs to, empty when the call has never rung
|
|
5189
|
+
* @type {string}
|
|
5190
|
+
* @memberof GetCallRingStateResponse
|
|
5191
|
+
*/
|
|
5192
|
+
session_id: string;
|
|
5193
|
+
/**
|
|
5194
|
+
* When the call session started
|
|
5195
|
+
* @type {string}
|
|
5196
|
+
* @memberof GetCallRingStateResponse
|
|
5197
|
+
*/
|
|
5198
|
+
session_started_at?: string;
|
|
5199
|
+
}
|
|
5133
5200
|
/**
|
|
5134
5201
|
* Basic response information
|
|
5135
5202
|
* @export
|
|
@@ -45,6 +45,13 @@ export type ReportedIceState = 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED';
|
|
|
45
45
|
export type JoinReason =
|
|
46
46
|
'first-attempt' | 'network-available' | 'migration' | 'full-rejoin';
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* What triggered an automatic join, reported as `source` on the call's
|
|
50
|
+
* CoordinatorJoin events. `ring-ws` is a ring WebSocket event, `ring-poll-api`
|
|
51
|
+
* is the ring state poller.
|
|
52
|
+
*/
|
|
53
|
+
export type JoinSource = 'ring-ws' | 'ring-poll-api';
|
|
54
|
+
|
|
48
55
|
export type ClientEventStandardCode =
|
|
49
56
|
| 'CLIENT_ABORTED'
|
|
50
57
|
| 'BACKEND_LEAVE'
|
|
@@ -67,6 +74,9 @@ export type ClientEventReporterOptions = {
|
|
|
67
74
|
enabled?: boolean;
|
|
68
75
|
};
|
|
69
76
|
|
|
77
|
+
// TODO OL: update OpenAPI
|
|
78
|
+
type ReportedClientEvent = ClientEvent & { source?: JoinSource };
|
|
79
|
+
|
|
70
80
|
type StageError = {
|
|
71
81
|
reason: string;
|
|
72
82
|
code: string;
|
|
@@ -78,6 +88,7 @@ type StagePairState = {
|
|
|
78
88
|
startedAt: number;
|
|
79
89
|
joinAttemptIdSnapshot?: string;
|
|
80
90
|
joinReasonSnapshot?: JoinReason;
|
|
91
|
+
joinSourceSnapshot?: JoinSource;
|
|
81
92
|
userIdSnapshot?: string;
|
|
82
93
|
lastError?: StageError;
|
|
83
94
|
};
|
|
@@ -104,6 +115,7 @@ export class ClientEventReporter {
|
|
|
104
115
|
private callContexts = new Map<string, CallReportContext>();
|
|
105
116
|
private joinAttemptIds = new Map<string, string>();
|
|
106
117
|
private joinReasons = new Map<string, JoinReason>();
|
|
118
|
+
private joinSources = new Map<string, JoinSource>();
|
|
107
119
|
private coordinatorPairs = new Map<string, StagePairState>();
|
|
108
120
|
private wsPairs = new Map<string, StagePairState>();
|
|
109
121
|
|
|
@@ -238,6 +250,7 @@ export class ClientEventReporter {
|
|
|
238
250
|
this.callContexts.delete(cid);
|
|
239
251
|
this.joinAttemptIds.delete(cid);
|
|
240
252
|
this.joinReasons.delete(cid);
|
|
253
|
+
this.joinSources.delete(cid);
|
|
241
254
|
this.coordinatorPairs.delete(cid);
|
|
242
255
|
this.wsPairs.delete(cid);
|
|
243
256
|
|
|
@@ -269,15 +282,22 @@ export class ClientEventReporter {
|
|
|
269
282
|
|
|
270
283
|
withJoinLifecycle = async <T>(
|
|
271
284
|
cid: string,
|
|
272
|
-
joinReason: JoinReason,
|
|
285
|
+
options: { joinReason: JoinReason; joinSource?: JoinSource },
|
|
273
286
|
op: () => Promise<T>,
|
|
274
287
|
): Promise<T> => {
|
|
288
|
+
const { joinReason, joinSource } = options;
|
|
289
|
+
|
|
290
|
+
if (joinSource) this.joinSources.set(cid, joinSource);
|
|
291
|
+
else this.joinSources.delete(cid);
|
|
292
|
+
|
|
275
293
|
this.startCorrelation(cid, joinReason);
|
|
276
294
|
try {
|
|
277
295
|
return await op();
|
|
278
296
|
} catch (err) {
|
|
279
297
|
this.closeCallPairs(cid);
|
|
280
298
|
throw err;
|
|
299
|
+
} finally {
|
|
300
|
+
this.joinSources.delete(cid);
|
|
281
301
|
}
|
|
282
302
|
};
|
|
283
303
|
|
|
@@ -436,6 +456,7 @@ export class ClientEventReporter {
|
|
|
436
456
|
startedAt: Date.now(),
|
|
437
457
|
joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
|
|
438
458
|
joinReasonSnapshot: this.joinReasons.get(cid),
|
|
459
|
+
joinSourceSnapshot: this.joinSources.get(cid),
|
|
439
460
|
};
|
|
440
461
|
this.coordinatorPairs.set(cid, pair);
|
|
441
462
|
this.sendForCall(cid, {
|
|
@@ -443,6 +464,7 @@ export class ClientEventReporter {
|
|
|
443
464
|
...(pair.joinReasonSnapshot && {
|
|
444
465
|
join_reason: pair.joinReasonSnapshot,
|
|
445
466
|
}),
|
|
467
|
+
...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
|
|
446
468
|
event_type: 'initiated',
|
|
447
469
|
});
|
|
448
470
|
}
|
|
@@ -457,6 +479,7 @@ export class ClientEventReporter {
|
|
|
457
479
|
...this.buildCommon(cid, 'CoordinatorJoin', pair),
|
|
458
480
|
...this.sessionIdField(cid),
|
|
459
481
|
...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
|
|
482
|
+
...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
|
|
460
483
|
event_type: 'completed',
|
|
461
484
|
outcome: 'success',
|
|
462
485
|
retry_count_attempt: pair.attempts - 1,
|
|
@@ -476,6 +499,7 @@ export class ClientEventReporter {
|
|
|
476
499
|
...this.buildCommon(cid, 'CoordinatorJoin', pair),
|
|
477
500
|
...this.sessionIdField(cid),
|
|
478
501
|
...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
|
|
502
|
+
...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
|
|
479
503
|
event_type: 'completed',
|
|
480
504
|
outcome: 'failure',
|
|
481
505
|
retry_count_attempt: pair.attempts - 1,
|
|
@@ -705,17 +729,19 @@ export class ClientEventReporter {
|
|
|
705
729
|
};
|
|
706
730
|
};
|
|
707
731
|
|
|
708
|
-
private send = (body:
|
|
732
|
+
private send = (body: ReportedClientEvent) => {
|
|
709
733
|
if (!this.enabled) return;
|
|
710
734
|
void this.sendWithRetry(body);
|
|
711
735
|
};
|
|
712
736
|
|
|
713
|
-
private sendForCall = (cid: string, body:
|
|
737
|
+
private sendForCall = (cid: string, body: ReportedClientEvent) => {
|
|
714
738
|
if (!this.callContexts.has(cid)) return;
|
|
715
739
|
this.send(body);
|
|
716
740
|
};
|
|
717
741
|
|
|
718
|
-
private sendWithRetry = async (
|
|
742
|
+
private sendWithRetry = async (
|
|
743
|
+
body: ReportedClientEvent,
|
|
744
|
+
): Promise<boolean> => {
|
|
719
745
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
720
746
|
try {
|
|
721
747
|
await this.streamClient.doAxiosRequest<
|
|
@@ -39,6 +39,12 @@ const iceEvent = (
|
|
|
39
39
|
|
|
40
40
|
describe('ClientEventReporter', () => {
|
|
41
41
|
const cid = 'default:call-1';
|
|
42
|
+
const callContext: CallReportContext = {
|
|
43
|
+
callType: 'default',
|
|
44
|
+
callId: 'call-1',
|
|
45
|
+
getCallSessionId: () => 'session-1',
|
|
46
|
+
getSfuId: () => 'sfu-1',
|
|
47
|
+
};
|
|
42
48
|
let doAxiosRequest: ReturnType<typeof vi.fn>;
|
|
43
49
|
let reporter: ClientEventReporter;
|
|
44
50
|
let connectId: string;
|
|
@@ -72,13 +78,7 @@ describe('ClientEventReporter', () => {
|
|
|
72
78
|
reporter = new ClientEventReporter({ streamClient });
|
|
73
79
|
connectId = reporter.startCoordinatorConnection('user-1');
|
|
74
80
|
|
|
75
|
-
|
|
76
|
-
callType: 'default',
|
|
77
|
-
callId: 'call-1',
|
|
78
|
-
getCallSessionId: () => 'session-1',
|
|
79
|
-
getSfuId: () => 'sfu-1',
|
|
80
|
-
};
|
|
81
|
-
reporter.registerCall(cid, ctx);
|
|
81
|
+
reporter.registerCall(cid, callContext);
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
it('emits an initiated then a completed event on success', async () => {
|
|
@@ -117,6 +117,183 @@ describe('ClientEventReporter', () => {
|
|
|
117
117
|
expect(events[1]).toMatchObject({ join_reason: 'migration' });
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
+
it('carries the joinSource across the whole join lifecycle', async () => {
|
|
121
|
+
await reporter.withJoinLifecycle(
|
|
122
|
+
cid,
|
|
123
|
+
{ joinReason: 'first-attempt', joinSource: 'ring-poll-api' },
|
|
124
|
+
() => reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')),
|
|
125
|
+
);
|
|
126
|
+
await flush();
|
|
127
|
+
|
|
128
|
+
const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
|
|
129
|
+
expect(events).toHaveLength(2);
|
|
130
|
+
expect(events[0]).toMatchObject({
|
|
131
|
+
event_type: 'initiated',
|
|
132
|
+
join_reason: 'first-attempt',
|
|
133
|
+
source: 'ring-poll-api',
|
|
134
|
+
});
|
|
135
|
+
expect(events[1]).toMatchObject({
|
|
136
|
+
event_type: 'completed',
|
|
137
|
+
outcome: 'success',
|
|
138
|
+
source: 'ring-poll-api',
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('carries the joinSource on a failed join', async () => {
|
|
143
|
+
await expect(
|
|
144
|
+
reporter.withJoinLifecycle(
|
|
145
|
+
cid,
|
|
146
|
+
{ joinReason: 'first-attempt', joinSource: 'ring-ws' },
|
|
147
|
+
() =>
|
|
148
|
+
reporter.track(cid, 'CoordinatorJoin', () =>
|
|
149
|
+
Promise.reject(new Error('boom')),
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
).rejects.toThrow('boom');
|
|
153
|
+
await flush();
|
|
154
|
+
|
|
155
|
+
const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
|
|
156
|
+
expect(events).toHaveLength(2);
|
|
157
|
+
expect(events[0]).toMatchObject({ source: 'ring-ws' });
|
|
158
|
+
expect(events[1]).toMatchObject({
|
|
159
|
+
event_type: 'completed',
|
|
160
|
+
outcome: 'failure',
|
|
161
|
+
source: 'ring-ws',
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('omits the source key entirely when the join has none', async () => {
|
|
166
|
+
await reporter.withJoinLifecycle(cid, { joinReason: 'first-attempt' }, () =>
|
|
167
|
+
reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')),
|
|
168
|
+
);
|
|
169
|
+
await flush();
|
|
170
|
+
|
|
171
|
+
const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
|
|
172
|
+
expect(events).toHaveLength(2);
|
|
173
|
+
expect('source' in events[0]).toBe(false);
|
|
174
|
+
expect('source' in events[1]).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// the regression that matters: a reconnect must not inherit the ring source
|
|
178
|
+
// of the join that came before it
|
|
179
|
+
it('does not carry a ring source into a later reconnect', async () => {
|
|
180
|
+
await reporter.withJoinLifecycle(
|
|
181
|
+
cid,
|
|
182
|
+
{ joinReason: 'first-attempt', joinSource: 'ring-ws' },
|
|
183
|
+
() => reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')),
|
|
184
|
+
);
|
|
185
|
+
doAxiosRequest.mockClear();
|
|
186
|
+
|
|
187
|
+
await reporter.withJoinLifecycle(cid, { joinReason: 'full-rejoin' }, () =>
|
|
188
|
+
reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')),
|
|
189
|
+
);
|
|
190
|
+
await flush();
|
|
191
|
+
|
|
192
|
+
const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
|
|
193
|
+
expect(events).toHaveLength(2);
|
|
194
|
+
expect(events[0]).toMatchObject({ join_reason: 'full-rejoin' });
|
|
195
|
+
expect('source' in events[0]).toBe(false);
|
|
196
|
+
expect('source' in events[1]).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// `Call.join`'s retry loop restores the pre-join state between attempts, so a
|
|
200
|
+
// reconnect can open its own lifecycle while the join's is still on the
|
|
201
|
+
// stack. The inner one must not inherit the outer one's source.
|
|
202
|
+
it('does not leak the joinSource into a nested sourceless lifecycle', async () => {
|
|
203
|
+
await reporter.withJoinLifecycle(
|
|
204
|
+
cid,
|
|
205
|
+
{ joinReason: 'first-attempt', joinSource: 'ring-ws' },
|
|
206
|
+
() =>
|
|
207
|
+
reporter.withJoinLifecycle(cid, { joinReason: 'full-rejoin' }, () =>
|
|
208
|
+
reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')),
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
await flush();
|
|
212
|
+
|
|
213
|
+
const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
|
|
214
|
+
expect(events).toHaveLength(2);
|
|
215
|
+
expect(events[0]).toMatchObject({ join_reason: 'full-rejoin' });
|
|
216
|
+
expect('source' in events[0]).toBe(false);
|
|
217
|
+
expect('source' in events[1]).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// a fast reconnect reports CoordinatorJoin without a join lifecycle, so the
|
|
221
|
+
// source has to be gone by the time the lifecycle ends
|
|
222
|
+
it('does not carry the joinSource into a join reported outside the lifecycle', async () => {
|
|
223
|
+
await reporter.withJoinLifecycle(
|
|
224
|
+
cid,
|
|
225
|
+
{ joinReason: 'first-attempt', joinSource: 'ring-ws' },
|
|
226
|
+
() => reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')),
|
|
227
|
+
);
|
|
228
|
+
doAxiosRequest.mockClear();
|
|
229
|
+
|
|
230
|
+
await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok'));
|
|
231
|
+
await flush();
|
|
232
|
+
|
|
233
|
+
const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
|
|
234
|
+
expect(events).toHaveLength(2);
|
|
235
|
+
expect('source' in events[0]).toBe(false);
|
|
236
|
+
expect('source' in events[1]).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// `Call.join` re-correlates mid-loop when it forces an SFU switch; those
|
|
240
|
+
// later attempts are still the same ring-caused join
|
|
241
|
+
it('keeps the joinSource across a mid-lifecycle re-correlation', async () => {
|
|
242
|
+
await reporter.withJoinLifecycle(
|
|
243
|
+
cid,
|
|
244
|
+
{ joinReason: 'first-attempt', joinSource: 'ring-ws' },
|
|
245
|
+
async () => {
|
|
246
|
+
await expect(
|
|
247
|
+
reporter.track(cid, 'CoordinatorJoin', () =>
|
|
248
|
+
Promise.reject(new Error('boom')),
|
|
249
|
+
),
|
|
250
|
+
).rejects.toThrow('boom');
|
|
251
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
252
|
+
await reporter.track(cid, 'CoordinatorJoin', () =>
|
|
253
|
+
Promise.resolve('ok'),
|
|
254
|
+
);
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
await flush();
|
|
258
|
+
|
|
259
|
+
const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
|
|
260
|
+
expect(events).toHaveLength(4);
|
|
261
|
+
expect(events.every((e) => e.source === 'ring-ws')).toBe(true);
|
|
262
|
+
expect(events[0].join_attempt_id).not.toBe(events[3].join_attempt_id);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('drops the joinSource when the call is unregistered', async () => {
|
|
266
|
+
await reporter.withJoinLifecycle(
|
|
267
|
+
cid,
|
|
268
|
+
{ joinReason: 'first-attempt', joinSource: 'ring-ws' },
|
|
269
|
+
() => Promise.resolve('ok'),
|
|
270
|
+
);
|
|
271
|
+
reporter.unregisterCall(cid);
|
|
272
|
+
reporter.registerCall(cid, callContext);
|
|
273
|
+
doAxiosRequest.mockClear();
|
|
274
|
+
|
|
275
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
276
|
+
await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok'));
|
|
277
|
+
await flush();
|
|
278
|
+
|
|
279
|
+
const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
|
|
280
|
+
expect(events).toHaveLength(2);
|
|
281
|
+
expect('source' in events[0]).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('reports the source only on CoordinatorJoin', async () => {
|
|
285
|
+
await reporter.withJoinLifecycle(
|
|
286
|
+
cid,
|
|
287
|
+
{ joinReason: 'first-attempt', joinSource: 'ring-ws' },
|
|
288
|
+
() => reporter.track(cid, 'WSJoin', () => Promise.resolve('ok')),
|
|
289
|
+
);
|
|
290
|
+
await flush();
|
|
291
|
+
|
|
292
|
+
const others = postedEvents().filter((e) => e.stage !== 'CoordinatorJoin');
|
|
293
|
+
expect(others.length).toBeGreaterThan(0);
|
|
294
|
+
expect(others.some((e) => 'source' in e)).toBe(false);
|
|
295
|
+
});
|
|
296
|
+
|
|
120
297
|
it('folds in-stage retries into a single pair', async () => {
|
|
121
298
|
reporter.startCorrelation(cid, 'first-attempt');
|
|
122
299
|
await expect(
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { Call } from '../Call';
|
|
2
|
+
import { reconcileRingState } from './reconcileRingState';
|
|
3
|
+
import { CallingState } from '../store';
|
|
4
|
+
import { createSubscription } from '../store/rxUtils';
|
|
5
|
+
import { getTimers } from '../timers';
|
|
6
|
+
import {
|
|
7
|
+
ErrorFromResponse,
|
|
8
|
+
type RingStatePollingOptions,
|
|
9
|
+
} from '../coordinator/connection/types';
|
|
10
|
+
import { videoLoggerSystem } from '../logger';
|
|
11
|
+
|
|
12
|
+
// `doJoin` restores the previous calling state when a join fails, so JOINING can
|
|
13
|
+
// go back to RINGING. Only a state the ring cannot return from ends the poller.
|
|
14
|
+
const ringIsOver = (callingState: CallingState) =>
|
|
15
|
+
callingState !== CallingState.RINGING &&
|
|
16
|
+
callingState !== CallingState.JOINING;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Polls the coordinator for the outcome of a ring the current user started.
|
|
20
|
+
*
|
|
21
|
+
* `call.accepted`, `call.rejected` and `call.missed` are delivered best-effort,
|
|
22
|
+
* with no store-and-forward, so a caller that drops one is left on a ringing
|
|
23
|
+
* screen while the callee is already in the call. After a quiet period this
|
|
24
|
+
* reads the ring state until a terminal outcome or the end of the ring window.
|
|
25
|
+
*/
|
|
26
|
+
export class RingStatePoller {
|
|
27
|
+
private readonly logger = videoLoggerSystem.getLogger('RingStatePoller');
|
|
28
|
+
private readonly call: Call;
|
|
29
|
+
private readonly startAfterMs: number;
|
|
30
|
+
private readonly intervalMs: number;
|
|
31
|
+
private sessionId: string | undefined;
|
|
32
|
+
private deadlineAt: number = 0;
|
|
33
|
+
private idleTimeoutId: number | undefined;
|
|
34
|
+
private intervalId: number | undefined;
|
|
35
|
+
private stopped: boolean = false;
|
|
36
|
+
private wasPolling: boolean = false;
|
|
37
|
+
private inFlight: boolean = false;
|
|
38
|
+
private unsubscribe: Array<() => void> = [];
|
|
39
|
+
|
|
40
|
+
constructor(call: Call, options: RingStatePollingOptions = {}) {
|
|
41
|
+
this.call = call;
|
|
42
|
+
this.startAfterMs = options.startAfterMs ?? 15_000;
|
|
43
|
+
this.intervalMs = options.intervalMs ?? 5_000;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Starts polling. Does nothing when the call has no session yet.
|
|
48
|
+
*/
|
|
49
|
+
start = () => {
|
|
50
|
+
if (this.stopped || this.sessionId) return;
|
|
51
|
+
if (this.call.state.callingState !== CallingState.RINGING) return;
|
|
52
|
+
|
|
53
|
+
// captured once: `call.ended` clears the call's current session
|
|
54
|
+
const sessionId = this.call.state.session?.id;
|
|
55
|
+
if (!sessionId) {
|
|
56
|
+
this.logger.warn('the call has no session');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.sessionId = sessionId;
|
|
60
|
+
|
|
61
|
+
const ring = this.call.state.settings?.ring;
|
|
62
|
+
const maxDurationMs =
|
|
63
|
+
ring?.auto_cancel_timeout_ms || ring?.missed_call_timeout_ms || 30_000;
|
|
64
|
+
this.deadlineAt = Date.now() + maxDurationMs;
|
|
65
|
+
|
|
66
|
+
// an incoming event proves the socket is alive, so the quiet period starts
|
|
67
|
+
// over: in a group ring a single rejection does not settle the ring.
|
|
68
|
+
this.unsubscribe.push(
|
|
69
|
+
this.call.on('call.accepted', () => this.armIdleWindow()),
|
|
70
|
+
this.call.on('call.rejected', () => this.armIdleWindow()),
|
|
71
|
+
this.call.on('call.missed', () => this.armIdleWindow()),
|
|
72
|
+
// the ring is over, whichever way it went. Joining an accepted call
|
|
73
|
+
// never goes through `leave`, so this is the only signal for it.
|
|
74
|
+
createSubscription(this.call.state.callingState$, (callingState) => {
|
|
75
|
+
if (ringIsOver(callingState)) this.stop();
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
this.armIdleWindow();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pauses polling, keeping the captured session, the deadline and the event
|
|
84
|
+
* subscriptions. Resuming does not extend the ring window.
|
|
85
|
+
*/
|
|
86
|
+
pause = () => {
|
|
87
|
+
if (this.stopped) return;
|
|
88
|
+
this.wasPolling = this.intervalId !== undefined;
|
|
89
|
+
const timers = getTimers();
|
|
90
|
+
timers.clearTimeout(this.idleTimeoutId);
|
|
91
|
+
timers.clearInterval(this.intervalId);
|
|
92
|
+
this.idleTimeoutId = undefined;
|
|
93
|
+
this.intervalId = undefined;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resumes a paused poller. Goes straight back to polling if it was already
|
|
98
|
+
* past the quiet period, so a pause does not buy the ring another one.
|
|
99
|
+
*/
|
|
100
|
+
resume = () => {
|
|
101
|
+
if (this.stopped || !this.sessionId) return;
|
|
102
|
+
if (this.idleTimeoutId !== undefined || this.intervalId !== undefined) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (!this.wasPolling) {
|
|
106
|
+
this.armIdleWindow();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.intervalId = getTimers().setInterval(this.runTick, this.intervalMs);
|
|
110
|
+
this.runTick();
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Stops polling. The poller cannot be restarted.
|
|
115
|
+
*/
|
|
116
|
+
stop = () => {
|
|
117
|
+
if (this.stopped) return;
|
|
118
|
+
this.stopped = true;
|
|
119
|
+
const timers = getTimers();
|
|
120
|
+
timers.clearTimeout(this.idleTimeoutId);
|
|
121
|
+
timers.clearInterval(this.intervalId);
|
|
122
|
+
this.idleTimeoutId = undefined;
|
|
123
|
+
this.intervalId = undefined;
|
|
124
|
+
this.unsubscribe.forEach((off) => off());
|
|
125
|
+
this.unsubscribe = [];
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
private armIdleWindow = () => {
|
|
129
|
+
if (this.stopped) return;
|
|
130
|
+
const timers = getTimers();
|
|
131
|
+
timers.clearTimeout(this.idleTimeoutId);
|
|
132
|
+
timers.clearInterval(this.intervalId);
|
|
133
|
+
this.intervalId = undefined;
|
|
134
|
+
this.idleTimeoutId = timers.setTimeout(() => {
|
|
135
|
+
this.idleTimeoutId = undefined;
|
|
136
|
+
if (this.stopped) return;
|
|
137
|
+
this.intervalId = timers.setInterval(this.runTick, this.intervalMs);
|
|
138
|
+
this.runTick();
|
|
139
|
+
}, this.startAfterMs);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
private runTick = () => {
|
|
143
|
+
this.tick().catch((err) => {
|
|
144
|
+
this.logger.warn('Failed to poll the ring state', err);
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
private tick = async () => {
|
|
149
|
+
if (this.stopped || this.inFlight || !this.sessionId) return;
|
|
150
|
+
const { callingState } = this.call.state;
|
|
151
|
+
if (ringIsOver(callingState) || Date.now() >= this.deadlineAt) {
|
|
152
|
+
this.stop();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// mid-join: nothing to reconcile until we know whether it succeeded
|
|
156
|
+
if (callingState !== CallingState.RINGING) return;
|
|
157
|
+
|
|
158
|
+
this.inFlight = true;
|
|
159
|
+
try {
|
|
160
|
+
const ringState = await this.call.getRingState(this.sessionId);
|
|
161
|
+
if (this.stopped) return;
|
|
162
|
+
this.call.state.updateFromRingState(ringState);
|
|
163
|
+
if (await reconcileRingState(this.call, 'ring-poll-api')) this.stop();
|
|
164
|
+
} catch (err) {
|
|
165
|
+
// a missing session, or one of another call, will never resolve
|
|
166
|
+
const status = err instanceof ErrorFromResponse ? err.status : undefined;
|
|
167
|
+
if (status === 400 || status === 404) {
|
|
168
|
+
this.logger.warn('Stopped polling the ring state', err);
|
|
169
|
+
this.stop();
|
|
170
|
+
} else {
|
|
171
|
+
this.logger.debug('Failed to poll the ring state', err);
|
|
172
|
+
}
|
|
173
|
+
} finally {
|
|
174
|
+
this.inFlight = false;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|