@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 +101 -27
- package/API.md +149 -22
- package/CLAUDE.md +3 -0
- package/README.md +68 -5
- package/dist/types.d.ts +31 -2
- package/dist/webrtc-manager.d.ts +29 -1
- package/dist/webrtc-manager.js +142 -45
- package/package.json +11 -3
package/AGENTS.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
```yaml
|
|
6
6
|
name: "@marianmeres/webrtc"
|
|
7
|
-
version: "
|
|
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/
|
|
31
|
-
- "@marianmeres/
|
|
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.
|
|
34
|
-
- "@std/fs": "^1.0.
|
|
35
|
-
- "@std/path": "^1.1.
|
|
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
|
|
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 |
|
|
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.
|
|
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
|
-
|
|
|
955
|
-
| INITIALIZING |
|
|
956
|
-
|
|
|
957
|
-
|
|
|
958
|
-
|
|
|
959
|
-
|
|
|
960
|
-
|
|
|
961
|
-
|
|
|
962
|
-
|
|
|
963
|
-
|
|
|
964
|
-
|
|
|
965
|
-
|
|
|
966
|
-
|
|
|
967
|
-
|
|
|
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
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:**
|
|
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
|
-
/**
|
|
165
|
-
|
|
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
|
}
|
package/dist/webrtc-manager.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
package/dist/webrtc-manager.js
CHANGED
|
@@ -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: {
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
this.#
|
|
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.#
|
|
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
|
-
//
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
690
|
-
|
|
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
|
-
//
|
|
766
|
-
//
|
|
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
|
-
|
|
797
|
-
|
|
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.
|
|
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": "^
|
|
17
|
-
"@marianmeres/pubsub": "^
|
|
24
|
+
"@marianmeres/fsm": "^3.0.0",
|
|
25
|
+
"@marianmeres/pubsub": "^3.0.0"
|
|
18
26
|
},
|
|
19
27
|
"repository": {
|
|
20
28
|
"type": "git",
|