@stream-io/video-client 1.54.0 → 1.55.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 (64) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +9641 -8767
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +9638 -8764
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +9639 -8765
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +13 -1
  9. package/dist/src/StreamSfuClient.d.ts +11 -3
  10. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  11. package/dist/src/gen/google/protobuf/struct.d.ts +3 -1
  12. package/dist/src/gen/google/protobuf/timestamp.d.ts +3 -1
  13. package/dist/src/gen/video/sfu/event/events.d.ts +22 -1
  14. package/dist/src/gen/video/sfu/models/models.d.ts +4 -0
  15. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +23 -2
  16. package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
  17. package/dist/src/rtc/BasePeerConnection.d.ts +2 -12
  18. package/dist/src/rtc/IceTrickleBuffer.d.ts +41 -3
  19. package/dist/src/rtc/Publisher.d.ts +5 -2
  20. package/dist/src/rtc/Subscriber.d.ts +8 -0
  21. package/dist/src/rtc/helpers/iceCandiates.d.ts +12 -0
  22. package/dist/src/rtc/types.d.ts +2 -0
  23. package/dist/src/stats/SfuStatsReporter.d.ts +32 -1
  24. package/dist/src/stats/rtc/StatsTracer.d.ts +38 -8
  25. package/dist/src/stats/rtc/Tracer.d.ts +9 -2
  26. package/dist/src/stats/rtc/types.d.ts +10 -4
  27. package/package.json +5 -3
  28. package/src/Call.ts +83 -35
  29. package/src/StreamSfuClient.ts +36 -21
  30. package/src/__tests__/StreamSfuClient.test.ts +159 -1
  31. package/src/__tests__/StreamVideoClient.api.test.ts +123 -97
  32. package/src/coordinator/connection/__tests__/connection.test.ts +22 -0
  33. package/src/coordinator/connection/connection.ts +8 -5
  34. package/src/gen/google/protobuf/struct.ts +7 -12
  35. package/src/gen/google/protobuf/timestamp.ts +6 -7
  36. package/src/gen/video/sfu/event/events.ts +22 -25
  37. package/src/gen/video/sfu/models/models.ts +10 -1
  38. package/src/gen/video/sfu/signal_rpc/signal.client.ts +24 -29
  39. package/src/helpers/MediaPlaybackWatchdog.ts +1 -0
  40. package/src/helpers/__tests__/browsers.test.ts +12 -12
  41. package/src/helpers/browsers.ts +5 -5
  42. package/src/reporting/ClientEventReporter.ts +17 -12
  43. package/src/reporting/__tests__/ClientEventReporter.test.ts +52 -0
  44. package/src/rtc/BasePeerConnection.ts +15 -34
  45. package/src/rtc/IceTrickleBuffer.ts +105 -12
  46. package/src/rtc/Publisher.ts +23 -19
  47. package/src/rtc/Subscriber.ts +97 -36
  48. package/src/rtc/__tests__/Call.reconnect.test.ts +45 -45
  49. package/src/rtc/__tests__/IceTrickleBuffer.test.ts +127 -0
  50. package/src/rtc/__tests__/Publisher.test.ts +2 -31
  51. package/src/rtc/__tests__/Subscriber.test.ts +271 -20
  52. package/src/rtc/helpers/__tests__/iceCandiates.test.ts +88 -0
  53. package/src/rtc/helpers/degradationPreference.ts +1 -0
  54. package/src/rtc/helpers/iceCandiates.ts +35 -0
  55. package/src/rtc/helpers/sdp.ts +3 -2
  56. package/src/rtc/helpers/tracks.ts +2 -0
  57. package/src/rtc/types.ts +2 -0
  58. package/src/stats/SfuStatsReporter.ts +149 -49
  59. package/src/stats/__tests__/SfuStatsReporter.test.ts +235 -0
  60. package/src/stats/rtc/StatsTracer.ts +90 -32
  61. package/src/stats/rtc/Tracer.ts +23 -2
  62. package/src/stats/rtc/__tests__/StatsTracer.test.ts +213 -6
  63. package/src/stats/rtc/__tests__/Tracer.test.ts +34 -0
  64. package/src/stats/rtc/types.ts +11 -4
