@stream-io/video-client 1.49.0 → 1.51.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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.browser.es.js +1404 -682
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1404 -682
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1404 -682
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +43 -3
  9. package/dist/src/coordinator/connection/client.d.ts +1 -1
  10. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  11. package/dist/src/coordinator/connection/types.d.ts +14 -0
  12. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  13. package/dist/src/devices/CameraManager.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +23 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +0 -1
  16. package/dist/src/devices/VirtualDevice.d.ts +59 -0
  17. package/dist/src/devices/devicePersistence.d.ts +1 -1
  18. package/dist/src/devices/index.d.ts +1 -0
  19. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  20. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  21. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  22. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  23. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  24. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  25. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  26. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  27. package/dist/src/helpers/browsers.d.ts +13 -0
  28. package/dist/src/helpers/concurrency.d.ts +6 -4
  29. package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
  30. package/dist/src/rtc/Publisher.d.ts +38 -3
  31. package/dist/src/rtc/Subscriber.d.ts +1 -0
  32. package/dist/src/rtc/TransceiverCache.d.ts +5 -1
  33. package/dist/src/rtc/helpers/degradationPreference.d.ts +3 -0
  34. package/dist/src/rtc/types.d.ts +2 -0
  35. package/dist/src/stats/rtc/types.d.ts +1 -1
  36. package/dist/src/store/rxUtils.d.ts +9 -0
  37. package/dist/src/types.d.ts +18 -0
  38. package/package.json +2 -2
  39. package/src/Call.ts +111 -33
  40. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  41. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  42. package/src/coordinator/connection/client.ts +1 -1
  43. package/src/coordinator/connection/connection.ts +149 -96
  44. package/src/coordinator/connection/types.ts +15 -0
  45. package/src/coordinator/connection/utils.ts +15 -0
  46. package/src/devices/CameraManager.ts +9 -2
  47. package/src/devices/DeviceManager.ts +239 -39
  48. package/src/devices/DeviceManagerState.ts +4 -2
  49. package/src/devices/VirtualDevice.ts +69 -0
  50. package/src/devices/__tests__/CameraManager.test.ts +19 -0
  51. package/src/devices/__tests__/DeviceManager.test.ts +404 -1
  52. package/src/devices/__tests__/mocks.ts +2 -0
  53. package/src/devices/devicePersistence.ts +2 -1
  54. package/src/devices/index.ts +1 -0
  55. package/src/gen/video/sfu/event/events.ts +15 -0
  56. package/src/gen/video/sfu/models/models.ts +44 -0
  57. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  58. package/src/helpers/BlockedAudioTracker.ts +74 -0
  59. package/src/helpers/DynascaleManager.ts +46 -337
  60. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  61. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  62. package/src/helpers/ViewportTracker.ts +74 -19
  63. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  64. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  65. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  66. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  67. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  68. package/src/helpers/__tests__/browsers.test.ts +85 -1
  69. package/src/helpers/browsers.ts +24 -0
  70. package/src/helpers/concurrency.ts +9 -10
  71. package/src/rtc/BasePeerConnection.ts +15 -3
  72. package/src/rtc/Publisher.ts +185 -40
  73. package/src/rtc/Subscriber.ts +42 -14
  74. package/src/rtc/TransceiverCache.ts +10 -3
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
  76. package/src/rtc/__tests__/Publisher.test.ts +747 -88
  77. package/src/rtc/__tests__/Subscriber.test.ts +148 -3
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +55 -0
  80. package/src/rtc/helpers/degradationPreference.ts +40 -0
  81. package/src/rtc/types.ts +2 -0
  82. package/src/stats/rtc/types.ts +1 -0
  83. package/src/store/__tests__/rxUtils.test.ts +276 -0
  84. package/src/store/rxUtils.ts +19 -0
  85. 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
- // local vars
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
- consecutiveFailures: number;
43
- pingInterval: number;
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
- isConnecting: boolean;
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
- rejectConnectionOpen?: (
53
- reason?: Error & {
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 {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
95
+ * @return Promise that completes once the first health check message is received
103
96
  */
104
- async connect(timeout = 15000) {
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 (error) {
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
- // @ts-expect-error type issue
135
- if (!error.isWSFailure) {
136
- // API rejected the connection and we should not retry
137
- throw new Error(
138
- JSON.stringify({
139
- // @ts-expect-error type issue
140
- code: error.code,
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 _waitForHealthy(timeout = 15000) {
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 (error: any) {
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
- * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
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 _connect() {
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.isConnectionIsPromisePending) {
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
- const response = await this.connectionOpen;
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 (err) {
326
- this.client._setupConnectionIdPromise();
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
- this.client.rejectConnectionId?.(err);
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 (error: any) {
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 Error & {
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 &&
@@ -176,9 +176,9 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
176
176
  return getVideoDevices(this.call.tracer);
177
177
  }
178
178
 
179
- protected override getStream(
179
+ protected override getResolvedConstraints(
180
180
  constraints: MediaTrackConstraints,
181
- ): Promise<MediaStream> {
181
+ ): MediaTrackConstraints {
182
182
  constraints.width = this.targetResolution.width;
183
183
  constraints.height = this.targetResolution.height;
184
184
  // We can't set both device id and facing mode
@@ -192,6 +192,13 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
192
192
  constraints.facingMode =
193
193
  this.state.direction === 'front' ? 'user' : 'environment';
194
194
  }
195
+
196
+ return constraints;
197
+ }
198
+
199
+ protected override getStream(
200
+ constraints: MediaTrackConstraints,
201
+ ): Promise<MediaStream> {
195
202
  return getVideoStream(constraints, this.call.tracer);
196
203
  }
197
204
  }