@stream-io/video-client 1.55.0 → 1.55.1

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.
@@ -40,6 +40,7 @@ export declare class StableWSConnection {
40
40
  consecutiveFailures: number;
41
41
  /** keep track of the total number of failures */
42
42
  totalFailures: number;
43
+ lastConnectionError?: WSConnectionError;
43
44
  /** Send a health check message every 25 seconds */
44
45
  pingInterval: number;
45
46
  healthCheckTimeoutRef?: number;
@@ -83,6 +83,7 @@ export type PublishBundle = {
83
83
  transceiver: RTCRtpTransceiver;
84
84
  options: TrackPublishOptions;
85
85
  videoSender?: VideoSender;
86
+ negotiated?: boolean;
86
87
  };
87
88
  export type TrackLayersCache = {
88
89
  publishOption: PublishOption;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.55.0",
3
+ "version": "1.55.1",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
@@ -46,7 +46,7 @@
46
46
  "@openapitools/openapi-generator-cli": "^2.34.0",
47
47
  "@rollup/plugin-replace": "^6.0.3",
48
48
  "@rollup/plugin-typescript": "^12.3.0",
49
- "@stream-io/audio-filters-web": "^0.9.0",
49
+ "@stream-io/audio-filters-web": "^0.9.1",
50
50
  "@stream-io/node-sdk": "^0.7.59",
51
51
  "@stream-io/typescript-config": "^0.0.0",
52
52
  "@total-typescript/shoehorn": "^0.1.2",
@@ -215,6 +215,53 @@ describe('StableWSConnection - silent handshake hang', () => {
215
215
  expect(outcome.kind).toBe('rejected');
216
216
  });
217
217
 
218
+ it('preserves an initial WS close reason when reconnect cannot get healthy', async () => {
219
+ const client = new StreamClient('test-key', {
220
+ browser: false,
221
+ defaultWsTimeout: 5000,
222
+ WebSocketImpl: ManualWebSocket as unknown as typeof WebSocket,
223
+ timeout: 1000,
224
+ });
225
+ vi.spyOn(client.tokenManager, 'tokenReady').mockResolvedValue('fake-token');
226
+ vi.spyOn(client.tokenManager, 'loadToken').mockResolvedValue('fake-token');
227
+ vi.spyOn(client.tokenManager, 'getToken').mockReturnValue('fake-token');
228
+
229
+ client._setUser({ id: 'test-user' });
230
+ client.userID = 'test-user';
231
+ client.clientID = 'test-user--abcdef';
232
+ client._setupConnectionIdPromise();
233
+
234
+ const wsConnection = new StableWSConnection(client);
235
+ client.wsConnection = wsConnection;
236
+
237
+ const connectAttemptOutcome = wsConnection.connect(5000).then(
238
+ () => ({ kind: 'resolved' as const }),
239
+ (error: Error) => ({ kind: 'rejected' as const, error }),
240
+ );
241
+
242
+ await vi.advanceTimersByTimeAsync(0);
243
+ const ws = ManualWebSocket.instances.at(-1)!;
244
+ expect(ws).toBeDefined();
245
+
246
+ ws.onclose?.({
247
+ code: 1006,
248
+ reason: 'specific ws close reason',
249
+ wasClean: false,
250
+ target: ws,
251
+ });
252
+
253
+ await vi.advanceTimersByTimeAsync(20000);
254
+ const outcome = await connectAttemptOutcome;
255
+
256
+ expect(outcome.kind).toBe('rejected');
257
+ if (outcome.kind === 'rejected') {
258
+ expect(outcome.error.message).toContain('specific ws close reason');
259
+ expect(outcome.error.message).not.toContain(
260
+ 'initial WS connection could not be established',
261
+ );
262
+ }
263
+ });
264
+
218
265
  it('does not schedule a reconnect (and leaves connectionIdPromise rejected) on a permanent, non-WS failure', async () => {
219
266
  const client = new StreamClient('test-key', {
220
267
  browser: false,
@@ -66,6 +66,7 @@ export class StableWSConnection {
66
66
  consecutiveFailures = 0;
67
67
  /** keep track of the total number of failures */
68
68
  totalFailures = 0;
69
+ lastConnectionError?: WSConnectionError;
69
70
 
70
71
  // Health-check pings + connection-staleness check.
71
72
  /** Send a health check message every 25 seconds */
@@ -102,6 +103,7 @@ export class StableWSConnection {
102
103
  }
103
104
 
104
105
  this.isDisconnected = false;
106
+ this.lastConnectionError = undefined;
105
107
 
106
108
  try {
107
109
  const healthCheck = await this._connect(timeout);
@@ -140,6 +142,7 @@ export class StableWSConnection {
140
142
  // _connect()'s catch) keeps a single failure from spawning two
141
143
  // parallel chains - one from this catch and one from _reconnect's
142
144
  // own catch when _connect was called from there.
145
+ this.lastConnectionError = error;
143
146
  this._reconnect();
144
147
  }
145
148
  }
@@ -179,14 +182,22 @@ export class StableWSConnection {
179
182
  (async () => {
180
183
  await sleep(timeout);
181
184
  this.isConnecting = false;
182
- throw new Error(
183
- JSON.stringify({
184
- code: '',
185
- StatusCode: '',
186
- message: 'initial WS connection could not be established',
187
- isWSFailure: true,
188
- }),
189
- );
185
+ const e = this.lastConnectionError;
186
+ const errorPayload = e
187
+ ? {
188
+ code: e.code,
189
+ StatusCode: e.StatusCode,
190
+ message: e.message,
191
+ isWSFailure: e.isWSFailure,
192
+ }
193
+ : {
194
+ code: '',
195
+ StatusCode: '',
196
+ message: 'initial WS connection could not be established',
197
+ isWSFailure: true,
198
+ };
199
+
200
+ throw new Error(JSON.stringify(errorPayload));
190
201
  })(),
191
202
  ]);
192
203
  };
@@ -376,6 +387,7 @@ export class StableWSConnection {
376
387
  return undefined;
377
388
  } catch (caught) {
378
389
  const err = caught as WSConnectionError;
390
+ this.lastConnectionError = err;
379
391
  this.isConnecting = false;
380
392
  this._log(`_connect() - Error - `, err);
381
393
  // Reject THIS attempt's connection-id promise (P1) directly via the
@@ -187,6 +187,9 @@ export class Publisher extends BasePeerConnection {
187
187
  if (isAudioTrackType(trackType)) {
188
188
  await this.updateAudioPublishOptions(trackType, options);
189
189
  }
190
+ if (track && !bundle.negotiated) {
191
+ await this.negotiate();
192
+ }
190
193
  };
191
194
 
192
195
  /**
@@ -490,6 +493,10 @@ export class Publisher extends BasePeerConnection {
490
493
 
491
494
  const { sdp: answerSdp } = response;
492
495
  await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
496
+
497
+ for (const bundle of this.transceiverCache.items()) {
498
+ if (bundle.transceiver.sender.track) bundle.negotiated = true;
499
+ }
493
500
  } catch (err) {
494
501
  // negotiation failed, rollback to the previous state
495
502
  if (this.pc.signalingState === 'have-local-offer') {
@@ -157,6 +157,7 @@ describe('Publisher', () => {
157
157
  publishOption: publisher['publishOptions'][0],
158
158
  transceiver,
159
159
  options: {},
160
+ negotiated: true,
160
161
  });
161
162
 
162
163
  await publisher.publish(track, TrackType.VIDEO);
@@ -166,6 +167,75 @@ describe('Publisher', () => {
166
167
  expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
167
168
  expect(track.stop).toHaveBeenCalled();
168
169
  });
170
+
171
+ it('should not renegotiate when reusing an already-negotiated transceiver', async () => {
172
+ const track = new MediaStreamTrack();
173
+ const clone = new MediaStreamTrack();
174
+ vi.spyOn(track, 'clone').mockReturnValue(clone);
175
+
176
+ const transceiver = new RTCRtpTransceiver();
177
+ // @ts-expect-error test setup
178
+ transceiver.sender.track = track;
179
+ publisher['transceiverCache'].add({
180
+ publishOption: publisher['publishOptions'][0],
181
+ transceiver,
182
+ options: {},
183
+ negotiated: true,
184
+ });
185
+
186
+ // @ts-expect-error - private method
187
+ const negotiateSpy = vi.spyOn(publisher, 'negotiate').mockResolvedValue();
188
+
189
+ await publisher.publish(track, TrackType.VIDEO);
190
+
191
+ expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
192
+ expect(negotiateSpy).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it('should renegotiate on republish when a previous negotiation never reached the SFU (SetPublisher timeout)', async () => {
196
+ const track = new MediaStreamTrack();
197
+ const transceiver = new RTCRtpTransceiver();
198
+ // @ts-expect-error test setup
199
+ transceiver.sender.track = track;
200
+ const bundle = {
201
+ publishOption: publisher['publishOptions'][0],
202
+ transceiver,
203
+ options: {},
204
+ negotiated: false,
205
+ };
206
+ publisher['transceiverCache'].add(bundle);
207
+
208
+ vi.spyOn(publisher['pc'], 'createOffer')
209
+ // @ts-expect-error TS picks up the wrong overload
210
+ .mockResolvedValue({ sdp: 'offer-sdp', type: 'offer' });
211
+ vi.spyOn(publisher['pc'], 'setLocalDescription').mockResolvedValue();
212
+ vi.spyOn(publisher['pc'], 'setRemoteDescription').mockResolvedValue();
213
+ vi.spyOn(publisher, 'getAnnouncedTracks').mockReturnValue([
214
+ // @ts-expect-error incomplete data
215
+ { trackId: '123' },
216
+ ]);
217
+
218
+ sfuClient.setPublisher = vi
219
+ .fn()
220
+ .mockRejectedValue(new Error('SetPublisherTimeout'));
221
+ await expect(publisher['negotiate']()).rejects.toThrow(
222
+ 'SetPublisherTimeout',
223
+ );
224
+ expect(bundle.negotiated).toBe(false);
225
+
226
+ const clone = new MediaStreamTrack();
227
+ vi.spyOn(track, 'clone').mockReturnValue(clone);
228
+ sfuClient.setPublisher = vi
229
+ .fn()
230
+ .mockResolvedValue({ response: { sdp: 'answer-sdp' } });
231
+
232
+ await publisher.publish(track, TrackType.VIDEO);
233
+
234
+ expect(publisher['pc'].addTransceiver).not.toHaveBeenCalled();
235
+ expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
236
+ expect(sfuClient.setPublisher).toHaveBeenCalled();
237
+ expect(bundle.negotiated).toBe(true);
238
+ });
169
239
  });
170
240
 
171
241
  describe('Event Handling', () => {
@@ -1457,6 +1527,7 @@ describe('Publisher', () => {
1457
1527
  publishOption: publisher['publishOptions'][0],
1458
1528
  transceiver,
1459
1529
  options: {},
1530
+ negotiated: true,
1460
1531
  });
1461
1532
 
1462
1533
  // stopping seeds the bundle's videoSender from the current encoder
@@ -1515,11 +1586,13 @@ describe('Publisher', () => {
1515
1586
  publishOption: publisher['publishOptions'][0],
1516
1587
  transceiver: vp8Transceiver,
1517
1588
  options: {},
1589
+ negotiated: true,
1518
1590
  });
1519
1591
  publisher['transceiverCache'].add({
1520
1592
  publishOption: publisher['publishOptions'][1],
1521
1593
  transceiver: vp9Transceiver,
1522
1594
  options: {},
1595
+ negotiated: true,
1523
1596
  });
1524
1597
 
1525
1598
  await publisher.stopTracks(TrackType.VIDEO);
@@ -1589,6 +1662,7 @@ describe('Publisher', () => {
1589
1662
  publishOption,
1590
1663
  transceiver,
1591
1664
  options: {},
1665
+ negotiated: true,
1592
1666
  });
1593
1667
 
1594
1668
  // SFU sends a changePublishQuality while we are not publishing.
package/src/rtc/types.ts CHANGED
@@ -112,6 +112,7 @@ export type PublishBundle = {
112
112
  transceiver: RTCRtpTransceiver;
113
113
  options: TrackPublishOptions;
114
114
  videoSender?: VideoSender;
115
+ negotiated?: boolean;
115
116
  };
116
117
 
117
118
  export type TrackLayersCache = {