@@ -9,11 +9,13 @@ import type {
9
9
  export class Tracer {
10
10
  private buffer: TraceRecord[] = [];
11
11
  private enabled = true;
12
- private readonly id: string | null;
12
+ readonly id: string | null;
13
+ private readonly maxBuffer: number;
13
14
  private keys?: Map<TraceKey, boolean>;
14
15
 
15
- constructor(id: string | null) {
16
+ constructor(id: string | null, maxBuffer: number = 2500) {
16
17
  this.id = id;
18
+ this.maxBuffer = maxBuffer;
17
19
  }
18
20
 
19
21
  setEnabled = (enabled: boolean) => {
@@ -25,6 +27,7 @@ export class Tracer {
25
27
  trace: Trace = (tag, data) => {
26
28
  if (!this.enabled) return;
27
29
  this.buffer.push([tag, this.id, data, Date.now()]);
30
+ this.capBuffer();
28
31
  };
29
32
 
30
33
  traceOnce = (key: TraceKey, tag: string, data: RTCStatsDataType) => {
@@ -44,6 +47,7 @@ export class Tracer {
44
47
  snapshot,
45
48
  rollback: () => {
46
49
  this.buffer.unshift(...snapshot);
50
+ this.capBuffer();
47
51
  },
48
52
  };
49
53
  };
@@ -52,4 +56,21 @@ export class Tracer {
52
56
  this.buffer = [];
53
57
  this.keys?.clear();
54
58
  };
59
+
60
+ /**
61
+ * Bounds the buffer to 2500 records by dropping the oldest ones,
62
+ * leaving a single `traceBufferOverflow` breadcrumb at the front so the
63
+ * consumer knows records were dropped.
64
+ */
65
+ private capBuffer = () => {
66
+ const overflow = this.buffer.length - this.maxBuffer;
67
+ if (overflow <= 0) return;
68
+ this.buffer.splice(0, overflow);
69
+ this.buffer[0] = [
70
+ 'traceBufferOverflow',
71
+ this.id,
72
+ { dropped: overflow },
73
+ Date.now(),
74
+ ];
75
+ };
55
76
  }
@@ -43,7 +43,8 @@ describe('StatsTracer timestamp drift correction', () => {
43
43
  THRESHOLD_MS,
44
44
  );
45
45
 
46
- const { delta } = await tracer.get();
46
+ await tracer.takeSample();
47
+ const { delta } = tracer.getPendingDeltas().at(-1)!;
47
48
 
48
49
  expect(delta.timestamp).toBe(WALL_NOW + 2000);
49
50
  });
@@ -60,7 +61,8 @@ describe('StatsTracer timestamp drift correction', () => {
60
61
  THRESHOLD_MS,
61
62
  );
62
63
 
63
- const { delta } = await tracer.get();
64
+ await tracer.takeSample();
65
+ const { delta } = tracer.getPendingDeltas().at(-1)!;
64
66
 
65
67
  expect(delta.timestamp).toBe(WALL_NOW);
66
68
  });
@@ -77,7 +79,8 @@ describe('StatsTracer timestamp drift correction', () => {
77
79
  THRESHOLD_MS,
78
80
  );
79
81
 
80
- const { delta } = await tracer.get();
82
+ await tracer.takeSample();
83
+ const { delta } = tracer.getPendingDeltas().at(-1)!;
81
84
 
82
85
  expect(delta['b'].timestamp).toBe(WALL_NOW);
83
86
  expect(delta.timestamp).toBe(WALL_NOW + 2000);
@@ -95,7 +98,8 @@ describe('StatsTracer timestamp drift correction', () => {
95
98
  THRESHOLD_MS,
96
99
  );
97
100
 
98
- const { delta } = await tracer.get();
101
+ await tracer.takeSample();
102
+ const { delta } = tracer.getPendingDeltas().at(-1)!;
99
103
 
100
104
  expect(delta.timestamp).toBe(WALL_NOW + THRESHOLD_MS);
101
105
  });
@@ -127,7 +131,8 @@ describe('StatsTracer timestamp drift correction', () => {
127
131
  THRESHOLD_MS,
128
132
  );
129
133
 
130
- const { delta } = await tracer.get();
134
+ await tracer.takeSample();
135
+ const { delta } = tracer.getPendingDeltas().at(-1)!;
131
136
 
132
137
  // both stale and future drift get clamped to wall time, while the
133
138
  // within-threshold anchor stays the delta's top-level timestamp.
@@ -148,8 +153,210 @@ describe('StatsTracer timestamp drift correction', () => {
148
153
  0,
149
154
  );
150
155
 
151
- const { delta } = await tracer.get();
156
+ await tracer.takeSample();
157
+ const { delta } = tracer.getPendingDeltas().at(-1)!;
152
158
 
153
159
  expect(delta.timestamp).toBe(WALL_NOW + 99_999);
154
160
  });
155
161
  });
