@streaming-cdn/rtc-web 1.4.0 → 1.5.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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # RTC Web SDK 1.3.28
1
+ # RTC Web SDK 1.5.0
2
2
 
3
3
  The customer package contains compiled ESM/UMD JavaScript and TypeScript declarations. It intentionally excludes implementation source and source maps. Example source remains available under `examples/`.
4
4
 
@@ -140,7 +140,65 @@ origin. In practice:
140
140
  so existing integrations keep working.
141
141
 
142
142
 
143
- For outgoing UI, show `contacting` while mobile push is being delivered, move to `ringing` on `call.delivery`, and show `connected` only after both `call.updated: accepted` and the media client's `connected` event. Do not call `join()` before acceptance. Cancel unanswered calls at the invitation `expiresAt` timestamp (45 seconds by default).
143
+ For outgoing UI, show `contacting` while mobile push is being delivered, move to `ringing` on `call.delivery`, and show `connected` only after `call.updated: accepted` **and the media client's `mediaconnected` event**. The media client's `connected` event means the signaling room was joined — it says nothing about media; a call marked connected on it can have zero media flowing. Do not call `join()` before acceptance. Cancel unanswered calls at the invitation `expiresAt` timestamp (45 seconds by default).
144
+
145
+ ## Connect phase: what fires when, and how long to wait
146
+
147
+ `join()` resolves when the signaling room accepts this participant. Media
148
+ comes up afterwards, and the media client narrates every step of it:
149
+
150
+ | Event | Detail | Meaning |
151
+ | --- | --- | --- |
152
+ | `connected` | `{ participantId, resumed }` | Signaling room joined. **Not** media. |
153
+ | `participantjoined` | participant, plus `initial: true` for peers that were already in the room when you joined | The other side is in the room. This is the moment to anchor your connect budget. |
154
+ | `negotiation` | `{ participantId, phase, at }` — `at` is ms since `join()` | One negotiation step: `offer-created`, `offer-sent`, `offer-received`, `answer-created`, `answer-sent`, `answer-received`, `remote-description-set`, `ice-gathering-complete`, `ice-candidate-sent`, `ice-candidate-received`. Never SDP or candidate bodies. |
155
+ | `icestate` | `{ participantId, state }` | `iceConnectionState` changed (`peerconnectionstate` carries `connectionState`). |
156
+ | `mediaconnected` | `{ participantId, elapsedMs, first }` | Media reached this peer — its connection became `connected` or its first remote track arrived, whichever came first. Once per peer; `first` is true for the first peer of the session. **This is "call connected".** |
157
+ | `connecttimeout` | `{ elapsedMs, peers: [{ participantId, connectionState, iceState, signalingState, lastNegotiationPhase }], participantCount }` | `connectTimeoutMs` (default 30 000; `0` disables) elapsed after `join()` with no `mediaconnected`. Fired once. The client does **not** leave — decide that yourself. |
158
+ | `signalingerror` | `{ code: "send_dropped", messageType }` | A signaling frame was dropped because the socket was not open. Reconnect rebuilds every peer; the loss is reported, never thrown. |
159
+
160
+ On the SFU transport, negotiation is with the platform SFU, so `negotiation`,
161
+ `icestate` and `peerconnectionstate` are attributed to your own participant
162
+ id. `mediaconnected` fires for your own id once the SFU connection is up
163
+ (that is the `first` one), then per remote participant as their tracks
164
+ arrive.
165
+
166
+ `getConnectPhase()` returns `{ joinedAt, mediaConnected, mediaConnectedElapsedMs, timedOut, lastConnectTimeout }`
167
+ for code that attaches late.
168
+
169
+ ### The staged connect budget
170
+
171
+ Measured on the platform (2026-09): a call that connects does so within a
172
+ few seconds of both sides being in the room, but *getting* both sides into
173
+ the room takes as long as the slower device's ring, wake and accept. The
174
+ budget is therefore staged, and **the timer for negotiation starts when the
175
+ other side is in the room (`participantCount === 2` in `stats`, or
176
+ `participantjoined`), not at `join()`**:
177
+
178
+ | Stage | Budget | Signal that ends it |
179
+ | --- | --- | --- |
180
+ | Join the signaling room | 20 s | `connected` (or `join()` rejects) |
181
+ | Wait for the other side to arrive | **at least 20 s, without hanging up** | `participantjoined` / `participantCount === 2` |
182
+ | Negotiation | 15 s from that moment | `mediaconnected` |
183
+ | Give up | **no earlier than 45 s after `join()`** | `connecttimeout`, or your own timer |
184
+
185
+ A callee who joined first spends the "wait for the other side" stage
186
+ watching the caller's `accepted` notification travel; a caller whose callee
187
+ was woken by push spends it watching the ring. Neither is a negotiation
188
+ failure. Set `connectTimeoutMs` at or above 45 000 unless your product has
189
+ its own staging, and treat `connecttimeout` as the diagnostic it is: its
190
+ `peers` snapshot says whether the other side never arrived
191
+ (`peers: []`), arrived and never answered (`lastNegotiationPhase:
192
+ "offer-sent"`), or exchanged descriptions and never found a route
193
+ (`iceState: "checking"` after `remote-description-set`).
194
+
195
+ The incoming-call client follows the same rule: `attachMediaClient()` no
196
+ longer disarms its call watchdog. The watchdog is disarmed by the media
197
+ client's `mediaconnected`; if that never comes, `callexpired` fires and the
198
+ call is released, media client included. The media client's join, media
199
+ establishment and connect timeout are also reported to the platform
200
+ through the existing bounded diagnostics channel (the same one that
201
+ carries signaling outages; no SDP, media, tokens or chat).
144
202
 
