@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
@@ -0,0 +1,310 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { fromPartial } from '@total-typescript/shoehorn';
3
+ import { TrackSubscriptionManager } from '../TrackSubscriptionManager';
4
+ import { CallState } from '../../store';
5
+ import { Tracer } from '../../stats';
6
+ import { DebounceType } from '../../types';
7
+ import { TrackType } from '../../gen/video/sfu/models/models';
8
+ import type { StreamSfuClient } from '../../StreamSfuClient';
9
+
10
+ describe('TrackSubscriptionManager', () => {
11
+ let state: CallState;
12
+ let tracer: Tracer;
13
+ let manager: TrackSubscriptionManager;
14
+ let updateSubscriptions: ReturnType<typeof vi.fn>;
15
+ let sfuClient: Pick<StreamSfuClient, 'updateSubscriptions'>;
16
+
17
+ const addParticipant = (
18
+ sessionId: string,
19
+ overrides: Partial<{
20
+ userId: string;
21
+ publishedTracks: TrackType[];
22
+ videoDimension: { width: number; height: number };
23
+ screenShareDimension: { width: number; height: number };
24
+ isLocalParticipant: boolean;
25
+ }> = {},
26
+ ) => {
27
+ state.updateOrAddParticipant(
28
+ sessionId,
29
+ fromPartial({
30
+ sessionId,
31
+ userId: overrides.userId ?? `user-${sessionId}`,
32
+ publishedTracks: overrides.publishedTracks ?? [],
33
+ videoDimension: overrides.videoDimension,
34
+ screenShareDimension: overrides.screenShareDimension,
35
+ isLocalParticipant: overrides.isLocalParticipant,
36
+ }),
37
+ );
38
+ };
39
+
40
+ beforeEach(() => {
41
+ state = new CallState();
42
+ tracer = new Tracer('test');
43
+ manager = new TrackSubscriptionManager(state, tracer);
44
+ updateSubscriptions = vi.fn().mockResolvedValue(undefined);
45
+ sfuClient = { updateSubscriptions };
46
+ manager.setSfuClient(sfuClient as StreamSfuClient);
47
+ });
48
+
49
+ // ---------------------------------------------------------------------
50
+ // subscriptions getter
51
+ // ---------------------------------------------------------------------
52
+
53
+ it('subscriptions returns empty for a call with no remote participants', () => {
54
+ expect(manager.subscriptions).toEqual([]);
55
+ });
56
+
57
+ it('subscriptions returns one VIDEO entry per published remote video track, skipping the local participant', () => {
58
+ addParticipant('local', {
59
+ isLocalParticipant: true,
60
+ publishedTracks: [TrackType.VIDEO],
61
+ videoDimension: { width: 640, height: 480 },
62
+ });
63
+ addParticipant('remote-a', {
64
+ publishedTracks: [TrackType.VIDEO],
65
+ videoDimension: { width: 320, height: 240 },
66
+ });
67
+ addParticipant('remote-b', {
68
+ publishedTracks: [TrackType.VIDEO],
69
+ videoDimension: { width: 1280, height: 720 },
70
+ });
71
+
72
+ const subs = manager.subscriptions;
73
+ expect(subs).toHaveLength(2);
74
+ expect(subs.every((s) => s.sessionId !== 'local')).toBe(true);
75
+ expect(subs.map((s) => s.trackType)).toEqual([
76
+ TrackType.VIDEO,
77
+ TrackType.VIDEO,
78
+ ]);
79
+ expect(subs.find((s) => s.sessionId === 'remote-a')?.dimension).toEqual({
80
+ width: 320,
81
+ height: 240,
82
+ });
83
+ });
84
+
85
+ it('subscriptions includes SCREEN_SHARE + SCREEN_SHARE_AUDIO entries when published', () => {
86
+ addParticipant('presenter', {
87
+ publishedTracks: [
88
+ TrackType.VIDEO,
89
+ TrackType.SCREEN_SHARE,
90
+ TrackType.SCREEN_SHARE_AUDIO,
91
+ ],
92
+ videoDimension: { width: 320, height: 240 },
93
+ screenShareDimension: { width: 1920, height: 1080 },
94
+ });
95
+
96
+ const subs = manager.subscriptions;
97
+ expect(subs).toHaveLength(3);
98
+ const trackTypes = new Set(subs.map((s) => s.trackType));
99
+ expect(trackTypes.has(TrackType.VIDEO)).toBe(true);
100
+ expect(trackTypes.has(TrackType.SCREEN_SHARE)).toBe(true);
101
+ expect(trackTypes.has(TrackType.SCREEN_SHARE_AUDIO)).toBe(true);
102
+ });
103
+
104
+ // ---------------------------------------------------------------------
105
+ // setOverrides
106
+ // ---------------------------------------------------------------------
107
+
108
+ it('applies a global override with a preferred dimension to every remote video subscription', () => {
109
+ addParticipant('a', {
110
+ publishedTracks: [TrackType.VIDEO],
111
+ videoDimension: { width: 320, height: 240 },
112
+ });
113
+ addParticipant('b', {
114
+ publishedTracks: [TrackType.VIDEO],
115
+ videoDimension: { width: 320, height: 240 },
116
+ });
117
+
118
+ manager.setOverrides({
119
+ enabled: true,
120
+ dimension: { width: 1280, height: 720 },
121
+ });
122
+
123
+ const subs = manager.subscriptions;
124
+ expect(subs).toHaveLength(2);
125
+ expect(subs.every((s) => s.dimension?.width === 1280)).toBe(true);
126
+ expect(subs.every((s) => s.dimension?.height === 720)).toBe(true);
127
+ });
128
+
129
+ it('applies a per-session override only to listed participants', () => {
130
+ addParticipant('a', {
131
+ publishedTracks: [TrackType.VIDEO],
132
+ videoDimension: { width: 320, height: 240 },
133
+ });
134
+ addParticipant('b', {
135
+ publishedTracks: [TrackType.VIDEO],
136
+ videoDimension: { width: 320, height: 240 },
137
+ });
138
+
139
+ manager.setOverrides(
140
+ { enabled: true, dimension: { width: 1280, height: 720 } },
141
+ ['a'],
142
+ );
143
+
144
+ const subs = manager.subscriptions;
145
+ const a = subs.find((s) => s.sessionId === 'a');
146
+ const b = subs.find((s) => s.sessionId === 'b');
147
+ expect(a?.dimension).toEqual({ width: 1280, height: 720 });
148
+ expect(b?.dimension).toEqual({ width: 320, height: 240 });
149
+ });
150
+
151
+ it('drops video from the subscription list when the override sets enabled=false globally', () => {
152
+ addParticipant('a', {
153
+ publishedTracks: [TrackType.VIDEO],
154
+ videoDimension: { width: 320, height: 240 },
155
+ });
156
+
157
+ manager.setOverrides({ enabled: false });
158
+
159
+ expect(manager.subscriptions).toEqual([]);
160
+ });
161
+
162
+ // ---------------------------------------------------------------------
163
+ // apply(): debouncing + SFU push
164
+ // ---------------------------------------------------------------------
165
+
166
+ it('apply() debounces rapid calls into one SFU RPC with the exact subscription payload', () => {
167
+ vi.useFakeTimers();
168
+ addParticipant('a', {
169
+ publishedTracks: [TrackType.VIDEO],
170
+ videoDimension: { width: 320, height: 240 },
171
+ });
172
+
173
+ manager.apply(DebounceType.FAST);
174
+ manager.apply(DebounceType.FAST);
175
+ manager.apply(DebounceType.FAST);
176
+
177
+ expect(updateSubscriptions).not.toHaveBeenCalled();
178
+ vi.advanceTimersByTime(DebounceType.FAST);
179
+ expect(updateSubscriptions).toHaveBeenCalledTimes(1);
180
+ const [payload] = updateSubscriptions.mock.calls[0];
181
+ expect(payload).toEqual([
182
+ {
183
+ userId: 'user-a',
184
+ sessionId: 'a',
185
+ trackType: TrackType.VIDEO,
186
+ dimension: { width: 320, height: 240 },
187
+ },
188
+ ]);
189
+ vi.useRealTimers();
190
+ });
191
+
192
+ it('apply(0) fires synchronously - no timer involved - with the exact subscription payload', () => {
193
+ addParticipant('a', {
194
+ publishedTracks: [TrackType.VIDEO],
195
+ videoDimension: { width: 320, height: 240 },
196
+ });
197
+
198
+ // DebounceType is a numeric enum; the implementation uses a truthy
199
+ // check, so `0` takes the synchronous branch.
200
+ manager.apply(0 as DebounceType);
201
+
202
+ expect(updateSubscriptions).toHaveBeenCalledTimes(1);
203
+ const [payload] = updateSubscriptions.mock.calls[0];
204
+ expect(payload).toEqual([
205
+ {
206
+ userId: 'user-a',
207
+ sessionId: 'a',
208
+ trackType: TrackType.VIDEO,
209
+ dimension: { width: 320, height: 240 },
210
+ },
211
+ ]);
212
+ });
213
+
214
+ it('apply() with DebounceType.SLOW (default) fires after 1200ms', () => {
215
+ vi.useFakeTimers();
216
+ addParticipant('a', {
217
+ publishedTracks: [TrackType.VIDEO],
218
+ videoDimension: { width: 320, height: 240 },
219
+ });
220
+
221
+ manager.apply();
222
+ expect(updateSubscriptions).not.toHaveBeenCalled();
223
+ vi.advanceTimersByTime(DebounceType.SLOW);
224
+ expect(updateSubscriptions).toHaveBeenCalledTimes(1);
225
+ vi.useRealTimers();
226
+ });
227
+
228
+ // ---------------------------------------------------------------------
229
+ // dispose() cancels pending timeout
230
+ // ---------------------------------------------------------------------
231
+
232
+ it('dispose() cancels a pending debounced push: no RPC fires after dispose', () => {
233
+ vi.useFakeTimers();
234
+ addParticipant('a', {
235
+ publishedTracks: [TrackType.VIDEO],
236
+ videoDimension: { width: 320, height: 240 },
237
+ });
238
+
239
+ manager.apply(DebounceType.FAST);
240
+ manager.dispose();
241
+ vi.advanceTimersByTime(DebounceType.FAST * 2);
242
+
243
+ expect(updateSubscriptions).not.toHaveBeenCalled();
244
+ vi.useRealTimers();
245
+ });
246
+
247
+ // ---------------------------------------------------------------------
248
+ // incomingVideoSettings$
249
+ // ---------------------------------------------------------------------
250
+
251
+ it('incomingVideoSettings$ reflects global + per-session overrides', () => {
252
+ let latest: unknown;
253
+ const sub = manager.incomingVideoSettings$.subscribe((v) => {
254
+ latest = v;
255
+ });
256
+
257
+ manager.setOverrides({
258
+ enabled: true,
259
+ dimension: { width: 1280, height: 720 },
260
+ });
261
+ manager.setOverrides({ enabled: false }, ['muted-session']);
262
+
263
+ expect(latest).toMatchObject({
264
+ enabled: true,
265
+ preferredResolution: { width: 1280, height: 720 },
266
+ participants: {
267
+ 'muted-session': { enabled: false, preferredResolution: undefined },
268
+ },
269
+ });
270
+ // isParticipantVideoEnabled helper honors per-session + global precedence.
271
+ expect(
272
+ (
273
+ latest as { isParticipantVideoEnabled: (id: string) => boolean }
274
+ ).isParticipantVideoEnabled('muted-session'),
275
+ ).toBe(false);
276
+ expect(
277
+ (
278
+ latest as { isParticipantVideoEnabled: (id: string) => boolean }
279
+ ).isParticipantVideoEnabled('other-session'),
280
+ ).toBe(true);
281
+
282
+ sub.unsubscribe();
283
+ });
284
+
285
+ it('incomingVideoSettings$ replays the latest value to a late subscriber (shareReplay(1))', () => {
286
+ // Set state BEFORE any subscriber attaches.
287
+ manager.setOverrides({
288
+ enabled: true,
289
+ dimension: { width: 1280, height: 720 },
290
+ });
291
+ manager.setOverrides({ enabled: false }, ['muted-session']);
292
+
293
+ let latest: unknown;
294
+ const sub = manager.incomingVideoSettings$.subscribe((v) => {
295
+ latest = v;
296
+ });
297
+
298
+ // The late subscriber must receive the buffered value synchronously
299
+ // on attach without needing a fresh setOverrides call.
300
+ expect(latest).toMatchObject({
301
+ enabled: true,
302
+ preferredResolution: { width: 1280, height: 720 },
303
+ participants: {
304
+ 'muted-session': { enabled: false, preferredResolution: undefined },
305
+ },
306
+ });
307
+
308
+ sub.unsubscribe();
309
+ });
310
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+
5
+ import '../../rtc/__tests__/mocks/webrtc.mocks';
6
+
7
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { Call } from '../../Call';
9
+ import { StreamClient } from '../../coordinator/connection/client';
10
+ import { StreamVideoWriteableStateStore } from '../../store';
11
+ import { noopComparator } from '../../sorting';
12
+ import { VisibilityState } from '../../types';
13
+ import { ViewportTracker } from '../ViewportTracker';
14
+
15
+ describe('ViewportTracker', () => {
16
+ let call: Call;
17
+ let viewportTracker: ViewportTracker;
18
+
19
+ beforeEach(() => {
20
+ call = new Call({
21
+ id: 'id',
22
+ type: 'default',
23
+ streamClient: new StreamClient('api-key', {
24
+ devicePersistence: { enabled: false },
25
+ }),
26
+ clientStore: new StreamVideoWriteableStateStore(),
27
+ });
28
+ call.setSortParticipantsBy(noopComparator());
29
+ viewportTracker = call.viewportTracker!;
30
+ });
31
+
32
+ it('is constructed by Call and exposed as call.viewportTracker', () => {
33
+ expect(viewportTracker).toBeInstanceOf(ViewportTracker);
34
+ });
35
+
36
+ it('updates participant viewportVisibilityState as visibility changes', () => {
37
+ let visibilityHandler:
38
+ | ((entry: IntersectionObserverEntry) => void)
39
+ | undefined;
40
+ vi.spyOn(viewportTracker, 'observe').mockImplementation((_el, handler) => {
41
+ visibilityHandler = handler;
42
+ return vi.fn();
43
+ });
44
+
45
+ // @ts-expect-error incomplete data
46
+ call.state.updateOrAddParticipant('session-id', {
47
+ userId: 'user-id',
48
+ sessionId: 'session-id',
49
+ publishedTracks: [],
50
+ });
51
+
52
+ const element = document.createElement('div');
53
+ const untrack = viewportTracker.trackElementVisibility(
54
+ element,
55
+ 'session-id',
56
+ 'videoTrack',
57
+ );
58
+
59
+ expect(visibilityHandler).toBeDefined();
60
+ expect(viewportTracker.observe).toHaveBeenCalledWith(
61
+ element,
62
+ expect.any(Function),
63
+ );
64
+
65
+ visibilityHandler!({ isIntersecting: true } as IntersectionObserverEntry);
66
+ expect(
67
+ call.state.findParticipantBySessionId('session-id')
68
+ ?.viewportVisibilityState?.videoTrack,
69
+ ).toBe(VisibilityState.VISIBLE);
70
+
71
+ visibilityHandler!({ isIntersecting: false } as IntersectionObserverEntry);
72
+ expect(
73
+ call.state.findParticipantBySessionId('session-id')
74
+ ?.viewportVisibilityState?.videoTrack,
75
+ ).toBe(VisibilityState.INVISIBLE);
76
+
77
+ untrack();
78
+ expect(
79
+ call.state.findParticipantBySessionId('session-id')
80
+ ?.viewportVisibilityState?.videoTrack,
81
+ ).toBe(VisibilityState.UNKNOWN);
82
+ });
83
+ });
@@ -1,5 +1,11 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { isChrome, isFirefox, isSafari, isSupportedBrowser } from '../browsers';
2
+ import {
3
+ isChrome,
4
+ isFirefox,
5
+ isSafari,
6
+ isSupportedBrowser,
7
+ isWebKit,
8
+ } from '../browsers';
3
9
  import { getClientDetails } from '../client-details';
4
10
  import { ClientDetails } from '../../gen/video/sfu/models/models';
5
11
 
@@ -31,6 +37,84 @@ describe('browsers', () => {
31
37
  });
32
38
  });
