@marianmeres/webrtc 1.4.5 → 2.1.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/AGENTS.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ```yaml
6
6
  name: "@marianmeres/webrtc"
7
- version: "1.0.2"
7
+ version: "2.0.0"
8
8
  license: MIT
9
9
  author: Marian Meres
10
10
  repository: https://github.com/marianmeres/webrtc
@@ -27,12 +27,13 @@ A lightweight, framework-agnostic WebRTC manager providing:
27
27
 
28
28
  ```yaml
29
29
  production:
30
- - "@marianmeres/fsm": "^2.11.0"
31
- - "@marianmeres/pubsub": "^2.4.4"
30
+ - "@marianmeres/clog": "^3.15.2"
31
+ - "@marianmeres/fsm": "^2.16.4"
32
+ - "@marianmeres/pubsub": "^2.4.6"
32
33
  development:
33
- - "@std/assert": "^1.0.16"
34
- - "@std/fs": "^1.0.20"
35
- - "@std/path": "^1.1.3"
34
+ - "@std/assert": "^1.0.18"
35
+ - "@std/fs": "^1.0.22"
36
+ - "@std/path": "^1.1.4"
36
37
  ```
37
38
 
38
39
  ## File Structure
@@ -93,15 +94,22 @@ scripts/
93
94
 
94
95
  ```
95
96
  IDLE --INIT--> INITIALIZING
97
+ IDLE --RESET--> IDLE (2.0)
96
98
  INITIALIZING --CONNECT--> CONNECTING
99
+ INITIALIZING --DISCONNECT--> DISCONNECTED (2.0, was silent no-op)
97
100
  INITIALIZING --ERROR--> ERROR
101
+ INITIALIZING --RESET--> IDLE (2.0, was silent no-op)
98
102
  CONNECTING --CONNECTED--> CONNECTED
99
103
  CONNECTING --DISCONNECT--> DISCONNECTED
100
104
  CONNECTING --ERROR--> ERROR
105
+ CONNECTING --RESET--> IDLE (2.0, was silent no-op)
101
106
  CONNECTED --DISCONNECT--> DISCONNECTED
102
107
  CONNECTED --ERROR--> ERROR
108
+ CONNECTED --RESET--> IDLE (2.0, was silent no-op)
103
109
  RECONNECTING --CONNECT--> CONNECTING
110
+ RECONNECTING --CONNECTED--> CONNECTED (2.0, fixes ICE-restart stuck-state bug)
104
111
  RECONNECTING --DISCONNECT--> DISCONNECTED
112
+ RECONNECTING --ERROR--> ERROR (2.0)
105
113
  RECONNECTING --RESET--> IDLE
106
114
  DISCONNECTED --CONNECT--> CONNECTING
107
115
  DISCONNECTED --RECONNECTING-->RECONNECTING
