@stream-io/video-client 1.54.1-beta.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 (59) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +9672 -8865
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +9673 -8866
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +9674 -8867
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +4 -4
  9. package/dist/src/StreamSfuClient.d.ts +11 -3
  10. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  11. package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
  12. package/dist/src/rtc/BasePeerConnection.d.ts +2 -12
  13. package/dist/src/rtc/IceTrickleBuffer.d.ts +41 -3
  14. package/dist/src/rtc/Publisher.d.ts +1 -1
  15. package/dist/src/rtc/Subscriber.d.ts +2 -1
  16. package/dist/src/rtc/helpers/iceCandiates.d.ts +12 -0
  17. package/dist/src/rtc/types.d.ts +2 -0
  18. package/dist/src/stats/SfuStatsReporter.d.ts +32 -1
  19. package/dist/src/stats/rtc/StatsTracer.d.ts +38 -8
  20. package/dist/src/stats/rtc/Tracer.d.ts +9 -2
  21. package/dist/src/stats/rtc/types.d.ts +10 -4
  22. package/package.json +5 -3
  23. package/src/Call.ts +47 -44
  24. package/src/StreamSfuClient.ts +36 -21
  25. package/src/__tests__/StreamSfuClient.test.ts +159 -1
  26. package/src/__tests__/StreamVideoClient.api.test.ts +123 -97
  27. package/src/coordinator/connection/__tests__/connection.test.ts +22 -0
  28. package/src/coordinator/connection/connection.ts +8 -5
  29. package/src/gen/video/sfu/event/events.ts +0 -1
  30. package/src/gen/video/sfu/models/models.ts +0 -1
  31. package/src/gen/video/sfu/signal_rpc/signal.client.ts +0 -1
  32. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -1
  33. package/src/helpers/MediaPlaybackWatchdog.ts +1 -0
  34. package/src/helpers/__tests__/browsers.test.ts +12 -12
  35. package/src/helpers/browsers.ts +5 -5
  36. package/src/helpers/client-details.ts +1 -1
  37. package/src/reporting/ClientEventReporter.ts +17 -12
  38. package/src/reporting/__tests__/ClientEventReporter.test.ts +52 -0
  39. package/src/rtc/BasePeerConnection.ts +15 -34
  40. package/src/rtc/IceTrickleBuffer.ts +105 -12
  41. package/src/rtc/Publisher.ts +19 -19
  42. package/src/rtc/Subscriber.ts +71 -37
  43. package/src/rtc/__tests__/Call.reconnect.test.ts +45 -45
  44. package/src/rtc/__tests__/IceTrickleBuffer.test.ts +127 -0
  45. package/src/rtc/__tests__/Publisher.test.ts +2 -31
  46. package/src/rtc/__tests__/Subscriber.test.ts +271 -20
  47. package/src/rtc/helpers/__tests__/iceCandiates.test.ts +88 -0
  48. package/src/rtc/helpers/degradationPreference.ts +1 -0
  49. package/src/rtc/helpers/iceCandiates.ts +35 -0
  50. package/src/rtc/helpers/sdp.ts +3 -2
  51. package/src/rtc/helpers/tracks.ts +2 -0
  52. package/src/rtc/types.ts +2 -0
  53. package/src/stats/SfuStatsReporter.ts +149 -49
  54. package/src/stats/__tests__/SfuStatsReporter.test.ts +235 -0
  55. package/src/stats/rtc/StatsTracer.ts +90 -32
  56. package/src/stats/rtc/Tracer.ts +23 -2
  57. package/src/stats/rtc/__tests__/StatsTracer.test.ts +213 -6
  58. package/src/stats/rtc/__tests__/Tracer.test.ts +34 -0
  59. package/src/stats/rtc/types.ts +11 -4