162
+
163
+ // Builds an RTCStatsReport from rich per-report fields (id is added for you).
164
+ const richReport = (
165
+ entries: Record<string, Record<string, unknown>>,
166
+ ): RTCStatsReport => {
167
+ const map = new Map<string, RTCStats>();
168
+ for (const [id, fields] of Object.entries(entries)) {
169
+ map.set(id, { id, ...fields } as unknown as RTCStats);
170
+ }
171
+ return map as unknown as RTCStatsReport;
172
+ };
173
+
174
+ // A pc mock whose getStats() returns a different report on each successive call.
175
+ const makeSeqPc = (reports: RTCStatsReport[]): RTCPeerConnection => {
176
+ const getStats = vi.fn();
177
+ for (const r of reports) getStats.mockResolvedValueOnce(r);
178
+ return { getStats } as unknown as RTCPeerConnection;
179
+ };
180
+
181
+ // Mirrors the server-side accumulator: applies chained deltas in order,
182
+ // un-zeroing each report's hoisted timestamp.
183
+ const applyChain = (
184
+ baseline: Record<string, any>,
185
+ deltas: Array<Record<string, any>>,
186
+ ): Record<string, any> => {
187
+ const acc: Record<string, any> = JSON.parse(JSON.stringify(baseline));
188
+ for (const delta of deltas) {
189
+ const top = delta.timestamp;
190
+ for (const [id, report] of Object.entries(delta)) {
191
+ if (id === 'timestamp') continue;
192
+ const target = (acc[id] ??= {});
193
+ for (const [k, v] of Object.entries(report as Record<string, unknown>)) {
194
+ target[k] = k === 'timestamp' && v === 0 ? top : v;
195
+ }
196
+ }
197
+ }
198
+ return acc;
199
+ };
200
+
201
+ describe('StatsTracer pending delta chain', () => {
202
+ beforeEach(() => {
203
+ vi.useFakeTimers();
204
+ vi.setSystemTime(WALL_NOW);
205
+ });
206
+
207
+ afterEach(() => {
208
+ vi.useRealTimers();
209
+ });
210
+
211
+ it('accumulates one delta per get() in the pending chain', async () => {
212
+ const pc = makeSeqPc([
213
+ makeReport([{ id: 'a', timestamp: WALL_NOW }]),
214
+ makeReport([{ id: 'a', timestamp: WALL_NOW + 1000 }]),
215
+ ]);
216
+ const tracer = new StatsTracer(pc, PeerType.SUBSCRIBER, new Map(), 0);
217
+
218
+ await tracer.takeSample();
219
+ await tracer.takeSample();
220
+
221
+ expect(tracer.getPendingDeltas()).toHaveLength(2);
222
+ });
223
+
224
+ it('commitDeltas removes exactly the committed deltas by identity', async () => {
225
+ const pc = makeSeqPc([
226
+ makeReport([{ id: 'a', timestamp: WALL_NOW }]),
227
+ makeReport([{ id: 'a', timestamp: WALL_NOW + 1000 }]),
228
+ ]);
229
+ const tracer = new StatsTracer(pc, PeerType.SUBSCRIBER, new Map(), 0);
230
+
231
+ await tracer.takeSample();
232
+ await tracer.takeSample();
233
+ const sent = tracer.getPendingDeltas();
234
+ tracer.commitDeltas(sent.slice(0, 1));
235
+
236
+ const remaining = tracer.getPendingDeltas();
237
+ expect(remaining).toHaveLength(1);
238
+ expect(remaining[0]).toBe(sent[1]);
239
+ });
240
+
241
+ it('clearPendingDeltas empties the chain', async () => {
242
+ const pc = makeSeqPc([
243
+ makeReport([{ id: 'a', timestamp: WALL_NOW }]),
244
+ makeReport([{ id: 'a', timestamp: WALL_NOW + 1000 }]),
245
+ ]);
246
+ const tracer = new StatsTracer(pc, PeerType.SUBSCRIBER, new Map(), 0);
247
+
248
+ await tracer.takeSample();
249
+ await tracer.takeSample();
250
+ tracer.clearPendingDeltas();
251
+
252
+ expect(tracer.getPendingDeltas()).toHaveLength(0);
253
+ });
254
+
255
+ it('reconstructs every sample by applying the un-acked chain in order', async () => {
256
+ const s0 = richReport({ a: { timestamp: WALL_NOW, bytes: 100 } });
257
+ const s1 = richReport({ a: { timestamp: WALL_NOW + 1000, bytes: 200 } });
258
+ const s2 = richReport({ a: { timestamp: WALL_NOW + 2000, bytes: 350 } });
259
+ const tracer = new StatsTracer(
260
+ makeSeqPc([s0, s1, s2]),
261
+ PeerType.SUBSCRIBER,
262
+ new Map(),
263
+ 0,
264
+ );
265
+
266
+ await tracer.takeSample();
267
+ await tracer.takeSample();
268
+ await tracer.takeSample();
269
+ const chain = tracer.getPendingDeltas().map((p) => p.delta);
270
+
271
+ expect(applyChain({}, chain)).toEqual({
272
+ a: { timestamp: WALL_NOW + 2000, bytes: 350 },
273
+ });
274
+ });
275
+
276
+ it('re-anchors with a full snapshot when the chain exceeds the cap', async () => {
277
+ const s0 = richReport({ a: { timestamp: WALL_NOW, bytes: 100 } });
278
+ const s1 = richReport({ a: { timestamp: WALL_NOW + 1000, bytes: 200 } });
279
+ const s2 = richReport({ a: { timestamp: WALL_NOW + 2000, bytes: 350 } });
280
+ const tracer = new StatsTracer(
281
+ makeSeqPc([s0, s1, s2]),
282
+ PeerType.SUBSCRIBER,
283
+ new Map(),
284
+ 0,
285
+ 2, // maxPendingDeltas
286
+ );
287
+
288
+ await tracer.takeSample();
289
+ await tracer.takeSample(); // chain now at the cap
290
+ await tracer.takeSample(); // exceeds cap -> re-anchor
291
+
292
+ const chain = tracer.getPendingDeltas();
293
+ expect(chain).toHaveLength(1);
294
+ // the single re-anchored delta is a full snapshot of S2
295
+ expect(applyChain({}, [chain[0].delta])).toEqual({
296
+ a: { timestamp: WALL_NOW + 2000, bytes: 350 },
297
+ });
298
+ });
299
+
300
+ it('keeps a re-anchored keyframe when a stale commit lands afterwards', async () => {
301
+ const s0 = richReport({ a: { timestamp: WALL_NOW, bytes: 100 } });
302
+ const s1 = richReport({ a: { timestamp: WALL_NOW + 1000, bytes: 200 } });
303
+ const s2 = richReport({ a: { timestamp: WALL_NOW + 2000, bytes: 350 } });
304
+ const tracer = new StatsTracer(
305
+ makeSeqPc([s0, s1, s2]),
306
+ PeerType.SUBSCRIBER,
307
+ new Map(),
308
+ 0,
309
+ 2,
310
+ );
311
+
312
+ await tracer.takeSample();
313
+ await tracer.takeSample();
314
+ const sent = tracer.getPendingDeltas(); // captured before re-anchor
315
+ await tracer.takeSample(); // re-anchors, dropping `sent`
316
+ tracer.commitDeltas(sent); // stale commit, must be a no-op
317
+
318
+ expect(tracer.getPendingDeltas()).toHaveLength(1);
319
+ });
320
+ });
321
+
322
+ const deferred = <T>() => {
323
+ let resolve!: (v: T) => void;
324
+ const promise = new Promise<T>((res) => {
325
+ resolve = res;
326
+ });
327
+ return { promise, resolve };
328
+ };
329
+
330
+ describe('StatsTracer concurrent sampling', () => {
331
+ it('serializes overlapping get() calls so the chain stays in call order', async () => {
332
+ const dA = deferred<RTCStatsReport>();
333
+ const dB = deferred<RTCStatsReport>();
334
+ const getStats = vi
335
+ .fn()
336
+ .mockReturnValueOnce(dA.promise)
337
+ .mockReturnValueOnce(dB.promise);
338
+ const pc = { getStats } as unknown as RTCPeerConnection;
339
+ const tracer = new StatsTracer(pc, PeerType.SUBSCRIBER, new Map(), 0);
340
+
341
+ const pA = tracer.takeSample();
342
+ const pB = tracer.takeSample();
343
+
344
+ // the second sample must not start until the first completes
345
+ expect(getStats).toHaveBeenCalledTimes(1);
346
+
347
+ // resolve the first sample; only then should the second getStats fire
348
+ dA.resolve(richReport({ a: { timestamp: 1000, bytes: 100 } }));
349
+ await pA;
350
+ expect(getStats).toHaveBeenCalledTimes(2);
351
+
352
+ dB.resolve(richReport({ a: { timestamp: 2000, bytes: 200 } }));
353
+ await pB;
354
+
355
+ // chain applied in call order reconstructs the second (newer) sample
356
+ const chain = tracer.getPendingDeltas().map((p) => p.delta);
357
+ expect(chain).toHaveLength(2);
358
+ expect(applyChain({}, chain)).toEqual({
359
+ a: { timestamp: 2000, bytes: 200 },
360
+ });
361
+ });
362
+ });
@@ -54,4 +54,38 @@ describe('Tracer', () => {
54
54
  const slice = tracer.take();
55
55
  expect(slice.snapshot.length).toBe(0);
56
56
  });