33
39
 
40
+ describe('isWebKit', () => {
41
+ it('should return false for an empty user agent', () => {
42
+ expect(isWebKit()).toBe(false);
43
+ });
44
+
45
+ it('should return true for Safari on macOS', () => {
46
+ // @ts-expect-error - mocking navigator
47
+ globalThis.navigator.userAgent =
48
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
49
+ expect(isWebKit()).toBe(true);
50
+ });
51
+
52
+ it('should return true for Safari on iOS', () => {
53
+ // @ts-expect-error - mocking navigator
54
+ globalThis.navigator.userAgent =
55
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1';
56
+ expect(isWebKit()).toBe(true);
57
+ });
58
+
59
+ it('should return true for the default iOS WKWebView (no "Safari" token)', () => {
60
+ // The key case isSafari misses: default WKWebView UA omits the
61
+ // Safari token unless the host sets `applicationNameForUserAgent`.
62
+ // @ts-expect-error - mocking navigator
63
+ globalThis.navigator.userAgent =
64
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148';
65
+ expect(isWebKit()).toBe(true);
66
+ });
67
+
68
+ it('should return false for Chrome on macOS', () => {
69
+ // @ts-expect-error - mocking navigator
70
+ globalThis.navigator.userAgent =
71
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
72
+ expect(isWebKit()).toBe(false);
73
+ });
74
+
75
+ it('should return true for Chrome on iOS (CriOS) — still WKWebView underneath', () => {
76
+ // @ts-expect-error - mocking navigator
77
+ globalThis.navigator.userAgent =
78
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148 Safari/604.1';
79
+ expect(isWebKit()).toBe(true);
80
+ });
81
+
82
+ it('should return true for Edge on iOS (EdgiOS) — still WKWebView underneath', () => {
83
+ // @ts-expect-error - mocking navigator
84
+ globalThis.navigator.userAgent =
85
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 EdgiOS/120.0.0.0 Mobile/15E148 Safari/604.1';
86
+ expect(isWebKit()).toBe(true);
87
+ });
88
+
89
+ it('should return true for Opera on iOS (OPiOS) — still WKWebView underneath', () => {
90
+ // @ts-expect-error - mocking navigator
91
+ globalThis.navigator.userAgent =
92
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 OPiOS/16.0.0 Mobile/15E148 Safari/9537.53';
93
+ expect(isWebKit()).toBe(true);
94
+ });
95
+
96
+ it('should return true for Firefox on iOS (FxiOS) — still WKWebView underneath', () => {
97
+ // @ts-expect-error - mocking navigator
98
+ globalThis.navigator.userAgent =
99
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/120.0 Mobile/15E148 Safari/605.1.15';
100
+ expect(isWebKit()).toBe(true);
101
+ });
102
+
103
+ it('should return false for Firefox on desktop (no AppleWebKit token)', () => {
104
+ // @ts-expect-error - mocking navigator
105
+ globalThis.navigator.userAgent =
106
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0';
107
+ expect(isWebKit()).toBe(false);
108
+ });
109
+
110
+ it('should return false for Android Chrome', () => {
111
+ // @ts-expect-error - mocking navigator
112
+ globalThis.navigator.userAgent =
113
+ 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
114
+ expect(isWebKit()).toBe(false);
115
+ });
116
+ });
117
+
34
118
  describe('isFirefox', () => {
35
119
  it('should return false if navigator is undefined', () => {
36
120
  expect(isFirefox()).toBe(false);
@@ -8,6 +8,30 @@ export const isSafari = () => {
8
8
  return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
9
9
  };
10
10
 
11
+ /**
12
+ * Checks whether the current runtime is a WebKit-engine browser.
13
+ *
14
+ * Returns true for desktop Safari, iOS Safari, bare iOS WKWebView hosts
15
+ * (in-app browsers, React Native WebView, Tauri-on-macOS, etc.), and for
16
+ * Chromium / Gecko-branded iOS browsers (`CriOS`, `EdgiOS`, `OPiOS`,
17
+ * `FxiOS`) since Apple forces every iOS browser onto WKWebView and they
18
+ * share the underlying WebKit quirks.
19
+ *
20
+ * Returns false for desktop Chromium-based browsers (which reuse the
21
+ * `AppleWebKit/` token in their UA) and Android.
22
+ */
23
+ export const isWebKit = () => {
24
+ if (typeof navigator === 'undefined') return false;
25
+ const ua = navigator.userAgent || '';
26
+ if (!/AppleWebKit\//.test(ua)) return false;
27
+ // Desktop Chromium reuses the AppleWebKit/ token. The `Chrome/` and
28
+ // `Chromium/` markers are only present on desktop Chromium builds
29
+ // (their iOS counterparts use `CriOS/` instead). `Android` rules out
30
+ // the mobile Blink stack.
31
+ const regExp = /Chrome\/|Chromium\/|Android/;
32
+ return !regExp.test(ua);
33
+ };
34
+
11
35
  /**
12
36
  * Checks whether the current browser is Firefox.
13
37
  */
@@ -3,8 +3,10 @@ interface PendingPromise {
3
3
  onContinued: () => void;
4
4
  }
5
5
 
6
+ type TagKey = string | symbol | object;
7
+
6
8
  type AsyncWrapper<P extends unknown[], T> = (
7
- tag: string | symbol,
9
+ tag: TagKey,
8
10
  cb: (...args: P) => Promise<T>,
9
11
  ) => {
10
12
  cb: () => Promise<T>;
@@ -41,13 +43,13 @@ export const withoutConcurrency = createRunner(wrapWithContinuationTracking);
41
43
  */
42
44
  export const withCancellation = createRunner(wrapWithCancellation);
43
45
 
44
- const pendingPromises = new Map<string | symbol, PendingPromise>();
46
+ const pendingPromises = new Map<TagKey, PendingPromise>();
45
47
 
46
- export function hasPending(tag: string | symbol) {
48
+ export function hasPending(tag: TagKey) {
47
49
  return pendingPromises.has(tag);
48
50
  }
49
51
 
50
- export async function settled(tag: string | symbol) {
52
+ export async function settled(tag: TagKey) {
51
53
  let pending: PendingPromise | undefined;
52
54
  while ((pending = pendingPromises.get(tag))) {
53
55
  await pending.promise;
@@ -66,7 +68,7 @@ export async function settled(tag: string | symbol) {
66
68
  * is defined by the wrapper.
67
69
  */
68
70
  function createRunner<P extends unknown[], T>(wrapper: AsyncWrapper<P, T>) {
69
- return function run(tag: string | symbol, cb: (...args: P) => Promise<T>) {
71
+ return function run(tag: TagKey, cb: (...args: P) => Promise<T>) {
70
72
  const { cb: wrapped, onContinued } = wrapper(tag, cb);
71
73
  const pending = pendingPromises.get(tag);
72
74
  pending?.onContinued();
@@ -83,10 +85,7 @@ function createRunner<P extends unknown[], T>(wrapper: AsyncWrapper<P, T>) {
83
85
  * if the function is the last in the queue, it cleans up the whole chain
84
86
  * of promises after finishing.
85
87
  */
86
- function wrapWithContinuationTracking<T>(
87
- tag: string | symbol,
88
- cb: () => Promise<T>,
89
- ) {
88
+ function wrapWithContinuationTracking<T>(tag: TagKey, cb: () => Promise<T>) {
90
89
  let hasContinuation = false;
91
90
  const wrapped = () =>
92
91
  cb().finally(() => {
@@ -108,7 +107,7 @@ function wrapWithContinuationTracking<T>(
108
107
  * of promises after finishing.
109
108
  */
110
109
  function wrapWithCancellation<T>(
111
- tag: string | symbol,
110
+ tag: TagKey,
112
111
  cb: (signal: AbortSignal) => Promise<T | 'canceled'>,
113
112
  ) {
114
113
  const ac = new AbortController();
@@ -42,7 +42,7 @@ export abstract class BasePeerConnection {
42
42
  private iceRestartTimeout?: NodeJS.Timeout;
43
43
  private preConnectStuckTimeout?: NodeJS.Timeout;
44
44
  protected isIceRestarting = false;
45
- private isDisposed = false;
45
+ protected isDisposed = false;
46
46
 
47
47
  protected trackIdToTrackType = new Map<string, TrackType>();
48
48
 
@@ -115,7 +115,7 @@ export abstract class BasePeerConnection {
115
115
  /**
116
116
  * Disposes the `RTCPeerConnection` instance.
117
117
  */
118
- dispose() {
118
+ async dispose(): Promise<void> {
119
119
  clearTimeout(this.iceRestartTimeout);
120
120
  this.iceRestartTimeout = undefined;
121
121
  clearTimeout(this.preConnectStuckTimeout);
@@ -141,6 +141,10 @@ export abstract class BasePeerConnection {
141
141
  this.onIceConnectionStateChange,
142
142
  );
143
143
  pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
144
+ pc.removeEventListener(
145
+ 'connectionstatechange',
146
+ this.onConnectionStateChange,
147
+ );
144
148
  this.unsubscribeIceTrickle?.();
145
149
  this.subscriptions.forEach((unsubscribe) => unsubscribe());
146
150
  this.subscriptions = [];
@@ -183,7 +187,7 @@ export abstract class BasePeerConnection {
183
187
  const getTag = () => this.tag;
184
188
  this.subscriptions.push(
185
189
  this.dispatcher.on(event, getTag, (e) => {
186
- const lockKey = `pc.${this.lock}.${event}`;
190
+ const lockKey = this.eventLockKey(event);
187
191
  withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
188
192
  if (this.isDisposed) return;
189
193
  this.logger.warn(`Error handling ${event}`, err);
@@ -192,6 +196,14 @@ export abstract class BasePeerConnection {
192
196
  );
193
197
  };
194
198
 
199
+ /**
200
+ * Returns the per-event `withoutConcurrency` tag used to serialize the
201
+ * dispatcher handler for `event` on this peer connection.
202
+ */
203
+ protected eventLockKey = (event: keyof AllSfuEvents): string => {
204
+ return `pc.${this.lock}.${event}`;
205
+ };
206
+
195
207
  /**
196
208
  * Appends the trickled ICE candidates to the `RTCPeerConnection`.
197
209
  */