@@ -129,7 +129,7 @@ export declare class Call {
129
129
  private joinResponseTimeout?;
130
130
  private rpcRequestTimeout?;
131
131
  private joinCallData?;
132
- private selfSubEnabled;
132
+ private allowOwnTracksLoopback;
133
133
  private hasJoinedOnce;
134
134
  private deviceSettingsAppliedOnce;
135
135
  private credentials?;
@@ -196,7 +196,7 @@ export declare class Call {
196
196
  /**
197
197
  * A flag indicating whether self-subscription is enabled for the call.
198
198
  */
199
- get isSelfSubEnabled(): boolean;
199
+ get isOwnTracksLoopbackAllowed(): boolean;
200
200
  /**
201
201
  * The largest video publish dimension across the current publish options.
202
202
  *
@@ -275,11 +275,11 @@ export declare class Call {
275
275
  *
276
276
  * @returns a promise which resolves once the call join-flow has finished.
277
277
  */
278
- join: ({ maxJoinRetries, joinResponseTimeout, rpcRequestTimeout, selfSubEnabled, ...data }?: JoinCallData & {
278
+ join: ({ maxJoinRetries, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback, ...data }?: JoinCallData & {
279
279
  maxJoinRetries?: number;
280
280
  joinResponseTimeout?: number;
281
281
  rpcRequestTimeout?: number;
282
- selfSubEnabled?: boolean;
282
+ allowOwnTracksLoopback?: boolean;
283
283
  }) => Promise<void>;
284
284
  /**
285
285
  * Will make a single attempt to watch for call related WebSocket events
@@ -87,12 +87,20 @@ export declare class StreamSfuClient {
87
87
  * trigger a reconnection attempt.
88
88
  */
89
89
  isClosingClean: boolean;
90
+ /**
91
+ * One-shot latch guarding `onSignalClose`. The signal connection can be
92
+ * detected as dead by more than one source (the health watchdog and the
93
+ * WebSocket `close` event, which on a wedged socket can arrive seconds
94
+ * apart). This ensures revival is triggered at most once per client.
95
+ */
96
+ private signalClosed;
90
97
  private readonly rpc;
91
98
  private keepAliveInterval?;
92
- private connectionCheckTimeout?;
99
+ private connectionCheckInterval?;
93
100
  private migrateAwayTimeout?;
94
101
  private readonly pingIntervalInMs;
95
102
  private readonly unhealthyTimeoutInMs;
103
+ private readonly connectionCheckIntervalInMs;
96
104
  private lastMessageTimestamp?;
97
105
  private readonly tracer?;
98
106
  private readonly unsubscribeIceTrickle;
@@ -127,7 +135,7 @@ export declare class StreamSfuClient {
127
135
  /**
128
136
  * The error code used when the SFU connection is unhealthy.
129
137
  * Usually, this means that no message has been received from the SFU for
130
- * a certain amount of time (`connectionCheckTimeout`).
138
+ * a certain amount of time (`unhealthyTimeoutInMs`).
131
139
  */
132
140
  static ERROR_CONNECTION_UNHEALTHY: number;
133
141
  /**
@@ -154,7 +162,7 @@ export declare class StreamSfuClient {
154
162
  private createWebSocket;
155
163
  get isHealthy(): boolean;
156
164
  get joinTask(): Promise<JoinResponse>;
157
- private handleWebSocketClose;
165
+ private notifySignalClose;
158
166
  close: (code?: number, reason?: string) => void;
159
167
  private dispose;
160
168
  getTrace: () => TraceSlice | undefined;
@@ -44,7 +44,7 @@ export declare class StableWSConnection {
44
44
  pingInterval: number;
45
45
  healthCheckTimeoutRef?: number;
46
46
  connectionCheckTimeout: number;
47
- connectionCheckTimeoutRef?: NodeJS.Timeout;
47
+ connectionCheckTimeoutRef?: number;
48
48
  /** Store the last event time for health checks */
49
49
  lastEvent: Date | null;
50
50
  constructor(client: StreamClient);
@@ -82,5 +82,6 @@ export declare class ClientEventReporter {
82
82
  private sessionIdField;
83
83
  private buildCommon;
84
84
  private send;
85
+ private sendForCall;
85
86
  private sendWithRetry;
86
87
  }
@@ -5,7 +5,7 @@ import { PeerType, TrackType } from '../gen/video/sfu/models/models';
5
5
  import { StreamSfuClient } from '../StreamSfuClient';
6
6
  import { AllSfuEvents, Dispatcher } from './Dispatcher';
7
7
  import { StatsTracer, Tracer } from '../stats';
8
- import { BasePeerConnectionOpts, OnRemoteTrackUnmute } from './types';
8
+ import { BasePeerConnectionOpts, OnReconnectionNeeded, OnRemoteTrackUnmute } from './types';
9
9
  import type { ClientPublishOptions } from '../types';
10
10
  /**
11
11
  * A base class for the `Publisher` and `Subscriber` classes.
@@ -20,7 +20,7 @@ export declare abstract class BasePeerConnection {
20
20
  protected readonly clientPublishOptions?: ClientPublishOptions;
21
21
  protected tag: string;
22
22
  protected sfuClient: StreamSfuClient;
23
- private onReconnectionNeeded?;
23
+ protected onReconnectionNeeded?: OnReconnectionNeeded;
24
24
  private onIceConnected?;
25
25
  private onPeerConnectionStateChange?;
26
26
  protected onRemoteTrackUnmute?: OnRemoteTrackUnmute;
@@ -95,21 +95,11 @@ export declare abstract class BasePeerConnection {
95
95
  * it returns `false`, otherwise it returns `true`.
96
96
  */
97
97
  isHealthy: () => boolean;
98
- /**
99
- * Returns true only when the peer connection is currently fully established
100
- * (ICE `connected`/`completed` AND connection state `connected`).
101
- * Transient states like `disconnected`, `checking`, or `new` return false.
102
- */
103
- isStable: () => boolean;
104
98
  /**
105
99
  * Handles the ICECandidate event and
106
100
  * Initiates an ICE Trickle process with the SFU.
107
101
  */
108
102
  private onIceCandidate;
109
- /**
110
- * Converts the ICE candidate to a JSON string.
111
- */
112
- private asJSON;
113
103
  /**
114
104
  * Handles the ConnectionStateChange event.
115
105
  */
@@ -1,12 +1,50 @@
1
- import { ReplaySubject } from 'rxjs';
1
+ import { Observable } from 'rxjs';
2
2
  import { ICETrickle } from '../gen/video/sfu/event/events';
3
+ import { PeerType } from '../gen/video/sfu/models/models';
3
4
  /**
4
5
  * A buffer for ICE Candidates. Used for ICE Trickle:
5
6
  * - https://bloggeek.me/webrtcglossary/trickle-ice/
7
+ *
8
+ * The buffer is generation-aware: each peer connection tells it which ICE
9
+ * generation is current via `updateActiveGeneration` (whenever it applies an
10
+ * offer/answer). Candidate streams then emit only candidates of the active
11
+ * generation, hold candidates of a not-yet-applied (future) generation until
12
+ * it becomes active, and drop candidates of a superseded generation so they
13
+ * are never replayed. Candidates with no detectable generation, or before any
14
+ * generation is set, are emitted as-is (fail open).
6
15
  */
7
16
  export declare class IceTrickleBuffer {
8
- readonly subscriberCandidates: ReplaySubject<RTCIceCandidateInit>;
9
- readonly publisherCandidates: ReplaySubject<RTCIceCandidateInit>;
17
+ readonly subscriber: CandidateGenerationBuffer;
18
+ readonly publisher: CandidateGenerationBuffer;
10
19
  push: (iceTrickle: ICETrickle) => void;
20
+ /**
21
+ * Declares the ICE generation that is now current for the given peer type,
22
+ * derived from the `ice-ufrag` of the just-applied remote description.
23
+ * Candidates of superseded generations are evicted; candidates of the active
24
+ * generation flow to subscribers.
25
+ */
26
+ updateActiveGeneration: (peerType: PeerType, sdp: string | undefined) => void;
11
27
  dispose: () => void;
12
28
  }
29
+ /**
30
+ * Per-peer-connection generation-aware candidate store. Retains trickled
31
+ * candidates and replays the active generation to each new subscriber, then
32
+ * forwards matching live candidates.
33
+ */
34
+ declare class CandidateGenerationBuffer {
35
+ private readonly store;
36
+ private readonly live;
37
+ private readonly seenUfrags;
38
+ private activeUfrag;
39
+ readonly candidates: Observable<RTCIceCandidateInit>;
40
+ push: (candidate: RTCIceCandidateInit) => void;
41
+ updateActiveGeneration: (ufrag: string | undefined) => void;
42
+ dispose: () => void;
43
+ /**
44
+ * A candidate belongs to the current generation when its ufrag matches the
45
+ * active one. Fail open when either the candidate's generation or the active
46
+ * generation is unknown, so untagged candidates are never withheld.
47
+ */
48
+ private isCurrent;
49
+ }
50
+ export {};
@@ -1,6 +1,6 @@
1
+ import { PublishOption, TrackInfo, TrackType } from '../gen/video/sfu/models/models';
1
2
  import { BasePeerConnection } from './BasePeerConnection';
2
3
  import type { BasePeerConnectionOpts, TrackPublishOptions } from './types';
3
- import { PublishOption, TrackInfo, TrackType } from '../gen/video/sfu/models/models';
4
4
  /**
5
5
  * The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
6
6
  *
@@ -13,7 +13,8 @@ export declare class Subscriber extends BasePeerConnection {
13
13
  * The map will never contain local streams so we can safely use it to
14
14
  * check if the stream is remote and dispose it when needed.
15
15
  */
16
- private trackedStreams;
16
+ private trackedStreams?;
17
+ private negotiationFailures;
17
18
  /**
18
19
  * Constructs a new `Subscriber` instance.
19
20
  */
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Converts the ICE candidate to a JSON string.
3
+ */
4
+ export declare const toJSON: (candidate: RTCIceCandidate) => string;
5
+ /**
6
+ * Extracts the ICE ufrag from an SDP, or `undefined` when absent.
7
+ */
8
+ export declare const parseIceUfrag: (sdp: string | undefined) => string | undefined;
9
+ /**
10
+ * Extracts the ICE ufrag (generation) a trickled candidate was gathered under.
11
+ */
12
+ export declare const getCandidateUfrag: (ice: RTCIceCandidateInit) => string | undefined;
@@ -21,6 +21,8 @@ export declare const ReconnectReason: {
21
21
  readonly CONNECTION_FAILED: "connection_failed";
22
22
  /** `restartIce()` rejected. */
23
23
  readonly RESTART_ICE_FAILED: "restart_ice_failed";
24
+ /** Subscriber renegotiation kept failing, escalate to REJOIN. */
25
+ readonly SUBSCRIBER_NEGOTIATION_FAILED: "subscriber_negotiation_failed";
24
26
  /** SFU `goAway` event, migrate to a new SFU. */
25
27
  readonly GO_AWAY: "go_away";
26
28
  /** Network came back online after going offline. */
@@ -36,14 +36,45 @@ export declare class SfuStatsReporter {
36
36
  private readonly sdkVersion;
37
37
  private readonly webRTCVersion;
38
38
  private readonly inputDevices;
39
+ private readonly statsConcurrencyTag;
40
+ private isStopped;
39
41
  constructor(sfuClient: StreamSfuClient, { options, clientDetails, subscriber, publisher, microphone, camera, state, tracer, unifiedSessionId, }: SfuStatsReporterOptions);
40
42
  private observeDevice;
41
43
  sendConnectionTime: (connectionTimeSeconds: number) => void;
42
44
  sendReconnectionTime: (strategy: WebsocketReconnectStrategy, timeSeconds: number) => void;
43
45
  private sendTelemetryData;
46
+ /**
47
+ * Samples both peer connections. Each `StatsTracer.takeSample()` is serialized
48
+ * internally, so this is safe even if it overlaps another sample (e.g. the
49
+ * connection-state-change handler). Kept separate from `send()` so an
50
+ * explicit flush can capture the sample from live peer connections before
51
+ * they are disposed, without waiting for an in-flight send.
52
+ */
53
+ private sample;
54
+ private send;
55
+ /**
56
+ * Samples and sends one report. Used by the scheduler and the telemetry path.
57
+ * Bails if the reporter has been stopped so it never samples disposed peer
58
+ * connections.
59
+ */
44
60
  private run;
45
61
  private scheduleNextReport;
46
62
  start: () => void;
47
63
  stop: () => void;
48
- flush: () => void;
64
+ /**
65
+ * Explicit/final flush (leave, migration, re-init). Time-boxes the sampling
66
+ * step and swallows its failures, so a slow or failing `getStats()` on a
67
+ * degraded or closing peer connection can never block or reject call teardown
68
+ * or reconnect setup. On a successful sample it fires the send best-effort;
69
+ * the returned promise resolves once the sample is taken (or the time-box
70
+ * elapses / sampling fails), never when the sending completes. No-op once the
71
+ * reporter has been stopped.
72
+ */
73
+ flush: () => Promise<void>;
74
+ /**
75
+ * Flush entry for the periodic scheduler. Skips when the reporter is stopped
76
+ * or a send is already in flight so ticks can't pile up under slow sends (the
77
+ * next sample's delta spans the skipped interval).
78
+ */
79
+ private scheduledFlush;
49
80
  }
@@ -1,5 +1,5 @@
1
1
  import { PeerType, TrackType } from '../../gen/video/sfu/models/models';
2
- import type { ComputedStats } from './types';
2
+ import type { ComputedStats, PendingDelta } from './types';
3
3
  /**
4
4
  * StatsTracer is a class that collects and processes WebRTC stats.
5
5
  * It is used to track the performance of the WebRTC connection
@@ -13,23 +13,53 @@ export declare class StatsTracer {
13
13
  private readonly peerType;
14
14
  private readonly trackIdToTrackType;
15
15
  private readonly driftThresholdMs;
16
+ private readonly maxPendingDeltas;
17
+ private readonly sampleTag;
16
18
  private costOverrides?;
17
- private previousStats;
19
+ private previousSample;
18
20
  private frameTimeHistory;
19
21
  private fpsHistory;
22
+ /**
23
+ * The un-acked, delta-compressed samples awaiting delivery confirmation.
24
+ * Each entry's delta is computed against the immediately preceding sample,
25
+ * so the list forms a chain the server applies in order. The delivery
26
+ * baseline advances only when the reporter calls `commitDeltas` after a
27
+ * successful send; until then the chain is re-sent in full.
28
+ */
29
+ private pendingDeltas;
20
30
  /**
21
31
  * Creates a new StatsTracer instance.
22
32
  */
23
- constructor(pc: RTCPeerConnection, peerType: PeerType, trackIdToTrackType: Map<string, TrackType>, statsTimestampDriftThresholdMs?: number);
33
+ constructor(pc: RTCPeerConnection, peerType: PeerType, trackIdToTrackType: Map<string, TrackType>, statsTimestampDriftThresholdMs?: number, maxPendingDeltas?: number);
34
+ /**
35
+ * Samples the RTCPeerConnection: returns the current stats report and the
36
+ * derived performance stats, and appends the delta-compressed sample to the
37
+ * un-acked delivery chain (retrieved via `getPendingDeltas`).
38
+ *
39
+ * @internal
40
+ */
41
+ takeSample: () => Promise<ComputedStats>;
42
+ /**
43
+ * Returns a stable copy of the un-acked delta chain to transmit, oldest first.
44
+ *
45
+ * @internal
46
+ */
47
+ getPendingDeltas: () => PendingDelta[];
48
+ /**
49
+ * Advances the delivery baseline by dropping exactly the deltas that were
50
+ * confirmed delivered. Matching is by object identity, so a stale commit
51
+ * that arrives after a re-anchor (which replaced the chain) is a safe no-op.
52
+ *
53
+ * @internal
54
+ */
55
+ commitDeltas: (sent: PendingDelta[]) => void;
24
56
  /**
25
- * Get the stats from the RTCPeerConnection.
26
- * When called, it will return the stats for the current connection.
27
- * It will also return the delta between the current stats and the previous stats.
28
- * This is used to track the performance of the connection.
57
+ * Drops the un-acked chain without sending it. Used when delta reporting is
58
+ * disabled so the chain can't grow unbounded.
29
59
  *
30
60
  * @internal
31
61
  */
32
- get: () => Promise<ComputedStats>;
62
+ clearPendingDeltas: () => void;
33
63
  /**
34
64
  * Collects encode stats from the RTCPeerConnection.
35
65
  */
@@ -2,13 +2,20 @@ import type { RTCStatsDataType, Trace, TraceKey, TraceSlice } from './types';
2
2
  export declare class Tracer {
3
3
  private buffer;
4
4
  private enabled;
5
- private readonly id;
5
+ readonly id: string | null;
6
+ private readonly maxBuffer;
6
7
  private keys?;
7
- constructor(id: string | null);
8
+ constructor(id: string | null, maxBuffer?: number);
8
9
  setEnabled: (enabled: boolean) => void;
9
10
  trace: Trace;
10
11
  traceOnce: (key: TraceKey, tag: string, data: RTCStatsDataType) => void;
11
12
  resetTrace: (key: TraceKey) => void;
12
13
  take: () => TraceSlice;
13
14
  dispose: () => void;
15
+ /**
16
+ * Bounds the buffer to 2500 records by dropping the oldest ones,
17
+ * leaving a single `traceBufferOverflow` breadcrumb at the front so the
18
+ * consumer knows records were dropped.
19
+ */
20
+ private capBuffer;
14
21
  }
@@ -17,12 +17,18 @@ export type ComputedStats = {
17
17
  * Current stats from the RTCPeerConnection.
18
18
  */
19
19
  stats: RTCStatsReport;
20
- /**
21
- * Delta between the current stats and the previous stats.
22
- */
23
- delta: Record<any, any>;
24
20
  /**
25
21
  * The current iteration of the stats.
26
22
  */
27
23
  performanceStats: PerformanceStats[];
28
24
  };
25
+ /**
26
+ * A single, not-yet-delivered delta-compressed stats sample.
27
+ * The `delta` is computed against the immediately preceding sample, so a
28
+ * sequence of `PendingDelta`s forms a chain that the server applies in order
29
+ * onto its running accumulator. `ts` is the wall-clock time the sample was taken.
30
+ */
31
+ export type PendingDelta = {
32
+ delta: Record<any, any>;
33
+ ts: number;
34
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.54.1-beta.0",
3
+ "version": "1.55.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
@@ -46,9 +46,11 @@
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.8.2",
49
+ "@stream-io/audio-filters-web": "^0.9.0",
50
50
  "@stream-io/node-sdk": "^0.7.59",
51
+ "@stream-io/typescript-config": "^0.0.0",
51
52
  "@total-typescript/shoehorn": "^0.1.2",
53
+ "@types/node": "^25.9.3",
52
54
  "@types/sdp-transform": "^2.15.0",
53
55
  "@types/ua-parser-js": "^0.7.39",
54
56
  "@vitest/coverage-v8": "^4.1.7",
@@ -57,7 +59,7 @@
57
59
  "prettier": "^3.8.3",
58
60
  "rimraf": "^6.1.3",
59
61
  "rollup": "^4.60.4",
60
- "typescript": "^5.9.3",
62
+ "typescript": "^6.0.3",
61
63
  "vitest": "^4.1.7",
62
64
  "vitest-mock-extended": "^4.0.0"
63
65
  }
package/src/Call.ts CHANGED
@@ -325,7 +325,7 @@ export class Call {
325
325
  private joinResponseTimeout?: number;
326
326
  private rpcRequestTimeout?: number;
327
327
  private joinCallData?: JoinCallData;
328
- private selfSubEnabled = false;
328
+ private allowOwnTracksLoopback = false;
329
329
  private hasJoinedOnce = false;
330
330
  private deviceSettingsAppliedOnce = false;
331
331
  private credentials?: Credentials;
@@ -741,7 +741,9 @@ export class Call {
741
741
 
742
742
  const leaveReason = message ?? reason ?? 'user is leaving the call';
743
743
  this.tracer.trace('call.leaveReason', leaveReason);
744
- this.sfuStatsReporter?.flush();
744
+ // await the final sample so it's captured from the still-live peer
745
+ // connections (disposed below); the send itself stays best-effort.
746
+ await this.sfuStatsReporter?.flush();
745
747
  this.sfuStatsReporter?.stop();
746
748
  this.sfuStatsReporter = undefined;
747
749
  this.lastStatsOptions = undefined;
@@ -788,6 +790,7 @@ export class Call {
788
790
  this.leaveCallHooks.forEach((hook) => hook());
789
791
  this.initialized = false;
790
792
  this.hasJoinedOnce = false;
793
+ this.allowOwnTracksLoopback = false;
791
794
  this.unifiedSessionId = undefined;
792
795
  this.ringingSubject.next(false);
793
796
  this.cancelAutoDrop();
@@ -834,8 +837,8 @@ export class Call {
834
837
  /**
835
838
  * A flag indicating whether self-subscription is enabled for the call.
836
839
  */
837
- get isSelfSubEnabled() {
838
- return this.selfSubEnabled;
840
+ get isOwnTracksLoopbackAllowed() {
841
+ return this.allowOwnTracksLoopback;
839
842
  }
840
843
 
841
844
  /**
@@ -1074,13 +1077,13 @@ export class Call {
1074
1077
  maxJoinRetries = 3,
1075
1078
  joinResponseTimeout,
1076
1079
  rpcRequestTimeout,
1077
- selfSubEnabled = false,
1080
+ allowOwnTracksLoopback = false,
1078
1081
  ...data
1079
1082
  }: JoinCallData & {
1080
1083
  maxJoinRetries?: number;
1081
1084
  joinResponseTimeout?: number;
1082
1085
  rpcRequestTimeout?: number;
1083
- selfSubEnabled?: boolean;
1086
+ allowOwnTracksLoopback?: boolean;
1084
1087
  } = {}): Promise<void> => {
1085
1088
  const callingState = this.state.callingState;
1086
1089
 
@@ -1088,14 +1091,14 @@ export class Call {
1088
1091
  throw new Error(`Illegal State: call.join() shall be called only once`);
1089
1092
  }
1090
1093
 
1094
+ // we need this to be set before the callingx.joinCall() is
1095
+ // called to avoid registering the test call in the CallKit/Telecom
1096
+ this.allowOwnTracksLoopback = allowOwnTracksLoopback;
1097
+
1091
1098
  if (data?.ring) {
1092
1099
  this.ringingSubject.next(true);
1093
1100
  }
1094
1101
 
1095
- // we need this to be set before the callingx.joinCall() is
1096
- // called to avoid registering the test call in the CallKit/Telecom
1097
- this.selfSubEnabled = selfSubEnabled;
1098
-
1099
1102
  const callingX = globalThis.streamRNVideoSDK?.callingX;
1100
1103
  if (callingX) {
1101
1104
  // for Android/iOS, we need to start the call in the callingx library as soon as possible
@@ -1330,23 +1333,9 @@ export class Call {
1330
1333
  // when performing fast reconnect, or when we reuse the same SFU client,
1331
1334
  // (ws remained healthy), we just need to restore the ICE connection
1332
1335
  if (performingFastReconnect) {
1333
- // The SFU automatically issues an ICE restart on the subscriber,
1334
- // so we only need to decide about the publisher. If the publisher's
1335
- // peer connection is still stable (ICE still connected end-to-end),
1336
- // the signal WebSocket drop was the only problem — the new WS alone
1337
- // is enough, and restarting ICE would add unnecessary SDP/ICE churn.
1338
- const publisherIsStable = this.publisher?.isStable() ?? true;
1339
- const includePublisher =
1340
- !!this.publisher?.isPublishing() && !publisherIsStable;
1341
- if (!includePublisher && this.publisher?.isPublishing()) {
1342
- this.logger.info(
1343
- '[Reconnect] FAST: skipping publisher ICE restart, publisher PC is stable',
1344
- );
1345
- }
1346
- await this.restoreICE(sfuClient, {
1347
- includeSubscriber: false,
1348
- includePublisher,
1349
- });
1336
+ // the SFU automatically issues an ICE restart on the subscriber
1337
+ // we don't have to do it ourselves
1338
+ await this.restoreICE(sfuClient, { includeSubscriber: false });
1350
1339
  } else {
1351
1340
  const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
1352
1341
  await this.initPublisherAndSubscriber({
@@ -1545,6 +1534,11 @@ export class Call {
1545
1534
  enable_rtc_stats: enableTracing,
1546
1535
  reporting_interval_ms: reportingIntervalMs,
1547
1536
  } = statsOptions;
1537
+ // Flush the previous reporter's final sample while its peer connections are
1538
+ // still alive, before we dispose them below. Awaits only the sampling step.
1539
+ await this.sfuStatsReporter?.flush();
1540
+ this.sfuStatsReporter?.stop();
1541
+ this.sfuStatsReporter = undefined;
1548
1542
  if (closePreviousInstances && this.subscriber) {
1549
1543
  await this.subscriber.dispose();
1550
1544
  }
@@ -1596,7 +1590,7 @@ export class Call {
1596
1590
  basePeerConnectionOptions,
1597
1591
  publishOptions,
1598
1592
  {
1599
- selfSubEnabled: this.selfSubEnabled,
1593
+ selfSubEnabled: this.allowOwnTracksLoopback,
1600
1594
  },
1601
1595
  );
1602
1596
  }
@@ -1613,8 +1607,6 @@ export class Call {
1613
1607
  }
1614
1608
 
1615
1609
  this.tracer.setEnabled(enableTracing);
1616
- this.sfuStatsReporter?.flush();
1617
- this.sfuStatsReporter?.stop();
1618
1610
  if (statsOptions?.reporting_interval_ms > 0) {
1619
1611
  this.sfuStatsReporter = new SfuStatsReporter(sfuClient, {
1620
1612
  clientDetails,
@@ -1682,6 +1674,10 @@ export class Call {
1682
1674
  reason: string,
1683
1675
  ) => {
1684
1676
  this.logger.debug('[Reconnect] SFU signal connection closed');
1677
+ // Ignore a close from a superseded client (e.g. an old socket's delayed
1678
+ // `onclose` arriving after a reconnection already swapped in a new client).
1679
+ // Only the currently active client's death may drive a reconnection.
1680
+ if (sfuClient !== this.sfuClient) return;
1685
1681
  const { callingState } = this.state;
1686
1682
  if (
1687
1683
  // SFU WS closed before we finished current join,
@@ -1722,11 +1718,12 @@ export class Call {
1722
1718
  strategy: WebsocketReconnectStrategy,
1723
1719
  reason: ReconnectReason,
1724
1720
  ): Promise<void> => {
1721
+ const { callingState } = this.state;
1725
1722
  if (
1726
- this.state.callingState === CallingState.JOINING ||
1727
- this.state.callingState === CallingState.RECONNECTING ||
1728
- this.state.callingState === CallingState.MIGRATING ||
1729
- this.state.callingState === CallingState.RECONNECTING_FAILED
1723
+ callingState === CallingState.JOINING ||
1724
+ callingState === CallingState.RECONNECTING ||
1725
+ callingState === CallingState.MIGRATING ||
1726
+ callingState === CallingState.RECONNECTING_FAILED
1730
1727
  )
1731
1728
  return;
1732
1729
 
@@ -2138,10 +2135,17 @@ export class Call {
2138
2135
  } else {
2139
2136
  if (!this.networkAvailableTask) return;
2140
2137
  this.logger.debug('[Reconnect] Going online');
2141
- this.sfuClient?.close(
2142
- StreamSfuClient.DISPOSE_OLD_SOCKET,
2143
- 'Closing WS to reconnect after going online',
2144
- );
2138
+ // Only discard the socket when no reconnect is already in flight. A
2139
+ // running reconnect owns the SFU-client lifecycle (`doJoin` closes
2140
+ // the previous client once the new session is up). Closing here would
2141
+ // tear down the socket it's mid-join on, leaving the in-flight rejoin
2142
+ // to spin on SIGNAL_LOST.
2143
+ if (!hasPending(this.reconnectConcurrencyTag)) {
2144
+ this.sfuClient?.close(
2145
+ StreamSfuClient.DISPOSE_OLD_SOCKET,
2146
+ 'Closing WS to reconnect after going online',
2147
+ );
2148
+ }
2145
2149
  // we went online, release the previous waiters and reset the state
2146
2150
  this.networkAvailableTask?.resolve();
2147
2151
  this.networkAvailableTask = undefined;
@@ -2495,9 +2499,8 @@ export class Call {
2495
2499
  */
2496
2500
  muteSelf = (type: TrackMuteType) => {
2497
2501
  const myUserId = this.currentUserId;
2498
- if (myUserId) {
2499
- return this.muteUser(myUserId, type);
2500
- }
2502
+ if (!myUserId) return undefined;
2503
+ return this.muteUser(myUserId, type);
2501
2504
  };
2502
2505
 
2503
2506
  /**
@@ -2515,9 +2518,9 @@ export class Call {
2515
2518
  }
2516
2519
  }
2517
2520
 
2518
- if (userIdsToMute.length > 0) {
2519
- return this.muteUser(userIdsToMute, type);
2520
- }
2521
+ return userIdsToMute.length > 0
2522
+ ? this.muteUser(userIdsToMute, type)
2523
+ : undefined;
2521
2524
  };
2522
2525
 
2523
2526
  /**