57
+
58
+ it('exposes its id via .id', () => {
59
+ expect(new Tracer('abc').id).toBe('abc');
60
+ expect(new Tracer(null).id).toBeNull();
61
+ });
62
+
63
+ it('caps the buffer, dropping oldest and leaving an overflow marker', () => {
64
+ const capped = new Tracer('id', 3);
65
+ capped.trace('e', { n: 1 });
66
+ capped.trace('e', { n: 2 });
67
+ capped.trace('e', { n: 3 });
68
+ capped.trace('e', { n: 4 }); // overflows the cap of 3
69
+
70
+ const slice = capped.take();
71
+ expect(slice.snapshot.length).toBe(3);
72
+ expect(slice.snapshot[0][0]).toBe('traceBufferOverflow');
73
+ // newest record is retained
74
+ expect(slice.snapshot[slice.snapshot.length - 1][2]).toEqual({ n: 4 });
75
+ });
76
+
77
+ it('caps the buffer on rollback too', () => {
78
+ const capped = new Tracer('id', 3);
79
+ capped.trace('e', { n: 1 });
80
+ const slice = capped.take();
81
+
82
+ capped.trace('e', { n: 2 });
83
+ capped.trace('e', { n: 3 });
84
+ capped.trace('e', { n: 4 }); // buffer now at the cap of 3
85
+ slice.rollback(); // prepends the old record, exceeding the cap
86
+
87
+ const after = capped.take();
88
+ expect(after.snapshot.length).toBe(3);
89
+ expect(after.snapshot[0][0]).toBe('traceBufferOverflow');
90
+ });
57
91
  });
@@ -40,12 +40,19 @@ export type ComputedStats = {
40
40
  * Current stats from the RTCPeerConnection.
41
41
  */
42
42
  stats: RTCStatsReport;
43
- /**
44
- * Delta between the current stats and the previous stats.
45
- */
46
- delta: Record<any, any>;
47
43
  /**
48
44
  * The current iteration of the stats.
49
45
  */
50
46
  performanceStats: PerformanceStats[];
51
47
  };
48
+
49
+ /**
50
+ * A single, not-yet-delivered delta-compressed stats sample.
51
+ * The `delta` is computed against the immediately preceding sample, so a
52
+ * sequence of `PendingDelta`s forms a chain that the server applies in order
53
+ * onto its running accumulator. `ts` is the wall-clock time the sample was taken.
54
+ */
55
+ export type PendingDelta = {
56
+ delta: Record<any, any>;
57
+ ts: number;
58
+ };