@stream-io/video-client 1.49.0 → 1.50.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 +11 -0
- package/dist/index.browser.es.js +1086 -594
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1086 -594
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1086 -594
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +42 -3
- package/dist/src/coordinator/connection/client.d.ts +1 -1
- package/dist/src/coordinator/connection/connection.d.ts +31 -25
- package/dist/src/coordinator/connection/types.d.ts +14 -0
- package/dist/src/coordinator/connection/utils.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +3 -0
- package/dist/src/devices/DeviceManagerState.d.ts +0 -1
- package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
- package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
- package/dist/src/helpers/DynascaleManager.d.ts +8 -86
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
- package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
- package/dist/src/helpers/ViewportTracker.d.ts +11 -17
- package/dist/src/helpers/browsers.d.ts +13 -0
- package/dist/src/helpers/concurrency.d.ts +6 -4
- package/dist/src/rtc/Publisher.d.ts +17 -0
- package/dist/src/rtc/Subscriber.d.ts +1 -0
- package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
- package/dist/src/stats/rtc/types.d.ts +1 -1
- package/dist/src/store/rxUtils.d.ts +9 -0
- package/dist/src/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/Call.ts +89 -22
- package/src/__tests__/Call.lifecycle.test.ts +67 -0
- package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
- package/src/coordinator/connection/client.ts +1 -1
- package/src/coordinator/connection/connection.ts +149 -96
- package/src/coordinator/connection/types.ts +15 -0
- package/src/coordinator/connection/utils.ts +15 -0
- package/src/devices/DeviceManager.ts +92 -32
- package/src/devices/DeviceManagerState.ts +0 -1
- package/src/devices/__tests__/DeviceManager.test.ts +283 -0
- package/src/devices/__tests__/mocks.ts +2 -0
- package/src/gen/video/sfu/event/events.ts +15 -0
- package/src/gen/video/sfu/models/models.ts +44 -0
- package/src/helpers/AudioBindingsWatchdog.ts +10 -7
- package/src/helpers/BlockedAudioTracker.ts +74 -0
- package/src/helpers/DynascaleManager.ts +46 -337
- package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
- package/src/helpers/TrackSubscriptionManager.ts +243 -0
- package/src/helpers/ViewportTracker.ts +74 -19
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
- package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
- package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
- package/src/helpers/__tests__/browsers.test.ts +85 -1
- package/src/helpers/browsers.ts +24 -0
- package/src/helpers/concurrency.ts +9 -10
- package/src/rtc/Publisher.ts +47 -1
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/__tests__/Publisher.test.ts +122 -10
- package/src/rtc/__tests__/Subscriber.test.ts +146 -1
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
- package/src/rtc/helpers/degradationPreference.ts +22 -0
- package/src/stats/rtc/types.ts +1 -0
- package/src/store/__tests__/rxUtils.test.ts +276 -0
- package/src/store/rxUtils.ts +19 -0
- package/src/types.ts +19 -0
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
retryInterval,
|
|
8
8
|
sleep,
|
|
9
9
|
} from './utils';
|
|
10
|
-
import type { StreamVideoEvent, UR } from './types';
|
|
10
|
+
import type { StreamVideoEvent, UR, WSConnectionError } from './types';
|
|
11
11
|
import type { LogLevel } from '@stream-io/logger';
|
|
12
12
|
import type {
|
|
13
13
|
ConnectedEvent,
|
|
@@ -36,59 +36,52 @@ import { APIErrorCodes } from './errors';
|
|
|
36
36
|
* - if the servers fails to publish a message to the client, the WS connection is destroyed
|
|
37
37
|
*/
|
|
38
38
|
export class StableWSConnection {
|
|
39
|
-
//
|
|
39
|
+
// Parent client reference.
|
|
40
|
+
client: StreamClient;
|
|
41
|
+
|
|
42
|
+
// Underlying WebSocket. wsID is bumped on each new connection so stale
|
|
43
|
+
// event handlers from previous sockets can be ignored.
|
|
44
|
+
ws?: WebSocket;
|
|
45
|
+
/** Incremented when a new WS connection is made */
|
|
46
|
+
wsID = 1;
|
|
47
|
+
|
|
48
|
+
// Connection lifecycle flags.
|
|
49
|
+
/** We only make 1 attempt to reconnect at the same time.. */
|
|
50
|
+
isConnecting = false;
|
|
51
|
+
/** To avoid reconnect if client is disconnected */
|
|
52
|
+
isDisconnected = false;
|
|
53
|
+
/** Boolean that indicates if we have a working connection to the server */
|
|
54
|
+
isHealthy = false;
|
|
55
|
+
|
|
56
|
+
// Open-connection promise: resolves on `connection.ok`, rejects on close/error.
|
|
40
57
|
connectionID?: string;
|
|
41
58
|
private connectionOpenSafe?: SafePromise<ConnectedEvent>;
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
resolveConnectionOpen?: (value: ConnectedEvent) => void;
|
|
60
|
+
rejectConnectionOpen?: (reason?: WSConnectionError) => void;
|
|
61
|
+
/** Boolean that indicates if the connection promise is resolved */
|
|
62
|
+
isConnectionOpenResolved?: boolean = false;
|
|
63
|
+
|
|
64
|
+
// Failure counters (drive retry/backoff scheduling).
|
|
65
|
+
/** consecutive failures influence the duration of the timeout */
|
|
66
|
+
consecutiveFailures = 0;
|
|
67
|
+
/** keep track of the total number of failures */
|
|
68
|
+
totalFailures = 0;
|
|
69
|
+
|
|
70
|
+
// Health-check pings + connection-staleness check.
|
|
71
|
+
/** Send a health check message every 25 seconds */
|
|
72
|
+
pingInterval = 25 * 1000;
|
|
44
73
|
healthCheckTimeoutRef?: number;
|
|
45
|
-
|
|
46
|
-
isDisconnected: boolean;
|
|
47
|
-
isHealthy: boolean;
|
|
48
|
-
isConnectionOpenResolved?: boolean;
|
|
49
|
-
lastEvent: Date | null;
|
|
50
|
-
connectionCheckTimeout: number;
|
|
74
|
+
connectionCheckTimeout = this.pingInterval + 10 * 1000;
|
|
51
75
|
connectionCheckTimeoutRef?: NodeJS.Timeout;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
code?: string | number;
|
|
55
|
-
isWSFailure?: boolean;
|
|
56
|
-
StatusCode?: string | number;
|
|
57
|
-
},
|
|
58
|
-
) => void;
|
|
59
|
-
resolveConnectionOpen?: (value: ConnectedEvent) => void;
|
|
60
|
-
totalFailures: number;
|
|
61
|
-
ws?: WebSocket;
|
|
62
|
-
wsID: number;
|
|
63
|
-
|
|
64
|
-
client: StreamClient;
|
|
76
|
+
/** Store the last event time for health checks */
|
|
77
|
+
lastEvent: Date | null = null;
|
|
65
78
|
|
|
66
79
|
constructor(client: StreamClient) {
|
|
67
80
|
this.client = client;
|
|
68
|
-
/** consecutive failures influence the duration of the timeout */
|
|
69
|
-
this.consecutiveFailures = 0;
|
|
70
|
-
/** keep track of the total number of failures */
|
|
71
|
-
this.totalFailures = 0;
|
|
72
|
-
/** We only make 1 attempt to reconnect at the same time.. */
|
|
73
|
-
this.isConnecting = false;
|
|
74
|
-
/** To avoid reconnect if client is disconnected */
|
|
75
|
-
this.isDisconnected = false;
|
|
76
|
-
/** Boolean that indicates if the connection promise is resolved */
|
|
77
|
-
this.isConnectionOpenResolved = false;
|
|
78
|
-
/** Boolean that indicates if we have a working connection to the server */
|
|
79
|
-
this.isHealthy = false;
|
|
80
|
-
/** Incremented when a new WS connection is made */
|
|
81
|
-
this.wsID = 1;
|
|
82
|
-
/** Store the last event time for health checks */
|
|
83
|
-
this.lastEvent = null;
|
|
84
|
-
/** Send a health check message every 25 seconds */
|
|
85
|
-
this.pingInterval = 25 * 1000;
|
|
86
|
-
this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
|
|
87
|
-
|
|
88
81
|
addConnectionEventListeners(this.onlineStatusChanged);
|
|
89
82
|
}
|
|
90
83
|
|
|
91
|
-
_log = (msg: string, extra: UR = {}, level: LogLevel = 'info') => {
|
|
84
|
+
_log = (msg: string, extra: UR | Error = {}, level: LogLevel = 'info') => {
|
|
92
85
|
this.client.logger[level](`connection:${msg}`, extra);
|
|
93
86
|
};
|
|
94
87
|
|
|
@@ -99,9 +92,9 @@ export class StableWSConnection {
|
|
|
99
92
|
/**
|
|
100
93
|
* connect - Connect to the WS URL
|
|
101
94
|
* the default 15s timeout allows between 2~3 tries
|
|
102
|
-
* @return
|
|
95
|
+
* @return Promise that completes once the first health check message is received
|
|
103
96
|
*/
|
|
104
|
-
async
|
|
97
|
+
connect = async (timeout = 15000): Promise<ConnectedEvent | undefined> => {
|
|
105
98
|
if (this.isConnecting) {
|
|
106
99
|
throw Error(
|
|
107
100
|
`You've called connect twice, can only attempt 1 connection at the time`,
|
|
@@ -111,18 +104,18 @@ export class StableWSConnection {
|
|
|
111
104
|
this.isDisconnected = false;
|
|
112
105
|
|
|
113
106
|
try {
|
|
114
|
-
const healthCheck = await this._connect();
|
|
107
|
+
const healthCheck = await this._connect(timeout);
|
|
115
108
|
this.consecutiveFailures = 0;
|
|
116
109
|
|
|
117
110
|
this._log(
|
|
118
111
|
`connect() - Established ws connection with healthcheck: ${healthCheck}`,
|
|
119
112
|
);
|
|
120
|
-
} catch (
|
|
113
|
+
} catch (caught) {
|
|
114
|
+
const error = caught as WSConnectionError;
|
|
121
115
|
this.isHealthy = false;
|
|
122
116
|
this.consecutiveFailures += 1;
|
|
123
117
|
|
|
124
118
|
if (
|
|
125
|
-
// @ts-expect-error type issue
|
|
126
119
|
error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
127
120
|
!this.client.tokenManager.isStatic()
|
|
128
121
|
) {
|
|
@@ -130,42 +123,44 @@ export class StableWSConnection {
|
|
|
130
123
|
'connect() - WS failure due to expired token, so going to try to reload token and reconnect',
|
|
131
124
|
);
|
|
132
125
|
this._reconnect({ refreshToken: true });
|
|
126
|
+
} else if (!error.isWSFailure) {
|
|
127
|
+
// API rejected the connection and we should not retry
|
|
128
|
+
throw new Error(
|
|
129
|
+
JSON.stringify({
|
|
130
|
+
code: error.code,
|
|
131
|
+
StatusCode: error.StatusCode,
|
|
132
|
+
message: error.message,
|
|
133
|
+
isWSFailure: error.isWSFailure,
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
133
136
|
} else {
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
// @ts-expect-error type issue
|
|
142
|
-
StatusCode: error.StatusCode,
|
|
143
|
-
// @ts-expect-error type issue
|
|
144
|
-
message: error.message,
|
|
145
|
-
// @ts-expect-error type issue
|
|
146
|
-
isWSFailure: error.isWSFailure,
|
|
147
|
-
}),
|
|
148
|
-
);
|
|
149
|
-
}
|
|
137
|
+
// Transient WS failure (e.g., handshake watchdog). Kick off a
|
|
138
|
+
// reconnect chain so _waitForHealthy(timeout) below has something
|
|
139
|
+
// to poll for. Owning the trigger here (rather than inside
|
|
140
|
+
// _connect()'s catch) keeps a single failure from spawning two
|
|
141
|
+
// parallel chains - one from this catch and one from _reconnect's
|
|
142
|
+
// own catch when _connect was called from there.
|
|
143
|
+
this._reconnect();
|
|
150
144
|
}
|
|
151
145
|
}
|
|
152
146
|
|
|
153
147
|
return await this._waitForHealthy(timeout);
|
|
154
|
-
}
|
|
148
|
+
};
|
|
155
149
|
|
|
156
150
|
/**
|
|
157
151
|
* _waitForHealthy polls the promise connection to see if its resolved until it times out
|
|
158
152
|
* the default 15s timeout allows between 2~3 tries
|
|
159
153
|
* @param timeout duration(ms)
|
|
160
154
|
*/
|
|
161
|
-
async
|
|
155
|
+
_waitForHealthy = async (timeout = 15000) => {
|
|
162
156
|
return Promise.race([
|
|
163
157
|
(async () => {
|
|
164
158
|
const interval = 50; // ms
|
|
165
159
|
for (let i = 0; i <= timeout; i += interval) {
|
|
166
160
|
try {
|
|
167
161
|
return await this.connectionOpen;
|
|
168
|
-
} catch (
|
|
162
|
+
} catch (caught) {
|
|
163
|
+
const error = caught as WSConnectionError;
|
|
169
164
|
if (i === timeout) {
|
|
170
165
|
throw new Error(
|
|
171
166
|
JSON.stringify({
|
|
@@ -193,7 +188,7 @@ export class StableWSConnection {
|
|
|
193
188
|
);
|
|
194
189
|
})(),
|
|
195
190
|
]);
|
|
196
|
-
}
|
|
191
|
+
};
|
|
197
192
|
|
|
198
193
|
/**
|
|
199
194
|
* Builds and returns the url for websocket.
|
|
@@ -211,9 +206,8 @@ export class StableWSConnection {
|
|
|
211
206
|
|
|
212
207
|
/**
|
|
213
208
|
* disconnect - Disconnect the connection and doesn't recover...
|
|
214
|
-
*
|
|
215
209
|
*/
|
|
216
|
-
disconnect(timeout?: number) {
|
|
210
|
+
disconnect = (timeout?: number) => {
|
|
217
211
|
this._log(
|
|
218
212
|
`disconnect() - Closing the websocket connection for wsID ${this.wsID}`,
|
|
219
213
|
);
|
|
@@ -275,16 +269,30 @@ export class StableWSConnection {
|
|
|
275
269
|
delete this.ws;
|
|
276
270
|
|
|
277
271
|
return isClosedPromise;
|
|
278
|
-
}
|
|
272
|
+
};
|
|
279
273
|
|
|
280
274
|
/**
|
|
281
275
|
* _connect - Connect to the WS endpoint
|
|
282
276
|
*
|
|
283
|
-
* @
|
|
277
|
+
* @param timeoutMs handshake watchdog deadline in ms. Defaults to
|
|
278
|
+
* `client.defaultWSTimeout` when not provided. Top-level `connect(timeout)`
|
|
279
|
+
* passes its own timeout through so caller-supplied deadlines are honored.
|
|
280
|
+
* @return Promise that completes once the first health check message is received
|
|
284
281
|
*/
|
|
285
|
-
async
|
|
282
|
+
_connect = async (
|
|
283
|
+
timeoutMs?: number,
|
|
284
|
+
): Promise<ConnectedEvent | undefined> => {
|
|
286
285
|
if (this.isConnecting) return; // ignore _connect if it's currently trying to connect
|
|
287
286
|
this.isConnecting = true;
|
|
287
|
+
// Snapshot of the connection-id reject closure owned by THIS attempt.
|
|
288
|
+
// Captured at function entry so that even early failures (e.g.,
|
|
289
|
+
// tokenManager.loadToken throwing before we reach the WS phase) can
|
|
290
|
+
// settle the promise the caller is awaiting. Re-captured below if
|
|
291
|
+
// _connect itself sets up a fresh promise. If a concurrent
|
|
292
|
+
// openConnection() rotates `client.rejectConnectionId` later, our
|
|
293
|
+
// captured closure still settles only the original promise (P1) and
|
|
294
|
+
// never poisons the newer one (P2).
|
|
295
|
+
let ownRejectConnectionId = this.client.rejectConnectionId;
|
|
288
296
|
let isTokenReady = false;
|
|
289
297
|
try {
|
|
290
298
|
this._log(`_connect() - waiting for token`);
|
|
@@ -302,8 +310,11 @@ export class StableWSConnection {
|
|
|
302
310
|
await this.client.tokenManager.loadToken();
|
|
303
311
|
}
|
|
304
312
|
|
|
305
|
-
if (!this.client.
|
|
313
|
+
if (!this.client.isConnectionIdPromisePending) {
|
|
306
314
|
this.client._setupConnectionIdPromise();
|
|
315
|
+
// recapture: we just rotated the resolver ourselves, the new
|
|
316
|
+
// closure is the one bound to the promise this attempt owns.
|
|
317
|
+
ownRejectConnectionId = this.client.rejectConnectionId;
|
|
307
318
|
}
|
|
308
319
|
this._setupConnectionPromise();
|
|
309
320
|
const wsURL = this._buildUrl();
|
|
@@ -314,23 +325,74 @@ export class StableWSConnection {
|
|
|
314
325
|
this.ws.onclose = this.onclose.bind(this, this.wsID);
|
|
315
326
|
this.ws.onerror = this.onerror.bind(this, this.wsID);
|
|
316
327
|
this.ws.onmessage = this.onmessage.bind(this, this.wsID);
|
|
317
|
-
|
|
328
|
+
|
|
329
|
+
// race the WS handshake against an explicit deadline so a silent
|
|
330
|
+
// network drop (e.g., carrier NAT or firewall) cannot wedge _connect()
|
|
331
|
+
const handshakeTimeout = timeoutMs ?? this.client.defaultWSTimeout;
|
|
332
|
+
const timers = getTimers();
|
|
333
|
+
let handshakeTimeoutId: number | undefined;
|
|
334
|
+
let response: ConnectedEvent | undefined;
|
|
335
|
+
try {
|
|
336
|
+
response = await Promise.race<ConnectedEvent | undefined>([
|
|
337
|
+
this.connectionOpen as Promise<ConnectedEvent>,
|
|
338
|
+
new Promise<never>((_, reject) => {
|
|
339
|
+
handshakeTimeoutId = timers.setTimeout(() => {
|
|
340
|
+
const err: WSConnectionError = new Error(
|
|
341
|
+
`WS handshake timed out after ${handshakeTimeout}ms`,
|
|
342
|
+
);
|
|
343
|
+
err.isWSFailure = true;
|
|
344
|
+
reject(err);
|
|
345
|
+
}, handshakeTimeout);
|
|
346
|
+
}),
|
|
347
|
+
]);
|
|
348
|
+
} finally {
|
|
349
|
+
timers.clearTimeout(handshakeTimeoutId);
|
|
350
|
+
}
|
|
318
351
|
this.isConnecting = false;
|
|
319
352
|
|
|
353
|
+
// If we were disconnected during the handshake (e.g. closeConnection()
|
|
354
|
+
// ran while a background _reconnect's _connect was in flight), tear
|
|
355
|
+
// down the new WS and throw so the caller of connect() does not get
|
|
356
|
+
// a misleading "success" for a connection that has already been
|
|
357
|
+
// aborted. We must NOT skip the throw and just return undefined: the
|
|
358
|
+
// outer connect() would otherwise fall through to _waitForHealthy(),
|
|
359
|
+
// which would observe the already-resolved connectionOpen promise
|
|
360
|
+
// and resolve with a ConnectedEvent for a torn-down connection.
|
|
361
|
+
if (this.isDisconnected) {
|
|
362
|
+
if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
|
|
363
|
+
this._destroyCurrentWSConnection();
|
|
364
|
+
}
|
|
365
|
+
throw new Error(
|
|
366
|
+
'WS handshake aborted: disconnect() ran while connecting',
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
320
370
|
if (response) {
|
|
321
371
|
this.connectionID = response.connection_id;
|
|
322
372
|
this.client.resolveConnectionId?.(this.connectionID);
|
|
323
373
|
return response;
|
|
324
374
|
}
|
|
325
|
-
} catch (
|
|
326
|
-
|
|
375
|
+
} catch (caught) {
|
|
376
|
+
const err = caught as WSConnectionError;
|
|
327
377
|
this.isConnecting = false;
|
|
328
|
-
// @ts-expect-error type issue
|
|
329
378
|
this._log(`_connect() - Error - `, err);
|
|
330
|
-
|
|
379
|
+
// Reject THIS attempt's connection-id promise (P1) directly via the
|
|
380
|
+
// captured closure. Whether or not a concurrent openConnection() has
|
|
381
|
+
// since rotated client.rejectConnectionId to a newer promise (P2),
|
|
382
|
+
// calling ownRejectConnectionId only settles P1 - P2 is untouched.
|
|
383
|
+
// P1's awaiters (e.g., doAxiosRequest awaiting connectionIdPromise)
|
|
384
|
+
// therefore fail fast instead of being orphaned.
|
|
385
|
+
ownRejectConnectionId?.(err);
|
|
386
|
+
// connectionOpen is per-instance and not subject to rotation, so
|
|
387
|
+
// calling it unconditionally is safe (and a no-op if already settled).
|
|
388
|
+
this.rejectConnectionOpen?.(err);
|
|
389
|
+
// tear down a half-open WS so it does not linger and fire a stale wsID later
|
|
390
|
+
if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
|
|
391
|
+
this._destroyCurrentWSConnection();
|
|
392
|
+
}
|
|
331
393
|
throw err;
|
|
332
394
|
}
|
|
333
|
-
}
|
|
395
|
+
};
|
|
334
396
|
|
|
335
397
|
/**
|
|
336
398
|
* _reconnect - Retry the connection to WS endpoint
|
|
@@ -388,7 +450,8 @@ export class StableWSConnection {
|
|
|
388
450
|
this._log('_reconnect() - Finished recoverCallBack');
|
|
389
451
|
|
|
390
452
|
this.consecutiveFailures = 0;
|
|
391
|
-
} catch (
|
|
453
|
+
} catch (caught) {
|
|
454
|
+
const error = caught as WSConnectionError;
|
|
392
455
|
this.isHealthy = false;
|
|
393
456
|
this.consecutiveFailures += 1;
|
|
394
457
|
if (
|
|
@@ -416,7 +479,6 @@ export class StableWSConnection {
|
|
|
416
479
|
* onlineStatusChanged - this function is called when the browser connects or disconnects from the internet.
|
|
417
480
|
*
|
|
418
481
|
* @param {Event} event Event with type online or offline
|
|
419
|
-
*
|
|
420
482
|
*/
|
|
421
483
|
onlineStatusChanged = (event: Event) => {
|
|
422
484
|
if (event.type === 'offline') {
|
|
@@ -538,19 +600,14 @@ export class StableWSConnection {
|
|
|
538
600
|
this._log('onclose() - onclose callback - ' + event.code, { event, wsID });
|
|
539
601
|
|
|
540
602
|
if (event.code === KnownCodes.WS_CLOSED_SUCCESS) {
|
|
541
|
-
// this is a permanent error raised by stream
|
|
603
|
+
// this is a permanent error raised by stream.
|
|
542
604
|
// usually caused by invalid auth details
|
|
543
|
-
const error = new Error(
|
|
605
|
+
const error: WSConnectionError = new Error(
|
|
544
606
|
`WS connection reject with error ${event.reason}`,
|
|
545
607
|
);
|
|
546
|
-
|
|
547
|
-
// @ts-expect-error type issue
|
|
548
608
|
error.reason = event.reason;
|
|
549
|
-
// @ts-expect-error type issue
|
|
550
609
|
error.code = event.code;
|
|
551
|
-
// @ts-expect-error type issue
|
|
552
610
|
error.wasClean = event.wasClean;
|
|
553
|
-
// @ts-expect-error type issue
|
|
554
611
|
error.target = event.target;
|
|
555
612
|
|
|
556
613
|
this.rejectConnectionOpen?.(error);
|
|
@@ -622,7 +679,7 @@ export class StableWSConnection {
|
|
|
622
679
|
private _errorFromWSEvent = (
|
|
623
680
|
event: CloseEvent | ConnectionErrorEvent,
|
|
624
681
|
isWSFailure = true,
|
|
625
|
-
) => {
|
|
682
|
+
): WSConnectionError => {
|
|
626
683
|
let code: number;
|
|
627
684
|
let statusCode: number;
|
|
628
685
|
let message: string;
|
|
@@ -639,11 +696,7 @@ export class StableWSConnection {
|
|
|
639
696
|
|
|
640
697
|
const msg = `WS failed with code: ${code}: ${APIErrorCodes[code] || code} and reason: ${message}`;
|
|
641
698
|
this._log(msg, { event }, 'warn');
|
|
642
|
-
const error = new Error(msg) as
|
|
643
|
-
code?: number;
|
|
644
|
-
isWSFailure?: boolean;
|
|
645
|
-
StatusCode?: number;
|
|
646
|
-
};
|
|
699
|
+
const error = new Error(msg) as WSConnectionError;
|
|
647
700
|
error.code = code;
|
|
648
701
|
/**
|
|
649
702
|
* StatusCode does not exist on any event types but has been left
|
|
@@ -42,6 +42,21 @@ export type APIErrorResponse = {
|
|
|
42
42
|
unrecoverable?: boolean;
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A standard `Error` augmented with the metadata that the coordinator
|
|
47
|
+
* WebSocket connection layer attaches to rejection causes.
|
|
48
|
+
* `isWSFailure: true` marks transient/retriable failures; absent or `false`
|
|
49
|
+
* indicates a permanent failure that should not be retried.
|
|
50
|
+
*/
|
|
51
|
+
export type WSConnectionError = Error & {
|
|
52
|
+
code?: string | number;
|
|
53
|
+
isWSFailure?: boolean;
|
|
54
|
+
StatusCode?: string | number;
|
|
55
|
+
reason?: string;
|
|
56
|
+
wasClean?: boolean;
|
|
57
|
+
target?: EventTarget | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
45
60
|
export class ErrorFromResponse<T> extends Error {
|
|
46
61
|
public code: number | null;
|
|
47
62
|
public status: number;
|
|
@@ -4,6 +4,21 @@ import type { ConnectionErrorEvent } from '../../gen/coordinator';
|
|
|
4
4
|
|
|
5
5
|
export const sleep = (m: number) => new Promise((r) => setTimeout(r, m));
|
|
6
6
|
|
|
7
|
+
export const timeboxed = async <T extends Promise<unknown>[]>(
|
|
8
|
+
promises: [...T],
|
|
9
|
+
ms: number,
|
|
10
|
+
): Promise<{ [K in keyof T]: Awaited<T[K]> }> => {
|
|
11
|
+
let timerId: ReturnType<typeof setTimeout>;
|
|
12
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
13
|
+
timerId = setTimeout(() => reject(new Error('timebox error')), ms);
|
|
14
|
+
});
|
|
15
|
+
try {
|
|
16
|
+
return await Promise.race([Promise.all(promises), timeout]);
|
|
17
|
+
} finally {
|
|
18
|
+
clearTimeout(timerId!);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
7
22
|
export function isFunction<T>(value: Function | T): value is Function {
|
|
8
23
|
return (
|
|
9
24
|
value &&
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type InputDeviceStatus,
|
|
10
10
|
} from './DeviceManagerState';
|
|
11
11
|
import { isMobile } from '../helpers/compatibility';
|
|
12
|
+
import { isWebKit } from '../helpers/browsers';
|
|
12
13
|
import { isReactNative } from '../helpers/platforms';
|
|
13
14
|
import { ScopedLogger, videoLoggerSystem } from '../logger';
|
|
14
15
|
import { TrackType } from '../gen/video/sfu/models/models';
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
MediaStreamFilterEntry,
|
|
25
26
|
MediaStreamFilterRegistrationResult,
|
|
26
27
|
} from './filters';
|
|
28
|
+
import { pushToIfMissing, removeFromIfPresent } from '../helpers/array';
|
|
27
29
|
import {
|
|
28
30
|
createSyntheticDevice,
|
|
29
31
|
defaultDeviceId,
|
|
@@ -49,6 +51,7 @@ export abstract class DeviceManager<
|
|
|
49
51
|
protected readonly call: Call;
|
|
50
52
|
protected readonly trackType: TrackType;
|
|
51
53
|
protected subscriptions: (() => void)[] = [];
|
|
54
|
+
protected currentStreamCleanups: (() => void)[] = [];
|
|
52
55
|
protected devicePersistence: Required<DevicePersistenceOptions>;
|
|
53
56
|
protected areSubscriptionsSetUp = false;
|
|
54
57
|
private isTrackStoppedDueToTrackEnd = false;
|
|
@@ -292,11 +295,17 @@ export abstract class DeviceManager<
|
|
|
292
295
|
* @internal
|
|
293
296
|
*/
|
|
294
297
|
dispose = () => {
|
|
298
|
+
this.runCurrentStreamCleanups();
|
|
295
299
|
this.subscriptions.forEach((s) => s());
|
|
296
300
|
this.subscriptions = [];
|
|
297
301
|
this.areSubscriptionsSetUp = false;
|
|
298
302
|
};
|
|
299
303
|
|
|
304
|
+
private runCurrentStreamCleanups = () => {
|
|
305
|
+
this.currentStreamCleanups.forEach((c) => c());
|
|
306
|
+
this.currentStreamCleanups = [];
|
|
307
|
+
};
|
|
308
|
+
|
|
300
309
|
protected async applySettingsToStream() {
|
|
301
310
|
await withCancellation(this.statusChangeConcurrencyTag, async (signal) => {
|
|
302
311
|
if (this.enabled) {
|
|
@@ -353,7 +362,9 @@ export abstract class DeviceManager<
|
|
|
353
362
|
// @ts-expect-error called to dispose the stream in RN
|
|
354
363
|
mediaStream.release();
|
|
355
364
|
}
|
|
365
|
+
this.runCurrentStreamCleanups();
|
|
356
366
|
this.state.setMediaStream(undefined, undefined);
|
|
367
|
+
this.setLocalInterrupted(false);
|
|
357
368
|
this.filters.forEach((entry) => entry.stop?.());
|
|
358
369
|
}
|
|
359
370
|
}
|
|
@@ -390,7 +401,7 @@ export abstract class DeviceManager<
|
|
|
390
401
|
protected async unmuteStream() {
|
|
391
402
|
this.logger.debug('Starting stream');
|
|
392
403
|
let stream: MediaStream;
|
|
393
|
-
let
|
|
404
|
+
let rootStreamPromise: Promise<MediaStream> | undefined;
|
|
394
405
|
if (
|
|
395
406
|
this.state.mediaStream &&
|
|
396
407
|
this.getTracks().every((t) => t.readyState === 'live')
|
|
@@ -398,6 +409,11 @@ export abstract class DeviceManager<
|
|
|
398
409
|
stream = this.state.mediaStream;
|
|
399
410
|
this.enableTracks();
|
|
400
411
|
} else {
|
|
412
|
+
// We are about to compose a fresh filter chain and acquire a new
|
|
413
|
+
// root stream. Drop any listeners bound to the previous root stream
|
|
414
|
+
// before chainWith below registers new ones for the new chain.
|
|
415
|
+
this.runCurrentStreamCleanups();
|
|
416
|
+
|
|
401
417
|
const defaultConstraints = this.state.defaultConstraints;
|
|
402
418
|
const constraints: MediaTrackConstraints = {
|
|
403
419
|
...defaultConstraints,
|
|
@@ -455,7 +471,7 @@ export abstract class DeviceManager<
|
|
|
455
471
|
});
|
|
456
472
|
};
|
|
457
473
|
parentTrack.addEventListener('ended', handleParentTrackEnded);
|
|
458
|
-
this.
|
|
474
|
+
this.currentStreamCleanups.push(() => {
|
|
459
475
|
parentTrack.removeEventListener('ended', handleParentTrackEnded);
|
|
460
476
|
});
|
|
461
477
|
});
|
|
@@ -465,7 +481,7 @@ export abstract class DeviceManager<
|
|
|
465
481
|
|
|
466
482
|
// the rootStream represents the stream coming from the actual device
|
|
467
483
|
// e.g. camera or microphone stream
|
|
468
|
-
|
|
484
|
+
rootStreamPromise = this.getStream(constraints as C);
|
|
469
485
|
// we publish the last MediaStream of the chain
|
|
470
486
|
stream = await this.filters.reduce(
|
|
471
487
|
(parent, entry) =>
|
|
@@ -482,45 +498,89 @@ export abstract class DeviceManager<
|
|
|
482
498
|
);
|
|
483
499
|
return parent;
|
|
484
500
|
}),
|
|
485
|
-
|
|
501
|
+
rootStreamPromise,
|
|
486
502
|
);
|
|
487
503
|
}
|
|
488
504
|
if (this.call.state.callingState === CallingState.JOINED) {
|
|
489
505
|
await this.publishStream(stream);
|
|
490
506
|
}
|
|
491
507
|
if (this.state.mediaStream !== stream) {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
this.
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
508
|
+
const rootStream = await rootStreamPromise;
|
|
509
|
+
this.state.setMediaStream(stream, rootStream);
|
|
510
|
+
if (rootStream) {
|
|
511
|
+
const handleTrackEnded = async () => {
|
|
512
|
+
this.setLocalInterrupted(false);
|
|
513
|
+
await this.statusChangeSettled();
|
|
514
|
+
if (this.enabled) {
|
|
515
|
+
this.isTrackStoppedDueToTrackEnd = true;
|
|
516
|
+
setTimeout(() => {
|
|
517
|
+
this.isTrackStoppedDueToTrackEnd = false;
|
|
518
|
+
}, 2000);
|
|
519
|
+
await this.disable();
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
const createTrackMuteHandler = (muted: boolean) => () => {
|
|
523
|
+
this.setLocalInterrupted(muted);
|
|
524
|
+
|
|
525
|
+
// WebKit's RTCRtpSender encoder can stay stalled after an iOS /
|
|
526
|
+
// macOS audio session interruption even though the track is
|
|
527
|
+
// unmuted. Re-arm the sender on every unmute for any WebKit
|
|
528
|
+
// runtime (Safari + plain iOS WKWebViews). Skipped when the
|
|
529
|
+
// page is hidden because the encoder won't resume until
|
|
530
|
+
// foreground anyway.
|
|
531
|
+
if (!muted && isWebKit() && document.visibilityState !== 'hidden') {
|
|
532
|
+
this.call.refreshPublishedTrack(this.trackType).catch((err) => {
|
|
533
|
+
this.logger.warn('Failed to refresh track on system unmute', err);
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// report all tracks on mobile, and only Video on desktop browsers
|
|
538
|
+
if (isMobile() || this.trackType == TrackType.VIDEO) {
|
|
539
|
+
this.call.tracer.trace('navigator.mediaDevices.muteStateUpdated', {
|
|
540
|
+
trackType: TrackType[this.trackType],
|
|
541
|
+
muted,
|
|
542
|
+
});
|
|
543
|
+
this.call
|
|
544
|
+
.notifyTrackMuteState(muted, this.trackType)
|
|
545
|
+
.catch((err) => {
|
|
546
|
+
this.logger.warn('Error while notifying track mute state', err);
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
rootStream.getTracks().forEach((track) => {
|
|
551
|
+
const muteHandler = createTrackMuteHandler(true);
|
|
552
|
+
const unmuteHandler = createTrackMuteHandler(false);
|
|
553
|
+
track.addEventListener('mute', muteHandler);
|
|
554
|
+
track.addEventListener('unmute', unmuteHandler);
|
|
555
|
+
track.addEventListener('ended', handleTrackEnded);
|
|
556
|
+
this.currentStreamCleanups.push(() => {
|
|
557
|
+
track.removeEventListener('mute', muteHandler);
|
|
558
|
+
track.removeEventListener('unmute', unmuteHandler);
|
|
559
|
+
track.removeEventListener('ended', handleTrackEnded);
|
|
560
|
+
});
|
|
519
561
|
});
|
|
520
|
-
|
|
562
|
+
const initialMuted = rootStream.getTracks().some((t) => t.muted);
|
|
563
|
+
this.setLocalInterrupted(initialMuted);
|
|
564
|
+
} else {
|
|
565
|
+
this.setLocalInterrupted(false);
|
|
566
|
+
}
|
|
521
567
|
}
|
|
522
568
|
}
|
|
523
569
|
|
|
570
|
+
private setLocalInterrupted = (interrupted: boolean) => {
|
|
571
|
+
const localParticipant = this.call.state.localParticipant;
|
|
572
|
+
if (!localParticipant) return;
|
|
573
|
+
this.call.state.updateParticipant(localParticipant.sessionId, (p) => {
|
|
574
|
+
const current = p.interruptedTracks ?? [];
|
|
575
|
+
const has = current.includes(this.trackType);
|
|
576
|
+
if (interrupted === has) return {};
|
|
577
|
+
const next = interrupted
|
|
578
|
+
? pushToIfMissing([...current], this.trackType)
|
|
579
|
+
: removeFromIfPresent([...current], this.trackType);
|
|
580
|
+
return { interruptedTracks: next };
|
|
581
|
+
});
|
|
582
|
+
};
|
|
583
|
+
|
|
524
584
|
private get mediaDeviceKind(): MediaDeviceKind {
|
|
525
585
|
if (this.trackType === TrackType.AUDIO) return 'audioinput';
|
|
526
586
|
if (this.trackType === TrackType.VIDEO) return 'videoinput';
|