145
203
  ## Complete managed integration
146
204
 
@@ -357,6 +415,48 @@ operational diagnostics. Tokens, SDP, media, and chat content are never part of
357
415
  that report. Set `reportSignalingDiagnostics: false` only when replacing this
358
416
  with an application-owned diagnostics pipeline.
359
417
 
418
+ ## 1.5.0 notes
419
+
420
+ New events and one option on the media client; nothing existing changes shape.
421
+
422
+ - **`mediaconnected` is the "call connected" signal.** `connected` was
423
+ documented as it, and it only ever meant the signaling room was joined.
424
+ Two calls on 2026-09-11 were marked connected with no media; the docs
425
+ were the defect.
426
+ - **`negotiation`, `icestate`** narrate the connect phase step by step —
427
+ message types and timing only, never SDP or candidate bodies.
428
+ - **`connectTimeoutMs`** (default 30 000; `0` disables) and the
429
+ **`connecttimeout`** event, which lists every peer's connection, ICE and
430
+ signaling state plus the last negotiation phase. The client does not
431
+ leave on its own.
432
+ - **`participantjoined` fires for peers already in the room**, marked
433
+ `initial: true`. The later arrival used to get no notice at all that
434
+ the other side was there.
435
+ - **`signalingerror { code: "send_dropped" }`** when a signaling frame is
436
+ dropped because the socket is not open. Still no throw.
437
+ - **The final quality sample is taken from a snapshot made before any
438
+ connection is closed**, so it describes the call rather than the
439
+ teardown; a final sample that lands on a running runtime sample now
440
+ waits its turn instead of being skipped. Samples also carry `statsTransport`,
441
+ `statsConnectionCount`, `statsRowCount` and `statsRowTypes`.
442
+ - **`attachMediaClient()` keeps the call watchdog armed** until the media
443
+ client reports `mediaconnected`. Attaching happened exactly when the
444
+ unguarded media phase began, so attaching used to end the only watch.
445
+ Media join, media establishment and connect timeout are reported to the
446
+ platform through the existing diagnostics channel, with the call id.
447
+ - **Staged connect budget published** above: join 20 s, wait for the other
448
+ side at least 20 s without hanging up, negotiation 15 s, give up no
449
+ earlier than 45 s — anchored at the moment the other side is in the room.
450
+
451
+ ## 1.4.0 notes
452
+
453
+ - **Call-slot hardening ported from React Native.** Every path that could
454
+ leave a call id in the active set for the life of the page — hang-up
455
+ failing on the server, an unanswered outgoing call, an accepted call
456
+ whose media never came up, the `accepted` broadcast reaching a session
457
+ holding no media, a `startCall` in flight across `disconnect()` — now
458
+ releases the slot, so the next call always works.
459
+
360
460
  ## 1.3.28 notes
