@stream-io/video-client 1.27.2 → 1.27.4

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.
@@ -139,7 +139,6 @@ export declare class StreamSfuClient {
139
139
  */
140
140
  constructor({ dispatcher, credentials, sessionId, cid, tag, joinResponseTimeout, onSignalClose, streamClient, enableTracing, }: StreamSfuClientConstructor);
141
141
  private createWebSocket;
142
- private cleanUpWebSocket;
143
142
  get isHealthy(): boolean;
144
143
  get joinTask(): Promise<JoinResponse>;
145
144
  private handleWebSocketClose;
@@ -10,3 +10,10 @@ export declare const isFirefox: () => boolean;
10
10
  * Checks whether the current browser is Google Chrome.
11
11
  */
12
12
  export declare const isChrome: () => boolean;
13
+ /**
14
+ * Checks whether the current browser is among the list of first-class supported browsers.
15
+ * This includes Chrome, Edge, Firefox, and Safari.
16
+ *
17
+ * Although the Stream Video SDK may work in other browsers, these are the ones we officially support.
18
+ */
19
+ export declare const isSupportedBrowser: () => Promise<boolean>;
@@ -1,6 +1,8 @@
1
1
  import { DispatchableMessage, SfuEventKinds } from './Dispatcher';
2
+ import { Tracer } from '../stats';
2
3
  export declare const createWebSocketSignalChannel: (opts: {
3
4
  endpoint: string;
4
5
  onMessage: <K extends SfuEventKinds>(message: DispatchableMessage<K>) => void;
5
6
  tag: string;
7
+ tracer: Tracer | undefined;
6
8
  }) => WebSocket;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.27.2",
3
+ "version": "1.27.4",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
@@ -274,6 +274,7 @@ export class StreamSfuClient {
274
274
  this.signalWs = createWebSocketSignalChannel({
275
275
  tag: this.tag,
276
276
  endpoint: `${this.credentials.server.ws_endpoint}?${new URLSearchParams(params).toString()}`,
277
+ tracer: this.tracer,
277
278
  onMessage: (message) => {
278
279
  this.lastMessageTimestamp = new Date();
279
280
  this.scheduleConnectionCheck();
@@ -285,10 +286,14 @@ export class StreamSfuClient {
285
286
  },
286
287
  });
287
288
 
289
+ let timeoutId: NodeJS.Timeout;
288
290
  this.signalReady = makeSafePromise(
289
291
  Promise.race<WebSocket>([
290
292
  new Promise((resolve, reject) => {
293
+ let didOpen = false;
291
294
  const onOpen = () => {
295
+ didOpen = true;
296
+ clearTimeout(timeoutId);
292
297
  this.signalWs.removeEventListener('open', onOpen);
293
298
  resolve(this.signalWs);
294
299
  };
@@ -298,28 +303,28 @@ export class StreamSfuClient {
298
303
  this.signalWs.addEventListener('close', (e) => {
299
304
  this.handleWebSocketClose(e);
300
305
  // Normally, this shouldn't have any effect, because WS should never emit 'close'
301
- // before emitting 'open'. However, strager things have happened, and we don't
302
- // want to leave signalReady in pending state.
303
- reject(
304
- new Error(`SFU WS closed or connection can't be established`),
305
- );
306
+ // before emitting 'open'. However, stranger things have happened, and we don't
307
+ // want to leave signalReady in a pending state.
308
+ const message = didOpen
309
+ ? `SFU WS closed: ${e.code} ${e.reason}`
310
+ : `SFU WS connection can't be established: ${e.code} ${e.reason}`;
311
+ this.tracer?.trace('signal.close', message);
312
+ clearTimeout(timeoutId);
313
+ reject(new Error(message));
306
314
  });
307
315
  }),
308
316
 
309
317
  new Promise((resolve, reject) => {
310
- setTimeout(
311
- () => reject(new Error('SFU WS connection timed out')),
312
- this.joinResponseTimeout,
313
- );
318
+ timeoutId = setTimeout(() => {
319
+ const message = `SFU WS connection failed to open after ${this.joinResponseTimeout}ms`;
320
+ this.tracer?.trace('signal.timeout', message);
321
+ reject(new Error(message));
322
+ }, this.joinResponseTimeout);
314
323
  }),
315
324
  ]),
316
325
  );
317
326
  };
318
327
 
319
- private cleanUpWebSocket = () => {
320
- this.signalWs.removeEventListener('close', this.handleWebSocketClose);
321
- };
322
-
323
328
  get isHealthy() {
324
329
  return (
325
330
  this.signalWs.readyState === WebSocket.OPEN &&
@@ -343,7 +348,7 @@ export class StreamSfuClient {
343
348
  if (this.signalWs.readyState === WebSocket.OPEN) {
344
349
  this.logger('debug', `Closing SFU WS connection: ${code} - ${reason}`);
345
350
  this.signalWs.close(code, `js-client: ${reason}`);
346
- this.cleanUpWebSocket();
351
+ this.signalWs.removeEventListener('close', this.handleWebSocketClose);
347
352
  }
348
353
  this.dispose();
349
354
  };
@@ -0,0 +1,191 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { isChrome, isFirefox, isSafari, isSupportedBrowser } from '../browsers';
3
+ import { getClientDetails } from '../client-details';
4
+ import { ClientDetails } from '../../gen/video/sfu/models/models';
5
+
6
+ describe('browsers', () => {
7
+ beforeEach(() => {
8
+ Object.defineProperty(globalThis, 'navigator', {
9
+ value: { userAgent: '' },
10
+ writable: true,
11
+ });
12
+ });
13
+
14
+ describe('isSafari', () => {
15
+ it('should return false if navigator is undefined', () => {
16
+ expect(isSafari()).toBe(false);
17
+ });
18
+
19
+ it('should return true for Safari user agent', () => {
20
+ // @ts-expect-error - mocking navigator
21
+ globalThis.navigator.userAgent =
22
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15';
23
+ expect(isSafari()).toBe(true);
24
+ });
25
+
26
+ it('should return false for Chrome user agent', () => {
27
+ // @ts-expect-error - mocking navigator
28
+ globalThis.navigator.userAgent =
29
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36';
30
+ expect(isSafari()).toBe(false);
31
+ });
32
+ });
33
+
34
+ describe('isFirefox', () => {
35
+ it('should return false if navigator is undefined', () => {
36
+ expect(isFirefox()).toBe(false);
37
+ });
38
+
39
+ it('should return true for Firefox user agent', () => {
40
+ // @ts-expect-error - mocking navigator
41
+ globalThis.navigator.userAgent =
42
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0';
43
+ expect(isFirefox()).toBe(true);
44
+ });
45
+
46
+ it('should return false for Chrome user agent', () => {
47
+ // @ts-expect-error - mocking navigator
48
+ globalThis.navigator.userAgent =
49
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36';
50
+ expect(isFirefox()).toBe(false);
51
+ });
52
+ });
53
+
54
+ describe('isChrome', () => {
55
+ it('should return false if navigator is undefined', () => {
56
+ expect(isChrome()).toBe(false);
57
+ });
58
+
59
+ it('should return true for Chrome user agent', () => {
60
+ // @ts-expect-error - mocking navigator
61
+ globalThis.navigator.userAgent =
62
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36';
63
+ expect(isChrome()).toBe(true);
64
+ });
65
+
66
+ it('should return false for Firefox user agent', () => {
67
+ // @ts-expect-error - mocking navigator
68
+ globalThis.navigator.userAgent =
69
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0';
70
+ expect(isChrome()).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe('isSupportedBrowser', () => {
75
+ vi.mock('../client-details', () => ({
76
+ getClientDetails: vi.fn(),
77
+ }));
78
+
79
+ it('should return false if browser is undefined', async () => {
80
+ vi.mocked(getClientDetails).mockResolvedValue({
81
+ browser: undefined,
82
+ } as ClientDetails);
83
+ expect(await isSupportedBrowser()).toBe(false);
84
+ });
85
+
86
+ it('should return true for supported Chrome version', async () => {
87
+ vi.mocked(getClientDetails).mockResolvedValue({
88
+ browser: { name: 'Chrome', version: '124' },
89
+ } as ClientDetails);
90
+ expect(await isSupportedBrowser()).toBe(true);
91
+ });
92
+
93
+ it('should return true for supported Chrome detailed version', async () => {
94
+ vi.mocked(getClientDetails).mockResolvedValue({
95
+ browser: { name: 'Chrome', version: '124.0.7204.158' },
96
+ } as ClientDetails);
97
+ expect(await isSupportedBrowser()).toBe(true);
98
+ });
99
+
100
+ it('should return false for unsupported Chrome version', async () => {
101
+ vi.mocked(getClientDetails).mockResolvedValue({
102
+ browser: { name: 'Chrome', version: '123' },
103
+ } as ClientDetails);
104
+ expect(await isSupportedBrowser()).toBe(false);
105
+ });
106
+
107
+ it('should return false for unsupported Chrome detailed version', async () => {
108
+ vi.mocked(getClientDetails).mockResolvedValue({
109
+ browser: { name: 'Chrome', version: '123.0.1234.99' },
110
+ } as ClientDetails);
111
+ expect(await isSupportedBrowser()).toBe(false);
112
+ });
113
+
114
+ it('should return true for supported Edge version', async () => {
115
+ vi.mocked(getClientDetails).mockResolvedValue({
116
+ browser: { name: 'Edge', version: '124' },
117
+ } as ClientDetails);
118
+ expect(await isSupportedBrowser()).toBe(true);
119
+ });
120
+
121
+ it('should return false for unsupported Edge version', async () => {
122
+ vi.mocked(getClientDetails).mockResolvedValue({
123
+ browser: { name: 'Edge', version: '123' },
124
+ } as ClientDetails);
125
+ expect(await isSupportedBrowser()).toBe(false);
126
+ });
127
+
128
+ it('should return true for supported Firefox version', async () => {
129
+ vi.mocked(getClientDetails).mockResolvedValue({
130
+ browser: { name: 'Firefox', version: '124' },
131
+ } as ClientDetails);
132
+ expect(await isSupportedBrowser()).toBe(true);
133
+ });
134
+
135
+ it('should return false for unsupported Firefox version', async () => {
136
+ vi.mocked(getClientDetails).mockResolvedValue({
137
+ browser: { name: 'Firefox', version: '123' },
138
+ } as ClientDetails);
139
+ expect(await isSupportedBrowser()).toBe(false);
140
+ });
141
+
142
+ it('should return true for supported Safari version', async () => {
143
+ vi.mocked(getClientDetails).mockResolvedValue({
144
+ browser: { name: 'Safari', version: '17' },
145
+ } as ClientDetails);
146
+ expect(await isSupportedBrowser()).toBe(true);
147
+ });
148
+
149
+ it('should return false for unsupported Safari version', async () => {
150
+ vi.mocked(getClientDetails).mockResolvedValue({
151
+ browser: { name: 'Safari', version: '16' },
152
+ } as ClientDetails);
153
+ expect(await isSupportedBrowser()).toBe(false);
154
+ });
155
+
156
+ it('should return true for supported WebKit version (WebView on iOS)', async () => {
157
+ vi.mocked(getClientDetails).mockResolvedValue({
158
+ browser: { name: 'WebKit', version: '605' },
159
+ } as ClientDetails);
160
+ expect(await isSupportedBrowser()).toBe(true);
161
+ });
162
+
163
+ it('should return false for unsupported WebKit version (WebView on iOS)', async () => {
164
+ vi.mocked(getClientDetails).mockResolvedValue({
165
+ browser: { name: 'WebKit', version: '604' },
166
+ } as ClientDetails);
167
+ expect(await isSupportedBrowser()).toBe(false);
168
+ });
169
+
170
+ it('should return true for supported WebView version (WebView on Android)', async () => {
171
+ vi.mocked(getClientDetails).mockResolvedValue({
172
+ browser: { name: 'WebView', version: '124' },
173
+ } as ClientDetails);
174
+ expect(await isSupportedBrowser()).toBe(true);
175
+ });
176
+
177
+ it('should return false for unsupported WebView version (WebView on Android)', async () => {
178
+ vi.mocked(getClientDetails).mockResolvedValue({
179
+ browser: { name: 'WebView', version: '123' },
180
+ } as ClientDetails);
181
+ expect(await isSupportedBrowser()).toBe(false);
182
+ });
183
+
184
+ it('should return false for unsupported browser', async () => {
185
+ vi.mocked(getClientDetails).mockResolvedValue({
186
+ browser: { name: 'Opera', version: '78' },
187
+ } as ClientDetails);
188
+ expect(await isSupportedBrowser()).toBe(false);
189
+ });
190
+ });
191
+ });
@@ -1,3 +1,5 @@
1
+ import { getClientDetails } from './client-details';
2
+
1
3
  /**
2
4
  * Checks whether the current browser is Safari.
3
5
  */
@@ -21,3 +23,26 @@ export const isChrome = () => {
21
23
  if (typeof navigator === 'undefined') return false;
22
24
  return navigator.userAgent?.includes('Chrome');
23
25
  };
26
+
27
+ /**
28
+ * Checks whether the current browser is among the list of first-class supported browsers.
29
+ * This includes Chrome, Edge, Firefox, and Safari.
30
+ *
31
+ * Although the Stream Video SDK may work in other browsers, these are the ones we officially support.
32
+ */
33
+ export const isSupportedBrowser = async (): Promise<boolean> => {
34
+ const { browser } = await getClientDetails();
35
+ if (!browser) return false; // we aren't running in a browser
36
+
37
+ const name = browser.name.toLowerCase();
38
+ const [major] = browser.version.split('.');
39
+ const version = parseInt(major, 10);
40
+ return (
41
+ (name.includes('chrome') && version >= 124) ||
42
+ (name.includes('edge') && version >= 124) ||
43
+ (name.includes('firefox') && version >= 124) ||
44
+ (name.includes('safari') && version >= 17) ||
45
+ (name.includes('webkit') && version >= 605) || // WebView on iOS
46
+ (name.includes('webview') && version >= 124) // WebView on Android
47
+ );
48
+ };
@@ -143,13 +143,18 @@ export const getClientDetails = async (): Promise<ClientDetails> => {
143
143
  // @ts-expect-error - userAgentData is not yet in the TS types
144
144
  const userAgentDataApi = navigator.userAgentData;
145
145
  let userAgentData:
146
- | { platform?: string; platformVersion?: string }
146
+ | {
147
+ platform?: string;
148
+ platformVersion?: string;
149
+ fullVersionList?: Array<{ brand: string; version: string }>;
150
+ }
147
151
  | undefined;
148
152
  if (userAgentDataApi && userAgentDataApi.getHighEntropyValues) {
149
153
  try {
150
154
  userAgentData = await userAgentDataApi.getHighEntropyValues([
151
155
  'platform',
152
156
  'platformVersion',
157
+ 'fullVersionList',
153
158
  ]);
154
159
  } catch {
155
160
  // Ignore the error
@@ -158,11 +163,21 @@ export const getClientDetails = async (): Promise<ClientDetails> => {
158
163
 
159
164
  const userAgent = new UAParser(navigator.userAgent);
160
165
  const { browser, os, device, cpu } = userAgent.getResult();
166
+ // If userAgentData is available, it means we are in a modern Chromium browser,
167
+ // and we can use it to get more accurate browser information.
168
+ // We hook into the fullVersionList to find the browser name and version and
169
+ // eventually detect exotic browsers like Samsung Internet, AVG Secure Browser, etc.
170
+ // who by default they identify themselves as "Chromium" in the user agent string.
171
+ // Eliminates the generic "Chromium" name and "Not)A_Brand" name from the list.
172
+ // https://wicg.github.io/ua-client-hints/#create-arbitrary-brands-section
173
+ const uaBrowser = userAgentData?.fullVersionList?.find(
174
+ (v) => !v.brand.includes('Chromium') && !v.brand.match(/[()\-./:;=?_]/g),
175
+ );
161
176
  return {
162
177
  sdk: sdkInfo,
163
178
  browser: {
164
- name: browser.name || navigator.userAgent,
165
- version: browser.version || '',
179
+ name: uaBrowser?.brand || browser.name || navigator.userAgent,
180
+ version: uaBrowser?.version || browser.version || '',
166
181
  },
167
182
  os: {
168
183
  name: userAgentData?.platform || os.name || '',
package/src/rtc/signal.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import { SfuEvent } from '../gen/video/sfu/event/events';
2
2
  import { getLogger } from '../logger';
3
3
  import { DispatchableMessage, SfuEventKinds } from './Dispatcher';
4
+ import { Tracer } from '../stats';
4
5
 
5
6
  export const createWebSocketSignalChannel = (opts: {
6
7
  endpoint: string;
7
8
  onMessage: <K extends SfuEventKinds>(message: DispatchableMessage<K>) => void;
8
9
  tag: string;
10
+ tracer: Tracer | undefined;
9
11
  }) => {
10
- const { endpoint, onMessage, tag } = opts;
12
+ const { endpoint, onMessage, tag, tracer } = opts;
11
13
  const logger = getLogger(['SfuClientWS', tag]);
12
14
  logger('debug', 'Creating signaling WS channel:', endpoint);
13
15
  const ws = new WebSocket(endpoint);
@@ -15,14 +17,17 @@ export const createWebSocketSignalChannel = (opts: {
15
17
 
16
18
  ws.addEventListener('error', (e) => {
17
19
  logger('error', 'Signaling WS channel error', e);
20
+ tracer?.trace('signal.ws.error', e);
18
21
  });
19
22
 
20
23
  ws.addEventListener('close', (e) => {
21
24
  logger('info', 'Signaling WS channel is closed', e);
25
+ tracer?.trace('signal.ws.close', e);
22
26
  });
23
27
 
24
28
  ws.addEventListener('open', (e) => {
25
29
  logger('info', 'Signaling WS channel is open', e);
30
+ tracer?.trace('signal.ws.open', e);
26
31
  });
27
32
 
28
33
  ws.addEventListener('message', (e) => {
@@ -34,11 +39,10 @@ export const createWebSocketSignalChannel = (opts: {
34
39
 
35
40
  onMessage(message as DispatchableMessage<SfuEventKinds>);
36
41
  } catch (err) {
37
- logger(
38
- 'error',
39
- 'Failed to decode a message. Check whether the Proto models match.',
40
- { event: e, error: err },
41
- );
42
+ const message =
43
+ 'Failed to decode a message. Check whether the Proto models match.';
44
+ logger('error', message, { event: e, error: err });
45
+ tracer?.trace('signal.ws.message.error', message);
42
46
  }
43
47
  });
44
48
  return ws;