@@ -150,10 +158,20 @@ interface WebRTCFactory {
150
158
  interface WebRTCManagerConfig {
151
159
  peerConfig?: RTCConfiguration; // ICE servers, certificates
152
160
  enableMicrophone?: boolean; // Default: false
161
+ audioDirection?: RTCRtpTransceiverDirection; // Default: "recvonly" (2.0)
162
+ // Direction for the audio transceiver added
163
+ // when enableMicrophone is false. Use "sendrecv"
164
+ // to avoid renegotiation when enabling mic later.
153
165
  dataChannelLabel?: string; // Auto-create data channel
154
166
  autoReconnect?: boolean; // Default: false
155
167
  maxReconnectAttempts?: number; // Default: 5
156
168
  reconnectDelay?: number; // Default: 1000ms
169
+ fullReconnectTimeout?: number; // Timeout for full reconnect strategy (default: 30000ms)
170
+ shouldReconnect?: (context: { // Callback to control reconnection
171
+ attempt: number;
172
+ maxAttempts: number;
173
+ strategy: "ice-restart" | "full";
174
+ }) => boolean;
157
175
  logger?: Logger; // Custom logger, falls back to console
158
176
  }
159
177
  ```
@@ -163,7 +181,9 @@ interface WebRTCManagerConfig {
163
181
  ```typescript
164
182
  interface GatherIceCandidatesOptions {
165
183
  timeout?: number; // Timeout in ms (default: 10000)
166
- onCandidate?: (candidate: RTCIceCandidate | null) => void; // Called for each candidate
184
+ onCandidate?: (candidate: RTCIceCandidate) => void; // Called for each REAL candidate
185
+ // (2.0: null sentinel no longer forwarded)
186
+ resolveOnTimeout?: boolean; // (2.0) Resolve instead of reject on timeout
167
187
  }
168
188
  ```
169
189
 
@@ -173,7 +193,8 @@ interface GatherIceCandidatesOptions {
173
193
  |----------|------|-------------|
174
194
  | state | WebRTCState | Current FSM state |
175
195
  | localStream | MediaStream \| null | Local audio stream |
176
- | remoteStream | MediaStream \| null | Remote audio stream |
196
+ | remoteStream | MediaStream \| null | First remote stream received (legacy single-stream accessor) |
197
+ | remoteStreams | ReadonlyMap<string, MediaStream> | (2.0) All remote streams keyed by `stream.id` |
177
198
  | dataChannels | ReadonlyMap<string, RTCDataChannel> | Active data channels |
178
199
  | peerConnection | RTCPeerConnection \| null | Underlying connection |
179
200
  | context | TContext \| null | User-defined context for arbitrary data |
@@ -183,9 +204,10 @@ interface GatherIceCandidatesOptions {
183
204
  | Method | Signature | Description |
184
205
  |--------|-----------|-------------|
185
206
  | initialize | `(): Promise<void>` | Create peer connection, setup tracks |
186
- | connect | `(): Promise<void>` | Transition to CONNECTING (auto-initializes if IDLE) |
187
- | disconnect | `(): void` | Close connection, cleanup resources |
188
- | reset | `(): void` | Reset to IDLE from any state |
207
+ | connect | `(): Promise<void>` | Transition to CONNECTING (auto-initializes if IDLE). (2.0) Resets `#reconnectAttempts` so a prior exhausted reconnect budget does not block new attempts. |
208
+ | disconnect | `(): void` | Close connection, cleanup resources. (2.0) Also resets `#reconnectAttempts` and publishes `local_stream:null` / `remote_stream:null`. |
209
+ | reset | `(): void` | Reset to IDLE from any state. (2.0) Now valid from every state (previously silently no-op'd from INITIALIZING/CONNECTING/CONNECTED). |
210
+ | dispose | `(): void` | (2.0) Fully dispose: unsubscribes every listener registered via `on()`/`subscribe()`, cleans up the PC, transitions to IDLE. Idempotent. Manager should not be reused after dispose. |
189
211
 
190
212
  ### Audio Methods
191
213
 
@@ -212,7 +234,7 @@ interface GatherIceCandidatesOptions {
212
234
  | setLocalDescription | `(description: RTCSessionDescriptionInit): Promise<boolean>` | Set local SDP |
213
235
  | setRemoteDescription | `(description: RTCSessionDescriptionInit): Promise<boolean>` | Set remote SDP |
214
236
  | addIceCandidate | `(candidate: RTCIceCandidateInit \| null): Promise<boolean>` | Add ICE candidate |
215
- | iceRestart | `(): Promise<boolean>` | Perform ICE restart |
237
+ | iceRestart | `(): Promise<boolean>` | Perform ICE restart. (2.0) Emits `ice_restart_offer` with the new local offer so the consumer can forward it via signaling. |
216
238
  | gatherIceCandidates | `(options?: GatherIceCandidatesOptions): Promise<void>` | Wait for ICE gathering to complete |
217
239
  | getLocalDescription | `(): RTCSessionDescription \| null` | Get local SDP |
218
240
  | getRemoteDescription | `(): RTCSessionDescription \| null` | Get remote SDP |
@@ -233,20 +255,22 @@ interface GatherIceCandidatesOptions {
233
255
 
234
256
  ## Event Constants
235
257
 
236
- | Constant | Value | Payload Type |
237
- |----------|-------|--------------|
238
- | EVENT_STATE_CHANGE | "state_change" | WebRTCState |
239
- | EVENT_LOCAL_STREAM | "local_stream" | MediaStream \| null |
240
- | EVENT_REMOTE_STREAM | "remote_stream" | MediaStream \| null |
241
- | EVENT_DATA_CHANNEL_OPEN | "data_channel_open" | RTCDataChannel |
242
- | EVENT_DATA_CHANNEL_MESSAGE | "data_channel_message" | { channel: RTCDataChannel; data: any } |
243
- | EVENT_DATA_CHANNEL_CLOSE | "data_channel_close" | RTCDataChannel |
244
- | EVENT_ICE_CANDIDATE | "ice_candidate" | RTCIceCandidate \| null |
245
- | EVENT_RECONNECTING | "reconnecting" | { attempt: number; strategy: "ice-restart" \| "full" } |
246
- | EVENT_RECONNECT_FAILED | "reconnect_failed" | { attempts: number } |
247
- | EVENT_DEVICE_CHANGED | "device_changed" | MediaDeviceInfo[] |
248
- | EVENT_MICROPHONE_FAILED | "microphone_failed" | { error?: any; reason?: string } |
249
- | EVENT_ERROR | "error" | Error |
258
+ | Constant | Value | Payload Type | Notes |
259
+ |----------|-------|--------------|-------|
260
+ | EVENT_STATE_CHANGE | "state_change" | WebRTCState | |
261
+ | EVENT_LOCAL_STREAM | "local_stream" | MediaStream \| null | (2.0) Also published as `null` on `disconnect()` / `cleanup()` |
262
+ | EVENT_REMOTE_STREAM | "remote_stream" | MediaStream \| null | (2.0) Also published as `null` on `disconnect()` / `cleanup()` |
263
+ | EVENT_DATA_CHANNEL_OPEN | "data_channel_open" | RTCDataChannel | |
264
+ | EVENT_DATA_CHANNEL_MESSAGE | "data_channel_message" | { channel: RTCDataChannel; data: any } | |
265
+ | EVENT_DATA_CHANNEL_CLOSE | "data_channel_close" | RTCDataChannel | |
266
+ | EVENT_ICE_CANDIDATE | "ice_candidate" | RTCIceCandidate \| null | |
267
+ | EVENT_RECONNECTING | "reconnecting" | { attempt: number; strategy: "ice-restart" \| "full" } | |
268
+ | EVENT_RECONNECT_FAILED | "reconnect_failed" | { attempts: number } | |
269
+ | EVENT_DEVICE_CHANGED | "device_changed" | MediaDeviceInfo[] | |
270
+ | EVENT_MICROPHONE_FAILED | "microphone_failed" | { error?: any; reason?: string } | |
271
+ | EVENT_ERROR | "error" | Error | |
272
+ | EVENT_ICE_RESTART_OFFER | "ice_restart_offer" | RTCSessionDescriptionInit | (2.0) Emitted after `iceRestart()` creates and sets a new local offer. Consumers MUST forward it via signaling. |
273
+ | EVENT_NEGOTIATION_NEEDED | "negotiation_needed" | undefined | (2.0) Forwarded from `pc.onnegotiationneeded`. Fires when renegotiation is required (e.g. late data channel or track change). |
250
274
 
251
275
  ## Signaling Flow (User Responsibility)
252
276
 
@@ -327,9 +351,12 @@ deno task serve:example # Run signaling server
327
351
  2. Data channels auto-cleanup on close
328
352
  3. Device change listener auto-setup on initialize
329
353
  4. "User-Initiated Abort" errors from intentional `close()` are ignored
330
- 5. `recvonly` transceiver added when microphone disabled (ensures audio SDP)
354
+ 5. Audio transceiver added when microphone disabled (ensures audio SDP). Direction defaults to `recvonly`; override with `audioDirection` config (2.0).
331
355
  6. Private fields use `#` syntax (true ES2022 private fields)
332
356
  7. Signaling transport NOT included - users implement their own
357
+ 8. (2.0) `#reconnectAttempts` is reset whenever the user explicitly calls `connect()` / `disconnect()` / `reset()` / `dispose()`, so a prior exhausted reconnect budget never blocks a fresh session.
358
+ 9. (2.0) ICE-restart success transitions `RECONNECTING -> CONNECTED` directly via the new FSM edge. Previously the FSM stayed stuck in `RECONNECTING` because the transition did not exist.
359
+ 10. (2.0) `switchMicrophone()` promotes `recvonly` / `inactive` transceivers to `sendrecv` so replacing the track actually transmits.
333
360
 
334
361
  ## Common Usage Patterns
335
362
 
@@ -379,7 +406,54 @@ manager.on("reconnecting", ({ attempt, strategy }) => {
379
406
  }
380
407
  });
381
408
 
409
+ // (2.0) For strategy="ice-restart", forward the offer manually if desired.
410
+ // The library emits the local offer via EVENT_ICE_RESTART_OFFER — consumers
411
+ // must send it to the remote peer for the restart to actually succeed.
412
+ manager.on("ice_restart_offer", (offer) => {
413
+ signalingChannel.send({ type: "offer", offer });
414
+ });
415
+
382
416
  manager.on("reconnect_failed", ({ attempts }) => {
383
417
  console.log(`Reconnection failed after ${attempts} attempts`);
384
418
  });
385
419
  ```
420
+
421
+ ## Breaking Changes (2.0)
422
+
423
+ Migrating from 1.x → 2.x. Most changes are bug fixes that align with documented behavior; only one consumer-visible break.
424
+
425
+ ### 1. `gatherIceCandidates` — `onCandidate` callback no longer receives the terminal `null`
426
+
427
+ 1.x forwarded the end-of-gathering `null` sentinel to `onCandidate`. 2.x forwards only real candidates. End-of-gathering is signaled by the returned promise resolving.
428
+
429
+ ```typescript
430
+ // 1.x
431
+ await manager.gatherIceCandidates({
432
+ onCandidate: (c) => {
433
+ if (c === null) handleEnd();
434
+ else collect.push(c);
435
+ },
436
+ });
437
+
438
+ // 2.x
439
+ await manager.gatherIceCandidates({
440
+ onCandidate: (c) => collect.push(c),
441
+ });
442
+ handleEnd(); // promise resolution == end of gathering
443
+ ```
444
+
445
+ ### 2. Behavior changes (no API change, but observable)
446
+
447
+ - `local_stream` / `remote_stream` events are now emitted with `null` payload on `disconnect()` / `cleanup()`. Subscribers that only handled `MediaStream` payloads must also handle `null` (this matches how `enableMicrophone(false)` already behaved).
448
+ - `reset()` now works from every state, including `INITIALIZING` / `CONNECTING` / `CONNECTED`. Previously these silently no-op'd — consumers relying on `reset()` being a no-op in those states must now expect the FSM to land in IDLE.
449
+ - After a successful ICE-restart reconnect, the FSM now transitions `RECONNECTING -> CONNECTED`. In 1.x it remained stuck in `RECONNECTING`.
450
+ - `#reconnectAttempts` is reset on every explicit `connect()` / `disconnect()` / `reset()` / `dispose()`. A 1.x consumer that exhausted the reconnect budget and then called `connect()` again would see no further reconnect attempts — 2.x correctly resumes.
451
+
452
+ ### 3. Additive (no code change required)
453
+
454
+ - New config: `audioDirection` (default `"recvonly"` — same effective behavior as 1.x).
455
+ - New getter: `remoteStreams: ReadonlyMap<string, MediaStream>`.
456
+ - New method: `dispose()`.
457
+ - New option: `gatherIceCandidates({ resolveOnTimeout: true })`.
458
+ - New events: `ice_restart_offer`, `negotiation_needed`.
459
+ - New static constants: `EVENT_ICE_RESTART_OFFER`, `EVENT_NEGOTIATION_NEEDED`.
package/API.md CHANGED
@@ -16,6 +16,7 @@ Complete API documentation for `@marianmeres/webrtc`.
16
16
  - [Types](#types)
17
17
  - [WebRTCFactory](#webrtcfactory)
18
18
  - [WebRTCManagerConfig](#webrtcmanagerconfig)
19
+ - [GatherIceCandidatesOptions](#gathericecandidatesoptions)
19
20
  - [WebRTCState](#webrtcstate)
20
21
  - [WebRTCFsmEvent](#webrtcfsmevent)
21
22
  - [WebRTCEvents](#webrtcevents)
@@ -99,12 +100,26 @@ Returns the local media stream (microphone audio), or `null` if not initialized.
99
100
  get remoteStream(): MediaStream | null
100
101
  ```
101
102
 
102
- Returns the remote media stream received from peer, or `null` if not connected.
103
+ Returns the **first** remote media stream received from the peer, or `null` if not connected. Provided for backwards compatibility; prefer `remoteStreams` when the remote side may publish more than one stream.
103
104
 
104
105
  **Returns:** `MediaStream | null`
105
106
 
106
107
  ---
107
108
 
109
+ #### remoteStreams
110
+
111
+ ```typescript
112
+ get remoteStreams(): ReadonlyMap<string, MediaStream>
113
+ ```
114
+
115
+ Returns every remote media stream received so far, keyed by `stream.id`. Populated incrementally as `ontrack` events fire. Useful when the remote peer publishes multiple streams (e.g. separate audio and video, or audio from multiple participants in an SFU-style setup).
116
+
117
+ **Returns:** `ReadonlyMap<string, MediaStream>`
118
+
119
+ **Added in:** 2.0
120
+
121
+ ---
122
+
108
123
  #### dataChannels
109
124
 
110
125
  ```typescript
@@ -199,6 +214,8 @@ Transitions to the `CONNECTING` state. Automatically calls `initialize()` if in
199
214
  - From `INITIALIZING`: Transitions to `CONNECTING`
200
215
  - From `CONNECTED` or `CONNECTING`: No-op
201
216
 
217
+ **Side effects (2.0):** Resets the internal reconnect attempts counter when starting a fresh session, so a previously-exhausted reconnect budget does not block new attempts.
218
+
202
219
  **Example:**
203
220
 
204
221
  ```typescript
@@ -219,6 +236,8 @@ Disconnects the peer connection and cleans up all resources:
219
236
  - Stops local media tracks
220
237
  - Closes peer connection
221
238
  - Clears reconnection timers
239
+ - (2.0) Publishes `local_stream` / `remote_stream` with `null` payload so UIs drop stale stream references
240
+ - (2.0) Resets the reconnect attempts counter
222
241
 
223
242
  **Transitions:** Any state → `DISCONNECTED`
224
243
 
@@ -241,6 +260,8 @@ Resets the manager to `IDLE` state from any state. Performs full cleanup and all
241
260
 
242
261
  **Transitions:** Any state → `IDLE`
243
262
 
263
+ **Changed in 2.0:** Now works correctly from every state. In 1.x, calling `reset()` from `INITIALIZING` / `CONNECTING` / `CONNECTED` silently no-op'd because those FSM transitions didn't exist.
264
+
244
265
  **Example:**
245
266
 
246
267
  ```typescript
@@ -251,6 +272,28 @@ console.log(manager.state); // "IDLE"
251
272
 
252
273
  ---
253
274
 
275
+ #### dispose()
276
+
277
+ ```typescript
278
+ dispose(): void
279
+ ```
280
+
281
+ Fully disposes the manager. Unsubscribes every listener registered via `on()` or `subscribe()`, closes the peer connection, stops streams, and transitions to `IDLE`. Idempotent. After calling this, the manager should not be reused.
282
+
283
+ Use this in framework teardown hooks (React `useEffect` cleanup, Svelte `onDestroy`, Vue `onUnmounted`, etc.) instead of tracking each returned unsubscribe handle manually.
284
+
285
+ **Added in:** 2.0
286
+
287
+ **Example:**
288
+
289
+ ```typescript
290
+ onDestroy(() => {
291
+ manager.dispose();
292
+ });
293
+ ```
294
+
295
+ ---
296
+
254
297
  ### Audio Methods
255
298
 
256
299
  #### enableMicrophone()
@@ -302,6 +345,8 @@ Switches the active microphone to a different audio input device.
302
345
 
303
346
  **Requirements:** Peer connection must be initialized and microphone must be enabled.
304
347
 
348
+ **Changed in 2.0:** Also promotes `recvonly` / `inactive` audio transceivers to `sendrecv`. In 1.x, switching the mic on a connection initialized with `enableMicrophone: false` would replace the track but leave the direction as `recvonly`, silently producing no audio.
349
+
305
350
  **Example:**
306
351
 
307
352
  ```typescript
@@ -551,15 +596,47 @@ await manager.addIceCandidate(remoteCandidate);
551
596
  async iceRestart(): Promise<boolean>
552
597
  ```
553
598
 
554
- Performs an ICE restart to recover from connection issues. Creates a new offer with the `iceRestart` flag.
599
+ Performs an ICE restart to recover from connection issues. Creates a new offer with the `iceRestart` flag, sets it as the local description, and emits `ice_restart_offer` with the new offer.
555
600
 
556
601
  **Returns:** `boolean` - `true` if successful
557
602
 
603
+ **Changed in 2.0:** Emits `EVENT_ICE_RESTART_OFFER` with the new local offer. Consumers **must** forward this offer to the remote peer via their signaling channel — otherwise the ICE restart silently has no effect. In 1.x the offer never left the library.
604
+
558
605
  **Example:**
559
606
 
560
607
  ```typescript
608
+ manager.on(WebRTCManager.EVENT_ICE_RESTART_OFFER, (offer) => {
609
+ signalingChannel.send({ type: "offer", offer });
610
+ });
611
+
561
612
  const success = await manager.iceRestart();
562
- // New ICE candidates will be generated
613
+ // New ICE candidates will be generated once the remote side responds
614
+ // to the forwarded offer with an answer.
615
+ ```
616
+
617
+ ---
618
+
619
+ #### gatherIceCandidates()
620
+
621
+ ```typescript
622
+ gatherIceCandidates(options?: GatherIceCandidatesOptions): Promise<void>
623
+ ```
624
+
625
+ Waits for ICE gathering to complete. Useful for HTTP-POST signaling patterns where you need the local description to bundle all candidates before sending.
626
+
627
+ **Parameters:** See [GatherIceCandidatesOptions](#gathericecandidatesoptions).
628
+
629
+ **Returns:** `Promise<void>` — resolves when gathering completes, rejects on timeout (unless `resolveOnTimeout: true`) or if the peer connection is not initialized.
630
+
631
+ **Changed in 2.0:** The `onCandidate` callback no longer receives the terminal `null` sentinel. End-of-gathering is signaled exclusively by the promise resolving.
632
+
633
+ **Example:**
634
+
635
+ ```typescript
636
+ const offer = await manager.createOffer();
637
+ await manager.setLocalDescription(offer);
638
+ await manager.gatherIceCandidates({ timeout: 5000 });
639
+ // manager.peerConnection.localDescription now has all candidates bundled
563
640
  ```
564
641
 
565
642
  ---
@@ -779,6 +856,15 @@ interface WebRTCManagerConfig {
779
856
  /** Enable microphone on initialization. Default: false */
780
857
  enableMicrophone?: boolean;
781
858
 
859
+ /**
860
+ * Direction of the audio transceiver added during `initialize()` when the
861
+ * microphone is NOT enabled. Default: "recvonly".
862
+ * Use "sendrecv" if you plan to enable the mic later and want to avoid
863
+ * renegotiation when the track is added.
864
+ * Added in 2.0.
865
+ */
866
+ audioDirection?: RTCRtpTransceiverDirection;
867
+
782
868
  /** Create a data channel with this label on connect */
783
869
  dataChannelLabel?: string;
784
870
 
@@ -808,6 +894,34 @@ interface WebRTCManagerConfig {
808
894
 
809
895
  ---
810
896
 
897
+ ### GatherIceCandidatesOptions
898
+
899
+ Options for `gatherIceCandidates()`.
900
+
901
+ ```typescript
902
+ interface GatherIceCandidatesOptions {
903
+ /** Timeout in milliseconds. Default: 10000 */
904
+ timeout?: number;
905
+
906
+ /**
907
+ * Called for each real ICE candidate as it's gathered.
908
+ * The terminal `null` (end-of-gathering sentinel) is NOT forwarded —
909
+ * use the returned promise's resolution to detect completion.
910
+ */
911
+ onCandidate?: (candidate: RTCIceCandidate) => void;
912
+
913
+ /**
914
+ * When true, the returned promise resolves instead of rejecting on timeout.
915
+ * Useful for HTTP-POST signaling where partial candidates are better than none.
916
+ * Default: false.
917
+ * Added in 2.0.
918
+ */
919
+ resolveOnTimeout?: boolean;
920
+ }
921
+ ```
922
+
923
+ ---
924
+
811
925
  ### WebRTCState
812
926
 
813
927
  Enum of possible connection states.
@@ -872,6 +986,10 @@ interface WebRTCEvents {
872
986
  device_changed: MediaDeviceInfo[];
873
987
  microphone_failed: { error?: any; reason?: string };
874
988
  error: Error;
989
+ /** (2.0) Local offer produced by iceRestart(); forward via signaling. */
990
+ ice_restart_offer: RTCSessionDescriptionInit;
991
+ /** (2.0) Forwarded from pc.onnegotiationneeded. */
992
+ negotiation_needed: undefined;
875
993
  }
876
994
  ```
877
995
 
@@ -884,12 +1002,14 @@ Static event name constants on `WebRTCManager`.
884
1002
  | Constant | Value | Payload |
885
1003
  |----------|-------|---------|
886
1004
  | `EVENT_STATE_CHANGE` | `"state_change"` | `WebRTCState` |
887
- | `EVENT_LOCAL_STREAM` | `"local_stream"` | `MediaStream \| null` |
888
- | `EVENT_REMOTE_STREAM` | `"remote_stream"` | `MediaStream \| null` |
1005
+ | `EVENT_LOCAL_STREAM` | `"local_stream"` | `MediaStream \| null` (also fires `null` on teardown — 2.0) |
1006
+ | `EVENT_REMOTE_STREAM` | `"remote_stream"` | `MediaStream \| null` (also fires `null` on teardown — 2.0) |
889
1007
  | `EVENT_DATA_CHANNEL_OPEN` | `"data_channel_open"` | `RTCDataChannel` |
890
1008
  | `EVENT_DATA_CHANNEL_MESSAGE` | `"data_channel_message"` | `{ channel, data }` |
891
1009
  | `EVENT_DATA_CHANNEL_CLOSE` | `"data_channel_close"` | `RTCDataChannel` |
892
1010
  | `EVENT_ICE_CANDIDATE` | `"ice_candidate"` | `RTCIceCandidate \| null` |
1011
+ | `EVENT_ICE_RESTART_OFFER` | `"ice_restart_offer"` | `RTCSessionDescriptionInit` (2.0) |
1012
+ | `EVENT_NEGOTIATION_NEEDED` | `"negotiation_needed"` | `undefined` (2.0) |
893
1013
  | `EVENT_RECONNECTING` | `"reconnecting"` | `{ attempt, strategy }` |
894
1014
  | `EVENT_RECONNECT_FAILED` | `"reconnect_failed"` | `{ attempts }` |
895
1015
  | `EVENT_DEVICE_CHANGED` | `"device_changed"` | `MediaDeviceInfo[]` |
@@ -948,23 +1068,30 @@ manager.on(WebRTCManager.EVENT_ICE_CANDIDATE, (candidate) => {
948
1068
 
949
1069
  ### Valid Transitions
950
1070
 
951
- | From State | Event | To State |
952
- |------------|-------|----------|
953
- | IDLE | INIT | INITIALIZING |
954
- | INITIALIZING | CONNECT | CONNECTING |
955
- | INITIALIZING | ERROR | ERROR |
956
- | CONNECTING | CONNECTED | CONNECTED |
957
- | CONNECTING | DISCONNECT | DISCONNECTED |
958
- | CONNECTING | ERROR | ERROR |
959
- | CONNECTED | DISCONNECT | DISCONNECTED |
960
- | CONNECTED | ERROR | ERROR |
961
- | RECONNECTING | CONNECT | CONNECTING |
962
- | RECONNECTING | DISCONNECT | DISCONNECTED |
963
- | RECONNECTING | RESET | IDLE |
964
- | DISCONNECTED | CONNECT | CONNECTING |
965
- | DISCONNECTED | RECONNECTING | RECONNECTING |
966
- | DISCONNECTED | RESET | IDLE |
967
- | ERROR | RESET | IDLE |
1071
+ | From State | Event | To State | Notes |
1072
+ |------------|-------|----------|-------|
1073
+ | IDLE | INIT | INITIALIZING | |
1074
+ | IDLE | RESET | IDLE | 2.0 — idempotent reset |
1075
+ | INITIALIZING | CONNECT | CONNECTING | |
1076
+ | INITIALIZING | DISCONNECT | DISCONNECTED | 2.0 — was silent no-op |
1077
+ | INITIALIZING | ERROR | ERROR | |
1078
+ | INITIALIZING | RESET | IDLE | 2.0 — was silent no-op |
1079
+ | CONNECTING | CONNECTED | CONNECTED | |
1080
+ | CONNECTING | DISCONNECT | DISCONNECTED | |
1081
+ | CONNECTING | ERROR | ERROR | |
1082
+ | CONNECTING | RESET | IDLE | 2.0 — was silent no-op |
1083
+ | CONNECTED | DISCONNECT | DISCONNECTED | |
1084
+ | CONNECTED | ERROR | ERROR | |
1085
+ | CONNECTED | RESET | IDLE | 2.0 — was silent no-op |
1086
+ | RECONNECTING | CONNECT | CONNECTING | |
1087
+ | RECONNECTING | CONNECTED | CONNECTED | 2.0 — fixes ICE-restart stuck-state bug |
1088
+ | RECONNECTING | DISCONNECT | DISCONNECTED | |
1089
+ | RECONNECTING | ERROR | ERROR | 2.0 |
1090
+ | RECONNECTING | RESET | IDLE | |
1091
+ | DISCONNECTED | CONNECT | CONNECTING | |
1092
+ | DISCONNECTED | RECONNECTING | RECONNECTING | |
1093
+ | DISCONNECTED | RESET | IDLE | |
1094
+ | ERROR | RESET | IDLE | |
968
1095
 
969
1096
  ### Reconnection Strategy
970
1097
 
package/CLAUDE.md ADDED
@@ -0,0 +1,3 @@
1
+ # Project Instructions
2
+
3
+ See [AGENTS.md](./AGENTS.md) for complete project documentation and AI agent instructions.
package/README.md CHANGED
@@ -49,6 +49,7 @@ const manager = new WebRTCManager<TContext>(factory, config);
49
49
  **Configuration Options:**
50
50
  - `peerConfig`: RTCConfiguration (ICE servers, etc.)
51
51
  - `enableMicrophone`: Enable microphone on initialization (default: false)
52
+ - `audioDirection`: Direction of the audio transceiver added when `enableMicrophone` is false (default: `"recvonly"`). Use `"sendrecv"` if you plan to enable the mic later and want to avoid renegotiation.
52
53
  - `dataChannelLabel`: Create a default data channel with this label
53
54
  - `autoReconnect`: Enable automatic reconnection (default: false)
54
55
  - `maxReconnectAttempts`: Max reconnection attempts (default: 5)
@@ -62,7 +63,8 @@ const manager = new WebRTCManager<TContext>(factory, config);
62
63
  ```typescript
63
64
  manager.state // Current WebRTCState
64
65
  manager.localStream // MediaStream | null
65
- manager.remoteStream // MediaStream | null
66
+ manager.remoteStream // MediaStream | null (first stream — legacy)
67
+ manager.remoteStreams // ReadonlyMap<string, MediaStream> (all remote streams by id)
66
68
  manager.dataChannels // ReadonlyMap<string, RTCDataChannel>
67
69
  manager.peerConnection // RTCPeerConnection | null
68
70
  manager.context // TContext | null - user-defined data
@@ -74,7 +76,8 @@ manager.context // TContext | null - user-defined data
74
76
  await manager.initialize() // Initialize peer connection
75
77
  await manager.connect() // Transition to CONNECTING state
76
78
  manager.disconnect() // Disconnect and cleanup
77
- manager.reset() // Reset to IDLE state
79
+ manager.reset() // Reset to IDLE state (valid from any state)
80
+ manager.dispose() // Full teardown: cleanup + unsubscribe every listener
78
81
  ```
79
82
 
80
83
  ### Audio Methods
@@ -101,7 +104,10 @@ await manager.gatherIceCandidates(options) // Wait for ICE gathering to comp
101
104
 
102
105
  Wait for ICE gathering to complete. Useful for HTTP POST signaling patterns where you need all ICE candidates bundled in the local description before sending to the server.
103
106
 
104
- **Options:** `timeout` (ms, default 10000), `onCandidate` (callback for each candidate)
107
+ **Options:**
108
+ - `timeout` (ms, default 10000)
109
+ - `onCandidate` — callback fired for each real candidate as it arrives (the terminal `null` sentinel is NOT forwarded; use the promise resolution to detect completion)
110
+ - `resolveOnTimeout` (boolean, default `false`) — when `true`, the promise resolves on timeout instead of rejecting, allowing you to proceed with whatever candidates were gathered so far
105
111
 
106
112
  ```typescript
107
113
  const offer = await manager.createOffer();
@@ -121,6 +127,7 @@ try {
121
127
  // 1. Retry with longer timeout
122
128
  // 2. Proceed anyway - localDescription may have partial candidates
123
129
  // 3. Treat as fatal: manager.reset()
130
+ // 4. Or pass `resolveOnTimeout: true` upfront to skip the reject path
124
131
  }
125
132
  }
126
133
  ```
@@ -198,12 +205,14 @@ const unsub = manager.subscribe((state) => {
198
205
 
199
206
  **Available Event Constants:**
200
207
  - `EVENT_STATE_CHANGE`
201
- - `EVENT_LOCAL_STREAM`
202
- - `EVENT_REMOTE_STREAM`
208
+ - `EVENT_LOCAL_STREAM` — also fires with `null` when the local stream is torn down
209
+ - `EVENT_REMOTE_STREAM` — also fires with `null` when the remote stream is torn down
203
210
  - `EVENT_DATA_CHANNEL_OPEN`
204
211
  - `EVENT_DATA_CHANNEL_MESSAGE`
205
212
  - `EVENT_DATA_CHANNEL_CLOSE`
206
213
  - `EVENT_ICE_CANDIDATE`
214
+ - `EVENT_ICE_RESTART_OFFER` — emitted after `iceRestart()` with the new local offer; forward via signaling
215
+ - `EVENT_NEGOTIATION_NEEDED` — forwarded from `pc.onnegotiationneeded` (e.g. after a late track or data channel)
207
216
  - `EVENT_RECONNECTING`
208
217
  - `EVENT_RECONNECT_FAILED`
209
218
  - `EVENT_DEVICE_CHANGED`
@@ -563,6 +572,60 @@ This example demonstrates:
563
572
  - **Audio streaming via WebRTC media tracks** (click "Send Beep" to transmit generated audio)
564
573
  - State change monitoring
565
574
 
575
+ ## Upgrading from 1.x to 2.x
576
+
577
+ Version 2.0 is a **bug-fix-driven major release**. Most changes are internal corrections that make the library behave the way the docs already said it did. There is exactly **one API-level breaking change**, plus a handful of observable behavior changes you should audit.
578
+
579
+ ### Breaking: `gatherIceCandidates` — `onCandidate` no longer receives the terminal `null`
580
+
581
+ In 1.x, the `onCandidate` callback was invoked once with `null` to signal end-of-gathering. In 2.x, only real ICE candidates are forwarded. End-of-gathering is signaled exclusively by the returned promise resolving.
582
+
583
+ ```typescript
584
+ // 1.x
585
+ await manager.gatherIceCandidates({
586
+ onCandidate: (c) => {
587
+ if (c === null) onGatheringComplete();
588
+ else collectedCandidates.push(c);
589
+ },
590
+ });
591
+
592
+ // 2.x
593
+ await manager.gatherIceCandidates({
594
+ onCandidate: (c) => collectedCandidates.push(c),
595
+ });
596
+ onGatheringComplete(); // promise resolution = end of gathering
597
+ ```
598
+
599
+ If you weren't using `onCandidate`, nothing changes.
600
+
601
+ ### Behavior changes (no API change, but worth auditing)
602
+
603
+ 1. **Stream events now fire with `null` on teardown.** `local_stream` and `remote_stream` subscribers will now receive `null` when `disconnect()` or `dispose()` runs. If your handler assumed the payload was always a `MediaStream`, add a null check. This matches how `enableMicrophone(false)` already behaved and fixes stale `<audio srcObject>` references in UIs.
604
+
605
+ 2. **`reset()` now works from every state.** In 1.x, calling `reset()` from `INITIALIZING`, `CONNECTING`, or `CONNECTED` silently did nothing (the FSM refused the transition). In 2.x, it always lands in `IDLE`. If you relied on `reset()` being a no-op from those states, wrap the call in a state guard.
606
+
607
+ 3. **ICE-restart reconnects now actually transition to `CONNECTED`.** In 1.x, a successful ICE-restart reconnect left the FSM stuck in `RECONNECTING` because the necessary transition was missing. In 2.x, `RECONNECTING → CONNECTED` is valid and the state machine tracks reality. Code that polled for `state === "RECONNECTING"` as a proxy for "still in flux" may now see `CONNECTED` sooner.
608
+
609
+ 4. **`#reconnectAttempts` resets on every explicit `connect()`/`disconnect()`/`reset()`/`dispose()`.** In 1.x, once the reconnect budget was exhausted, a subsequent user-initiated `connect()` inherited the exhausted counter and would skip all further reconnect attempts on the new session. In 2.x, the counter resets so the next session gets a fresh budget.
610
+
611
+ 5. **`iceRestart()` now emits `ice_restart_offer`.** A real ICE restart requires the new local offer to reach the remote peer via signaling. In 1.x, the library set the local description and returned — the offer never left the process, so ICE restarts silently failed unless the remote side also initiated. In 2.x, subscribe to `ice_restart_offer` and forward the offer through your signaling channel:
612
+
613
+ ```typescript
614
+ manager.on("ice_restart_offer", (offer) => {
615
+ signalingChannel.send({ type: "offer", offer });
616
+ });
617
+ ```
618
+
619
+ 6. **`switchMicrophone()` now promotes `recvonly`/`inactive` transceivers to `sendrecv`.** Previously, calling `switchMicrophone()` on a connection that was originally set up with the mic disabled would replace the track but leave the transceiver direction as `recvonly`, silently producing no audio. 2.x promotes the direction so the new track actually transmits.
620
+
621
+ ### New, additive APIs (no code changes required)
622
+
623
+ - `audioDirection` config — set to `"sendrecv"` if you plan to enable the mic mid-session and want to avoid renegotiation.
624
+ - `remoteStreams` getter — `ReadonlyMap<string, MediaStream>` of all remote streams, keyed by stream id. The legacy single-stream `remoteStream` getter still works and points at the first stream received.
625
+ - `dispose()` method — one-shot teardown that unsubscribes every listener registered via `on()` or `subscribe()` and cleans up the peer connection. Use in framework teardown hooks instead of tracking each returned unsubscribe handle.
626
+ - `gatherIceCandidates({ resolveOnTimeout: true })` — resolves the promise on timeout instead of rejecting.
627
+ - `negotiation_needed` event — forwarded from `pc.onnegotiationneeded`. Listen for it when you create data channels or add tracks after the initial handshake.
628
+
566
629
  ## License
567
630
 
568
631
  MIT
package/dist/types.d.ts CHANGED
@@ -18,6 +18,13 @@ export interface WebRTCManagerConfig {
18
18
  peerConfig?: RTCConfiguration;
19
19
  /** Whether to enable microphone initially. Defaults to false. */
20
20
  enableMicrophone?: boolean;
21
+ /**
22
+ * Direction of the audio transceiver added during `initialize()` when the
23
+ * microphone is NOT enabled. Defaults to "recvonly".
24
+ * Use "sendrecv" if you expect to enable the microphone mid-session and want
25
+ * to avoid renegotiation.
26
+ */
27
+ audioDirection?: RTCRtpTransceiverDirection;
21
28
  /** Label for the default data channel. If provided, a data channel will be created on connect. */
22
29
  dataChannelLabel?: string;
23
30
  /** Enable automatic reconnection on connection failure. Defaults to false. */
@@ -153,6 +160,18 @@ export interface WebRTCEvents {
153
160
  };
154
161
  /** Emitted when an error occurs. Payload: the Error object. */
155
162
  error: Error;
163
+ /**
164
+ * Emitted after `iceRestart()` creates and sets a new local offer.
165
+ * Consumers MUST forward this offer to the remote peer via signaling for
166
+ * the ICE restart to actually succeed.
167
+ */
168
+ ice_restart_offer: RTCSessionDescriptionInit;
169
+ /**
170
+ * Emitted when the peer connection reports that renegotiation is needed
171
+ * (e.g. after adding tracks or creating a data channel post-handshake).
172
+ * Consumers should create a new offer and re-signal.
173
+ */
174
+ negotiation_needed: undefined;
156
175
  }
157
176
  /**
158
177
  * Options for waiting for ICE gathering to complete.
@@ -161,6 +180,16 @@ export interface WebRTCEvents {
161
180
  export interface GatherIceCandidatesOptions {
162
181
  /** Timeout in milliseconds (default: 10000) */
163
182
  timeout?: number;
164
- /** Called for each ICE candidate as it's gathered */
165
- onCandidate?: (candidate: RTCIceCandidate | null) => void;
183
+ /**
184
+ * Called for each real ICE candidate as it's gathered.
185
+ * The terminal `null` (end-of-gathering sentinel) is NOT forwarded —
186
+ * use the returned promise's resolution to detect completion.
187
+ */
188
+ onCandidate?: (candidate: RTCIceCandidate) => void;
189
+ /**
190
+ * If true, the returned promise resolves instead of rejecting when the timeout
191
+ * elapses. Useful for HTTP-POST signaling where partial candidates are better
192
+ * than no candidates at all. Defaults to false (preserving existing behavior).
193
+ */
194
+ resolveOnTimeout?: boolean;
166
195
  }
@@ -51,6 +51,19 @@ export declare class WebRTCManager<TContext = unknown> {
51
51
  static readonly EVENT_MICROPHONE_FAILED = "microphone_failed";
52
52
  /** Event emitted when an error occurs. Payload: `Error` */
53
53
  static readonly EVENT_ERROR = "error";
54
+ /**
55
+ * Event emitted after `iceRestart()` creates and sets a new local offer.
56
+ * Consumers MUST forward the offer SDP to the remote peer via signaling.
57
+ * Payload: `RTCSessionDescriptionInit`
58
+ */
59
+ static readonly EVENT_ICE_RESTART_OFFER = "ice_restart_offer";
60
+ /**
61
+ * Event emitted when the peer connection fires `negotiationneeded`
62
+ * (e.g., when a data channel is created after the initial handshake, or when
63
+ * tracks are added/removed). Consumers should create a new offer and renegotiate.
64
+ * Payload: `undefined`
65
+ */
66
+ static readonly EVENT_NEGOTIATION_NEEDED = "negotiation_needed";
54
67
  /**
55
68
  * User-defined context object for storing arbitrary data associated with this manager.
56
69
  * Useful for attaching application-specific state (e.g., audio streams, metadata)
@@ -81,8 +94,17 @@ export declare class WebRTCManager<TContext = unknown> {
81
94
  get dataChannels(): ReadonlyMap<string, RTCDataChannel>;
82
95
  /** Returns the local media stream, or null if not initialized. */
83
96
  get localStream(): MediaStream | null;
84
- /** Returns the remote media stream, or null if not connected. */
97
+ /**
98
+ * Returns the first remote media stream received, or null if not connected.
99
+ * For connections that may produce multiple remote streams, prefer {@link remoteStreams}.
100
+ */
85
101
  get remoteStream(): MediaStream | null;
102
+ /**
103
+ * Returns a readonly map of all remote media streams, keyed by stream id.
104
+ * Populated as `ontrack` fires. Useful when the remote peer publishes more
105
+ * than one stream (e.g. separate audio + video).
106
+ */
107
+ get remoteStreams(): ReadonlyMap<string, MediaStream>;
86
108
  /** Returns the underlying RTCPeerConnection, or null if not initialized. */
87
109
  get peerConnection(): RTCPeerConnection | null;
88
110
  /** Returns a Mermaid diagram representation of the FSM state machine. */
@@ -145,6 +167,12 @@ export declare class WebRTCManager<TContext = unknown> {
145
167
  * Cleans up all resources and allows reinitialization.
146
168
  */
147
169
  reset(): void;
170
+ /**
171
+ * Fully disposes the manager: closes the connection, stops streams, and
172
+ * unsubscribes every event listener registered via `on()` or `subscribe()`.
173
+ * After calling this, the manager should not be reused.
174
+ */
175
+ dispose(): void;
148
176
  /**
149
177
  * Creates a new data channel with the specified label.
150
178
  * Returns existing channel if one with the same label already exists.
@@ -53,6 +53,19 @@ export class WebRTCManager {
53
53
  static EVENT_MICROPHONE_FAILED = "microphone_failed";
54
54
  /** Event emitted when an error occurs. Payload: `Error` */
55
55
  static EVENT_ERROR = "error";
56
+ /**
57
+ * Event emitted after `iceRestart()` creates and sets a new local offer.
58
+ * Consumers MUST forward the offer SDP to the remote peer via signaling.
59
+ * Payload: `RTCSessionDescriptionInit`
60
+ */
61
+ static EVENT_ICE_RESTART_OFFER = "ice_restart_offer";
62
+ /**
63
+ * Event emitted when the peer connection fires `negotiationneeded`
64
+ * (e.g., when a data channel is created after the initial handshake, or when
65
+ * tracks are added/removed). Consumers should create a new offer and renegotiate.
66
+ * Payload: `undefined`
67
+ */
68
+ static EVENT_NEGOTIATION_NEEDED = "negotiation_needed";
56
69
  #fsm;
57
70
  #pubsub;
58
71
  #pc = null;
@@ -61,8 +74,10 @@ export class WebRTCManager {
61
74
  #logger;
62
75
  #localStream = null;
63
76
  #remoteStream = null;
77
+ #remoteStreams = new Map();
64
78
  #dataChannels = new Map();
65
79
  #reconnectAttempts = 0;
80
+ #disposed = false;
66
81
  /**
67
82
  * User-defined context object for storing arbitrary data associated with this manager.
68
83
  * Useful for attaching application-specific state (e.g., audio streams, metadata)
@@ -100,12 +115,17 @@ export class WebRTCManager {
100
115
  logger: this.#logger,
101
116
  states: {
102
117
  [WebRTCState.IDLE]: {
103
- on: { [WebRTCFsmEvent.INIT]: WebRTCState.INITIALIZING },
118
+ on: {
119
+ [WebRTCFsmEvent.INIT]: WebRTCState.INITIALIZING,
120
+ [WebRTCFsmEvent.RESET]: WebRTCState.IDLE,
121
+ },
104
122
  },
105
123
  [WebRTCState.INITIALIZING]: {
106
124
  on: {
107
125
  [WebRTCFsmEvent.CONNECT]: WebRTCState.CONNECTING,
126
+ [WebRTCFsmEvent.DISCONNECT]: WebRTCState.DISCONNECTED,
108
127
  [WebRTCFsmEvent.ERROR]: WebRTCState.ERROR,
128
+ [WebRTCFsmEvent.RESET]: WebRTCState.IDLE,
109
129
  },
110
130
  },
111
131
  [WebRTCState.CONNECTING]: {
@@ -113,18 +133,24 @@ export class WebRTCManager {
113
133
  [WebRTCFsmEvent.CONNECTED]: WebRTCState.CONNECTED,
114
134
  [WebRTCFsmEvent.DISCONNECT]: WebRTCState.DISCONNECTED,
115
135
  [WebRTCFsmEvent.ERROR]: WebRTCState.ERROR,
136
+ [WebRTCFsmEvent.RESET]: WebRTCState.IDLE,
116
137
  },
117
138
  },
118
139
  [WebRTCState.CONNECTED]: {
119
140
  on: {
120
141
  [WebRTCFsmEvent.DISCONNECT]: WebRTCState.DISCONNECTED,
121
142
  [WebRTCFsmEvent.ERROR]: WebRTCState.ERROR,
143
+ [WebRTCFsmEvent.RESET]: WebRTCState.IDLE,
122
144
  },
123
145
  },
124
146
  [WebRTCState.RECONNECTING]: {
125
147
  on: {
126
148
  [WebRTCFsmEvent.CONNECT]: WebRTCState.CONNECTING,
149
+ // ICE-restart success transitions directly from RECONNECTING to CONNECTED
150
+ // without going through CONNECTING (the PC was never torn down).
151
+ [WebRTCFsmEvent.CONNECTED]: WebRTCState.CONNECTED,
127
152
  [WebRTCFsmEvent.DISCONNECT]: WebRTCState.DISCONNECTED,
153
+ [WebRTCFsmEvent.ERROR]: WebRTCState.ERROR,
128
154
  [WebRTCFsmEvent.RESET]: WebRTCState.IDLE,
129
155
  },
130
156
  },
@@ -154,10 +180,21 @@ export class WebRTCManager {
154
180
  get localStream() {
155
181
  return this.#localStream;
156
182
  }
157
- /** Returns the remote media stream, or null if not connected. */
183
+ /**
184
+ * Returns the first remote media stream received, or null if not connected.
185
+ * For connections that may produce multiple remote streams, prefer {@link remoteStreams}.
186
+ */
158
187
  get remoteStream() {
159
188
  return this.#remoteStream;
160
189
  }
190
+ /**
191
+ * Returns a readonly map of all remote media streams, keyed by stream id.
192
+ * Populated as `ontrack` fires. Useful when the remote peer publishes more
193
+ * than one stream (e.g. separate audio + video).
194
+ */
195
+ get remoteStreams() {
196
+ return this.#remoteStreams;
197
+ }
161
198
  /** Returns the underlying RTCPeerConnection, or null if not initialized. */
162
199
  get peerConnection() {
163
200
  return this.#pc;
@@ -241,21 +278,22 @@ export class WebRTCManager {
241
278
  if (!newTrack) {
242
279
  throw new Error("No audio track in new stream");
243
280
  }
244
- // Find the sender for the audio track - check both senders and transceivers
245
- let sender = this.#pc.getSenders().find((s) => s.track?.kind === "audio");
246
- if (!sender) {
247
- // Try to find via transceiver
248
- const transceivers = this.#pc.getTransceivers();
249
- const audioTransceiver = transceivers.find((t) => t.receiver.track.kind === "audio");
250
- if (audioTransceiver) {
251
- sender = audioTransceiver.sender;
252
- }
281
+ // Locate the audio transceiver so we can both replace the track AND
282
+ // ensure the direction allows sending. Using the transceiver (rather
283
+ // than just the sender) lets us flip `recvonly` -> `sendrecv` when
284
+ // the mic was previously disabled.
285
+ const transceivers = this.#pc.getTransceivers();
286
+ const audioTransceiver = transceivers.find((t) => t.sender.track?.kind === "audio" ||
287
+ t.receiver.track?.kind === "audio");
288
+ if (!audioTransceiver) {
289
+ throw new Error("No audio transceiver found - enable microphone first");
253
290
  }
254
- if (!sender) {
255
- throw new Error("No audio sender found - enable microphone first");
291
+ await audioTransceiver.sender.replaceTrack(newTrack);
292
+ if (audioTransceiver.direction === "recvonly" ||
293
+ audioTransceiver.direction === "inactive") {
294
+ audioTransceiver.direction = "sendrecv";
295
+ this.#logger.debug("switchMicrophone: promoted transceiver direction to sendrecv.");
256
296
  }
257
- // Replace the track
258
- await sender.replaceTrack(newTrack);
259
297
  // Stop old tracks
260
298
  this.#localStream.getAudioTracks().forEach((track) => track.stop());
261
299
  // Update local stream reference
@@ -296,10 +334,12 @@ export class WebRTCManager {
296
334
  }
297
335
  }
298
336
  else {
299
- // Always setup to receive audio, even if we don't enable microphone
300
- // This ensures the SDP includes audio media line
301
- this.#pc.addTransceiver("audio", { direction: "recvonly" });
302
- this.#logger.debug("Added receive-only audio transceiver.");
337
+ // Always setup an audio transceiver so the SDP includes an audio media line.
338
+ // Direction is configurable (default recvonly for BC). Use 'sendrecv' when
339
+ // you expect to enable the mic later without having to renegotiate.
340
+ const direction = this.#config.audioDirection ?? "recvonly";
341
+ this.#pc.addTransceiver("audio", { direction });
342
+ this.#logger.debug(`Added audio transceiver (direction=${direction}).`);
303
343
  }
304
344
  if (this.#config.dataChannelLabel) {
305
345
  this.#logger.debug(`Creating default data channel '${this.#config.dataChannelLabel}'.`);
@@ -318,6 +358,13 @@ export class WebRTCManager {
318
358
  */
319
359
  async connect() {
320
360
  this.#logger.debug(`Connect called with current state ${this.state}.`);
361
+ // A user-initiated connect starts a fresh reconnect budget. Without this,
362
+ // a second connection attempt after an earlier exhausted reconnect would
363
+ // short-circuit in #handleConnectionFailure and never retry.
364
+ if (this.state !== WebRTCState.RECONNECTING &&
365
+ this.state !== WebRTCState.CONNECTING) {
366
+ this.#reconnectAttempts = 0;
367
+ }
321
368
  // Initialize if needed
322
369
  if (this.state === WebRTCState.IDLE) {
323
370
  this.#logger.debug("State is IDLE, initializing first.");
@@ -329,7 +376,7 @@ export class WebRTCManager {
329
376
  // Clean up old connection
330
377
  this.#cleanup();
331
378
  // Reset to IDLE and reinitialize
332
- this.#fsm.transition(WebRTCFsmEvent.RESET);
379
+ this.#dispatch(WebRTCFsmEvent.RESET);
333
380
  await this.initialize();
334
381
  // Stay in INITIALIZING state - caller needs to create offer/answer
335
382
  return;
@@ -423,7 +470,11 @@ export class WebRTCManager {
423
470
  */
424
471
  disconnect() {
425
472
  this.#logger.debug("Disconnect called.");
473
+ // Explicit disconnect is a user-initiated cancel of any in-flight reconnect.
474
+ this.#reconnectAttempts = 0;
426
475
  this.#cleanup();
476
+ // DISCONNECT is only valid from INITIALIZING/CONNECTING/CONNECTED/RECONNECTING.
477
+ // From other states, the event is a no-op, which is the desired behavior.
427
478
  this.#dispatch(WebRTCFsmEvent.DISCONNECT);
428
479
  }
429
480
  /**
@@ -432,23 +483,30 @@ export class WebRTCManager {
432
483
  */
433
484
  reset() {
434
485
  this.#logger.debug(`Reset called with current state ${this.state}.`);
486
+ this.#reconnectAttempts = 0;
435
487
  this.#cleanup();
436
- // Reset from any non-IDLE state
437
- if (this.state !== WebRTCState.IDLE) {
438
- // Force transition to DISCONNECTED first if needed, then to IDLE
439
- if (this.state === WebRTCState.ERROR ||
440
- this.state === WebRTCState.DISCONNECTED ||
441
- this.state === WebRTCState.RECONNECTING) {
442
- this.#dispatch(WebRTCFsmEvent.RESET);
443
- }
444
- else {
445
- // For other states, go through DISCONNECTED first
446
- this.#dispatch(WebRTCFsmEvent.DISCONNECT);
447
- this.#dispatch(WebRTCFsmEvent.RESET);
448
- }
449
- }
488
+ // RESET is now valid from every state, so a single dispatch always lands in IDLE.
489
+ this.#dispatch(WebRTCFsmEvent.RESET);
450
490
  this.#logger.debug(`Reset complete, state is now ${this.state}.`);
451
491
  }
492
+ /**
493
+ * Fully disposes the manager: closes the connection, stops streams, and
494
+ * unsubscribes every event listener registered via `on()` or `subscribe()`.
495
+ * After calling this, the manager should not be reused.
496
+ */
497
+ dispose() {
498
+ if (this.#disposed)
499
+ return;
500
+ this.#logger.debug("Dispose called.");
501
+ this.#disposed = true;
502
+ this.#reconnectAttempts = 0;
503
+ // Drop every subscriber first so teardown side-effects (stream clears,
504
+ // state transitions) don't fire notifications at a consumer that's
505
+ // explicitly asked to let go.
506
+ this.#pubsub.unsubscribeAll();
507
+ this.#cleanup();
508
+ this.#dispatch(WebRTCFsmEvent.RESET);
509
+ }
452
510
  /**
453
511
  * Creates a new data channel with the specified label.
454
512
  * Returns existing channel if one with the same label already exists.
@@ -642,6 +700,10 @@ export class WebRTCManager {
642
700
  try {
643
701
  const offer = await this.#pc.createOffer({ iceRestart: true });
644
702
  await this.#pc.setLocalDescription(offer);
703
+ // A real ICE restart requires the new offer to reach the remote peer
704
+ // and an answer to come back. Emit the offer so consumers can forward it
705
+ // via their signaling channel. Without this, ICE restart silently fails.
706
+ this.#pubsub.publish(WebRTCManager.EVENT_ICE_RESTART_OFFER, offer);
645
707
  this.#logger.debug("ICE restart initiated.");
646
708
  return true;
647
709
  }
@@ -658,7 +720,7 @@ export class WebRTCManager {
658
720
  * @param options - Optional configuration for timeout and candidate callback.
659
721
  */
660
722
  gatherIceCandidates(options = {}) {
661
- const { timeout = 10000, onCandidate } = options;
723
+ const { timeout = 10000, onCandidate, resolveOnTimeout = false } = options;
662
724
  if (!this.#pc) {
663
725
  return Promise.reject(new Error("Peer connection not initialized"));
664
726
  }
@@ -671,7 +733,13 @@ export class WebRTCManager {
671
733
  return new Promise((resolve, reject) => {
672
734
  const timer = setTimeout(() => {
673
735
  cleanup();
674
- reject(new Error("ICE gathering timeout"));
736
+ if (resolveOnTimeout) {
737
+ this.#logger.debug("ICE gathering timed out; resolving with partial candidates.");
738
+ resolve();
739
+ }
740
+ else {
741
+ reject(new Error("ICE gathering timeout"));
742
+ }
675
743
  }, timeout);
676
744
  const cleanup = () => {
677
745
  clearTimeout(timer);
@@ -686,8 +754,12 @@ export class WebRTCManager {
686
754
  }
687
755
  };
688
756
  const handleCandidate = (event) => {
689
- onCandidate?.(event.candidate);
690
- if (event.candidate === null) {
757
+ // Only forward real candidates to the callback; the terminal `null`
758
+ // is an end-of-gathering sentinel, not a candidate.
759
+ if (event.candidate) {
760
+ onCandidate?.(event.candidate);
761
+ }
762
+ else {
691
763
  this.#logger.debug("ICE gathering complete via null candidate.");
692
764
  cleanup();
693
765
  resolve();
@@ -762,9 +834,10 @@ export class WebRTCManager {
762
834
  const state = this.#pc.connectionState;
763
835
  this.#logger.debug(`Connection state changed to ${state}.`);
764
836
  if (state === "connected") {
765
- // Only dispatch if in CONNECTING state (FSM can handle CONNECTED event)
766
- // This guards against late connection success after user has disconnected
767
- if (this.state === WebRTCState.CONNECTING) {
837
+ // Dispatch CONNECTED from either CONNECTING (fresh connection / full reconnect)
838
+ // or RECONNECTING (successful ICE-restart keeps the PC alive).
839
+ if (this.state === WebRTCState.CONNECTING ||
840
+ this.state === WebRTCState.RECONNECTING) {
768
841
  // Connection successful - reset reconnect attempts and clear any pending timeout
769
842
  this.#reconnectAttempts = 0;
770
843
  if (this.#fullReconnectTimeoutTimer !== null) {
@@ -782,10 +855,13 @@ export class WebRTCManager {
782
855
  this.#handleConnectionFailure();
783
856
  }
784
857
  else if (state === "disconnected" || state === "closed") {
785
- // Only dispatch if not already in a terminal state
858
+ // Only dispatch if not already in a terminal state.
859
+ // Also skip INITIALIZING (no DISCONNECT transition is meaningful there —
860
+ // initialize() owns that state and will handle failure via ERROR).
786
861
  if (this.state !== WebRTCState.DISCONNECTED &&
787
862
  this.state !== WebRTCState.ERROR &&
788
- this.state !== WebRTCState.IDLE) {
863
+ this.state !== WebRTCState.IDLE &&
864
+ this.state !== WebRTCState.INITIALIZING) {
789
865
  this.#dispatch(WebRTCFsmEvent.DISCONNECT);
790
866
  }
791
867
  }
@@ -793,8 +869,14 @@ export class WebRTCManager {
793
869
  this.#pc.ontrack = (event) => {
794
870
  this.#logger.debug(`Remote ${event.track.kind} track received.`);
795
871
  if (event.streams && event.streams[0]) {
796
- this.#remoteStream = event.streams[0];
797
- this.#pubsub.publish(WebRTCManager.EVENT_REMOTE_STREAM, this.#remoteStream);
872
+ const stream = event.streams[0];
873
+ // Track the first stream under the legacy `remoteStream` getter for BC,
874
+ // and accumulate every distinct stream under `remoteStreams` by id.
875
+ if (!this.#remoteStream) {
876
+ this.#remoteStream = stream;
877
+ }
878
+ this.#remoteStreams.set(stream.id, stream);
879
+ this.#pubsub.publish(WebRTCManager.EVENT_REMOTE_STREAM, stream);
798
880
  }
799
881
  };
800
882
  this.#pc.ondatachannel = (event) => {
@@ -807,6 +889,10 @@ export class WebRTCManager {
807
889
  this.#logger.debug(`ICE candidate generated: ${event.candidate ? "candidate" : "null (gathering complete)"}.`);
808
890
  this.#pubsub.publish(WebRTCManager.EVENT_ICE_CANDIDATE, event.candidate);
809
891
  };
892
+ this.#pc.onnegotiationneeded = () => {
893
+ this.#logger.debug("Negotiation needed.");
894
+ this.#pubsub.publish(WebRTCManager.EVENT_NEGOTIATION_NEEDED, undefined);
895
+ };
810
896
  }
811
897
  #cleanup() {
812
898
  this.#logger.debug("Cleanup started.");
@@ -837,6 +923,7 @@ export class WebRTCManager {
837
923
  this.#logger.debug(`Closed ${dcCount} data channel(s).`);
838
924
  }
839
925
  // Stop local stream tracks
926
+ const hadLocalStream = this.#localStream !== null;
840
927
  if (this.#localStream) {
841
928
  this.#localStream.getTracks().forEach((track) => track.stop());
842
929
  this.#localStream = null;
@@ -848,7 +935,17 @@ export class WebRTCManager {
848
935
  this.#pc = null;
849
936
  this.#logger.debug("Peer connection closed.");
850
937
  }
938
+ const hadRemoteStream = this.#remoteStream !== null || this.#remoteStreams.size > 0;
851
939
  this.#remoteStream = null;
940
+ this.#remoteStreams.clear();
941
+ // Notify subscribers that streams are gone so UIs can drop stale <audio>/<video>
942
+ // srcObject references. Symmetric with enableMicrophone(false).
943
+ if (hadLocalStream) {
944
+ this.#pubsub.publish(WebRTCManager.EVENT_LOCAL_STREAM, null);
945
+ }
946
+ if (hadRemoteStream) {
947
+ this.#pubsub.publish(WebRTCManager.EVENT_REMOTE_STREAM, null);
948
+ }
852
949
  this.#logger.debug("Cleanup complete.");
853
950
  }
854
951
  #handleConnectionFailure() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/webrtc",
3
- "version": "1.4.5",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "main": "dist/mod.js",
6
6
  "types": "dist/mod.d.ts",
@@ -10,11 +10,19 @@
10
10
  "import": "./dist/mod.js"
11
11
  }
12
12
  },
13
+ "files": [
14
+ "dist",
15
+ "LICENSE",
16
+ "README.md",
17
+ "API.md",
18
+ "AGENTS.md",
19
+ "CLAUDE.md"
20
+ ],
13
21
  "author": "Marian Meres",
14
22
  "license": "MIT",
15
23
  "dependencies": {
16
- "@marianmeres/fsm": "^2.16.4",
17
- "@marianmeres/pubsub": "^2.4.4"
24
+ "@marianmeres/fsm": "^3.0.0",
25
+ "@marianmeres/pubsub": "^3.0.0"
18
26
  },
19
27
  "repository": {
20
28
  "type": "git",