361
461
 
362
462
  - **The idle pose measures which way is down.** 1.3.27 lowered the arms out
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type NativeRoomNegotiationPhase } from "./native-room";
1
2
  export { createPictureInPictureController } from "./picture-in-picture";
2
3
  export type { PictureInPictureController, PictureInPictureOptions } from "./picture-in-picture";
3
4
  export { pickActiveSpeaker, SPEAKER_NOISE_FLOOR, SPEAKER_TAKEOVER_RATIO } from "./active-speaker";
@@ -26,6 +27,65 @@ export type RtcQuality = "auto" | "audio" | "low" | "medium" | "high";
26
27
  export type RtcVideoCodec = "auto" | "vp8" | "h264";
27
28
  export type RtcIceTransportPolicy = "all" | "relay";
28
29
  export type RtcConnectionState = "initialized" | "joining" | "connected" | "reconnecting" | "disconnected" | "failed";
30
+ /**
31
+ * Default for `connectTimeoutMs`: how long after `join()` the client waits for
32
+ * the first peer to reach media before emitting `connecttimeout`. It only
33
+ * reports — the client never leaves on its own.
34
+ */
35
+ export declare const DEFAULT_CONNECT_TIMEOUT_MS = 30000;
36
+ export type RtcNegotiationPhase = NativeRoomNegotiationPhase;
37
+ /** `negotiation` event: one step of a peer's media negotiation, `at` ms after `join()`. Never SDP or candidate bodies. */
38
+ export interface RtcNegotiationEvent {
39
+ participantId: string;
40
+ phase: RtcNegotiationPhase;
41
+ at: number;
42
+ }
43
+ /** `icestate` event: a peer connection's `iceConnectionState` changed. */
44
+ export interface RtcIceStateEvent {
45
+ participantId: string;
46
+ state: string;
47
+ }
48
+ /**
49
+ * `mediaconnected` event: media reached this peer — its connection became
50
+ * `connected` or its first remote track arrived, whichever came first. Fired
51
+ * once per peer; `first` is true only for the first peer of the session.
52
+ */
53
+ export interface RtcMediaConnectedEvent {
54
+ participantId: string;
55
+ elapsedMs: number;
56
+ first: boolean;
57
+ }
58
+ export interface RtcConnectTimeoutPeer {
59
+ participantId: string;
60
+ connectionState: string;
61
+ iceState: string;
62
+ signalingState: string;
63
+ lastNegotiationPhase: RtcNegotiationPhase | null;
64
+ }
65
+ /**
66
+ * `connecttimeout` event: `connectTimeoutMs` elapsed after `join()` with no
67
+ * `mediaconnected`. Says what state every peer was in so the failure is
68
+ * diagnosable from the event alone. Fired at most once per session.
69
+ */
70
+ export interface RtcConnectTimeoutEvent {
71
+ elapsedMs: number;
72
+ peers: RtcConnectTimeoutPeer[];
73
+ participantCount: number;
74
+ }
75
+ /** `signalingerror` detail when a signaling frame was dropped because the socket was not open. */
76
+ export interface RtcSignalingDropEvent {
77
+ code: "send_dropped";
78
+ messageType: string;
79
+ }
80
+ /** Where this session stands between `join()` and media, for integrators that attach late. */
81
+ export interface RtcConnectPhase {
82
+ joinedAt: string | null;
83
+ mediaConnected: boolean;
84
+ mediaConnectedElapsedMs: number | null;
85
+ timedOut: boolean;
86
+ /** The `connecttimeout` payload once it has fired; null otherwise. */
87
+ lastConnectTimeout: RtcConnectTimeoutEvent | null;
88
+ }
29
89
  export interface RtcCredential {
30
90
  signalingToken: string;
31
91
  signalingUrl: string;
@@ -71,6 +131,13 @@ export interface RtcClientOptions {
71
131
  /** Enables automatic quality changes when quality is "auto". */
72
132
  adaptiveQuality?: boolean;
73
133
  autoJoin?: boolean;
134
+ /**
135
+ * Milliseconds after `join()` before `connecttimeout` is emitted when no
136
+ * peer has reached media. Defaults to 30 000; `0` disables. Anchor your own
137
+ * budget at the moment the other side is in the room, not at `join()`: a
138
+ * callee who joined first waits here for the caller through the whole ring.
139
+ */
140
+ connectTimeoutMs?: number;
74
141
  statsInterval?: number;
75
142
  telemetryInterval?: number;
76
143
  autoReportStats?: boolean;
@@ -128,6 +195,16 @@ export interface RtcQualitySample {
128
195
  qualityLimitationReason: string | null;
129
196
  deviceMemoryGb: number | null;
130
197
  effectiveConnectionType: string | null;
198
+ /**
199
+ * Collection diagnostics (S5): which transport was sampled, how many peer
200
+ * connections existed, and what `getStats()` actually returned — so a
201
+ * sample whose derived fields are null still says whether there was
202
+ * nothing to measure or nothing measurable.
203
+ */
204
+ statsTransport: "mesh" | "sfu" | null;
205
+ statsConnectionCount: number | null;
206
+ statsRowCount: number | null;
207
+ statsRowTypes: string | null;
131
208
  }
132
209
  export interface RtcStats {
133
210
  state: RtcConnectionState;
@@ -169,7 +246,7 @@ export declare class RtcClient extends EventTarget {
169
246
  private latestMediaScore;
170
247
  private latestQuality;
171
248
  private statsTimer;
172
- private statsRunning;
249
+ private statsTask;
173
250
  private previousCounters;
174
251
  private lastTelemetryAt;
175
252
  private joinPromise;
@@ -177,10 +254,20 @@ export declare class RtcClient extends EventTarget {
177
254
  private effectiveQuality;
178
255
  private poorQualitySamples;
179
256
  private goodQualitySamples;
257
+ private transport;
258
+ private joinStartedAt;
259
+ private connectTimer;
260
+ private connectTimedOut;
261
+ private lastConnectTimeout;
262
+ private readonly mediaConnectedPeers;
263
+ private firstMediaConnectedElapsedMs;
264
+ private readonly lastNegotiationPhase;
180
265
  constructor(options: RtcClientOptions);
181
266
  initialize(): Promise<this>;
182
267
  join(): Promise<void>;
183
268
  leave(): Promise<void>;
269
+ /** Where the session stands between `join()` and media. Lets a late `attachMediaClient` catch up. */
270
+ getConnectPhase(): RtcConnectPhase;
184
271
  setMuted(muted: boolean): Promise<void>;
185
272
  setCameraEnabled(enabled: boolean): Promise<void>;
186
273
  setVideoTrack(track: MediaStreamTrack): Promise<void>;
@@ -214,13 +301,22 @@ export declare class RtcClient extends EventTarget {
214
301
  requestMedia?: boolean;
215
302
  probeUrl?: string;
216
303
  }): Promise<RtcPreflightResult>;
217
- collectQualitySample(sampleType?: RtcQualitySample["sampleType"]): Promise<RtcQualitySample>;
304
+ /**
305
+ * `connectionState` names the state to report instead of reading it from the
306
+ * connection — `leave()` passes the snapshot it took before closing anything.
307
+ */
308
+ collectQualitySample(sampleType?: RtcQualitySample["sampleType"], connectionState?: string): Promise<RtcQualitySample>;
218
309
  reportQuality(sample: Partial<RtcQualitySample>): Promise<void>;
219
310
  getStats(): RtcStats;
220
311
  /** Escape hatch for advanced integrations. Prefer the stable SDK methods when possible. */
221
312
  getNativeClient<T = unknown>(): T | null;
222
313
  destroy(): void;
223
314
  private handleNativeEvent;
315
+ private elapsedSinceJoin;
316
+ /** Fires `mediaconnected` once per peer, and disarms the connect watchdog on the first. */
317
+ private markMediaConnected;
318
+ private armConnectTimeout;
319
+ private clearConnectTimeout;
224
320
  private startStats;
225
321
  private sampleAndReport;
226
322
  private stopStats;
@@ -320,6 +416,7 @@ export declare class RtcIncomingClient extends EventTarget {
320
416
  private currentTokenExpiresAt?;
321
417
  private readonly callAudio;
322
418
  private readonly mediaClients;
419
+ private readonly mediaSubscriptions;
323
420
  private readonly activeCallIds;
324
421
  private readonly seenInvitationIds;
325
422
  private readonly invitationExpiryTimers;
@@ -330,8 +427,17 @@ export declare class RtcIncomingClient extends EventTarget {
330
427
  constructor(options: RtcIncomingClientOptions);
331
428
  connect(): this;
332
429
  setMount(target: HTMLElement | string | null): void;
333
- /** Binds media lifetime to a call. Terminal call states automatically leave and destroy it. */
430
+ /**
431
+ * Binds media lifetime to a call. Terminal call states automatically leave
432
+ * and destroy it. The call watchdog keeps running until the media client
433
+ * reports `mediaconnected`; if it expires first, `callexpired` fires and the
434
+ * call — media included — is released. The media client's join, media
435
+ * establishment and connect timeout are reported to the platform through
436
+ * the same bounded diagnostics channel as signaling outages.
437
+ */
334
438
  attachMediaClient(callId: string, media: RtcClient): () => void;
439
+ /** True once the media client bound to this call has reached media on at least one peer. */
440
+ private mediaEstablished;
335
441
  /**
336
442
  * Arms the self-release timer for a call that holds the single call slot but
337
443
  * has no media attached. Every path that claims the slot arms this, so no
@@ -403,4 +509,4 @@ export declare class RtcIncomingClient extends EventTarget {
403
509
  private emit;
404
510
  }
405
511
  export declare function createRtcIncomingClient(options: RtcIncomingClientOptions): RtcIncomingClient;
406
- export declare const version = "1.4.0";
512
+ export declare const version = "1.5.0";
@@ -30,6 +30,21 @@ export interface NativeRoomOptions {
30
30
  }
31
31
  export type NativeRoomQuality = "audio" | "low" | "medium" | "high";
32
32
  export type NativeRoomVideoCodec = "auto" | "vp8" | "h264";
33
+ /**
34
+ * One step of a peer's media negotiation, as seen from this side. Emitted as
35
+ * `negotiation` with the peer's participant id; never the SDP or a candidate
36
+ * body. The phases are the same on the React Native adapter.
37
+ */
38
+ export type NativeRoomNegotiationPhase = "offer-created" | "offer-sent" | "offer-received" | "answer-created" | "answer-sent" | "answer-received" | "remote-description-set" | "ice-gathering-complete" | "ice-candidate-sent" | "ice-candidate-received";
39
+ /** Snapshot of one connection's state machines, for diagnostics. */
40
+ export interface NativeRoomPeerState {
41
+ participantId: string;
42
+ connectionState: string;
43
+ iceState: string;
44
+ signalingState: string;
45
+ }
46
+ declare function peerStateOf(participantId: string, connection: RTCPeerConnection): NativeRoomPeerState;
47
+ export { peerStateOf as readPeerState };
33
48
  export declare class NativeRoomClient {
34
49
  private readonly options;
35
50
  private socket;
@@ -71,12 +86,15 @@ export declare class NativeRoomClient {
71
86
  stream: MediaStream;
72
87
  }>;
73
88
  getPrimaryPeerConnection(): RTCPeerConnection | null;
89
+ /** Every peer connection's state machines at this instant, one entry per remote participant. */
90
+ getPeerStates(): NativeRoomPeerState[];
74
91
  sendChat(message: string, participantIds?: string[]): void;
75
92
  sendControl(action: string, detail?: Record<string, unknown>): void;
76
93
  private connect;
77
94
  private openSocket;
78
95
  private handleSignal;
79
96
  private ensurePeer;
97
+ private negotiation;
80
98
  private createOffer;
81
99
  private performOffer;
82
100
  private shouldInitiateOffer;