@marianmeres/webrtc 0.0.2 → 1.0.2
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 +373 -0
- package/API.md +949 -0
- package/README.md +7 -1
- package/dist/types.d.ts +14 -1
- package/dist/webrtc-manager.d.ts +45 -0
- package/dist/webrtc-manager.js +172 -23
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# @marianmeres/webrtc
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@marianmeres/webrtc)
|
|
4
|
+
[](https://jsr.io/@marianmeres/webrtc)
|
|
4
5
|
|
|
5
6
|
A lightweight, framework-agnostic WebRTC manager with state machine-based lifecycle management and event-driven architecture.
|
|
6
7
|
|
|
@@ -51,6 +52,7 @@ const manager = new WebRtcManager(factory, config);
|
|
|
51
52
|
- `maxReconnectAttempts`: Max reconnection attempts (default: 5)
|
|
52
53
|
- `reconnectDelay`: Initial reconnection delay in ms (default: 1000)
|
|
53
54
|
- `debug`: Enable debug logging (default: false)
|
|
55
|
+
- `logger`: Custom logger instance implementing `Logger` interface (default: console)
|
|
54
56
|
|
|
55
57
|
### State and Properties
|
|
56
58
|
|
|
@@ -366,6 +368,10 @@ await connection.createOffer();
|
|
|
366
368
|
connection.sendMessage('Hello!');
|
|
367
369
|
```
|
|
368
370
|
|
|
371
|
+
## API Reference
|
|
372
|
+
|
|
373
|
+
For complete API documentation, see [API.md](API.md).
|
|
374
|
+
|
|
369
375
|
## State Machine
|
|
370
376
|
|
|
371
377
|
The manager uses a finite state machine with the following states:
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console-compatible logger interface.
|
|
3
|
+
* Each method accepts variadic arguments and returns a string representation of the first argument.
|
|
4
|
+
* This enables patterns like `throw new Error(logger.error("msg"))`.
|
|
5
|
+
*/
|
|
6
|
+
export interface Logger {
|
|
7
|
+
debug: (...args: any[]) => string;
|
|
8
|
+
log: (...args: any[]) => string;
|
|
9
|
+
warn: (...args: any[]) => string;
|
|
10
|
+
error: (...args: any[]) => string;
|
|
11
|
+
}
|
|
1
12
|
export interface WebRtcManagerConfig {
|
|
2
13
|
/** Initial peer configuration (ICE servers, etc.) */
|
|
3
14
|
peerConfig?: RTCConfiguration;
|
|
@@ -11,8 +22,10 @@ export interface WebRtcManagerConfig {
|
|
|
11
22
|
maxReconnectAttempts?: number;
|
|
12
23
|
/** Initial reconnection delay in ms. Doubles with each attempt. Defaults to 1000. */
|
|
13
24
|
reconnectDelay?: number;
|
|
14
|
-
/**
|
|
25
|
+
/** Enable debug logging. Defaults to false. */
|
|
15
26
|
debug?: boolean;
|
|
27
|
+
/** Custom logger instance. If not provided, falls back to console. */
|
|
28
|
+
logger?: Logger;
|
|
16
29
|
}
|
|
17
30
|
export interface WebRtcFactory {
|
|
18
31
|
createPeerConnection(config?: RTCConfiguration): RTCPeerConnection;
|
package/dist/webrtc-manager.d.ts
CHANGED
|
@@ -1,18 +1,61 @@
|
|
|
1
1
|
import { type WebRtcFactory, type WebRtcManagerConfig, WebRtcState, type WebRtcEvents } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* WebRTC connection manager with FSM-based lifecycle and event-driven architecture.
|
|
4
|
+
*
|
|
5
|
+
* Provides a high-level API for managing WebRTC peer connections, audio streams,
|
|
6
|
+
* and data channels. The manager uses a finite state machine to handle connection
|
|
7
|
+
* lifecycle and emits events for all state changes and important occurrences.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* const factory = {
|
|
12
|
+
* createPeerConnection: (config) => new RTCPeerConnection(config),
|
|
13
|
+
* getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints),
|
|
14
|
+
* enumerateDevices: () => navigator.mediaDevices.enumerateDevices(),
|
|
15
|
+
* };
|
|
16
|
+
*
|
|
17
|
+
* const manager = new WebRtcManager(factory, {
|
|
18
|
+
* peerConfig: { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] },
|
|
19
|
+
* enableMicrophone: true,
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* await manager.initialize();
|
|
23
|
+
* await manager.connect();
|
|
24
|
+
* const offer = await manager.createOffer();
|
|
25
|
+
* await manager.setLocalDescription(offer);
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
2
28
|
export declare class WebRtcManager {
|
|
3
29
|
#private;
|
|
30
|
+
/** Event emitted when connection state changes. Payload: {@link WebRtcState} */
|
|
4
31
|
static readonly EVENT_STATE_CHANGE = "state_change";
|
|
32
|
+
/** Event emitted when local media stream changes. Payload: `MediaStream | null` */
|
|
5
33
|
static readonly EVENT_LOCAL_STREAM = "local_stream";
|
|
34
|
+
/** Event emitted when remote media stream is received. Payload: `MediaStream | null` */
|
|
6
35
|
static readonly EVENT_REMOTE_STREAM = "remote_stream";
|
|
36
|
+
/** Event emitted when a data channel opens. Payload: `RTCDataChannel` */
|
|
7
37
|
static readonly EVENT_DATA_CHANNEL_OPEN = "data_channel_open";
|
|
38
|
+
/** Event emitted when a data channel receives a message. Payload: `{ channel: RTCDataChannel; data: any }` */
|
|
8
39
|
static readonly EVENT_DATA_CHANNEL_MESSAGE = "data_channel_message";
|
|
40
|
+
/** Event emitted when a data channel closes. Payload: `RTCDataChannel` */
|
|
9
41
|
static readonly EVENT_DATA_CHANNEL_CLOSE = "data_channel_close";
|
|
42
|
+
/** Event emitted when an ICE candidate is generated. Payload: `RTCIceCandidate | null` */
|
|
10
43
|
static readonly EVENT_ICE_CANDIDATE = "ice_candidate";
|
|
44
|
+
/** Event emitted when reconnection is attempted. Payload: `{ attempt: number; strategy: "ice-restart" | "full" }` */
|
|
11
45
|
static readonly EVENT_RECONNECTING = "reconnecting";
|
|
46
|
+
/** Event emitted when all reconnection attempts fail. Payload: `{ attempts: number }` */
|
|
12
47
|
static readonly EVENT_RECONNECT_FAILED = "reconnect_failed";
|
|
48
|
+
/** Event emitted when audio devices change. Payload: `MediaDeviceInfo[]` */
|
|
13
49
|
static readonly EVENT_DEVICE_CHANGED = "device_changed";
|
|
50
|
+
/** Event emitted when microphone access fails. Payload: `{ error?: any; reason?: string }` */
|
|
14
51
|
static readonly EVENT_MICROPHONE_FAILED = "microphone_failed";
|
|
52
|
+
/** Event emitted when an error occurs. Payload: `Error` */
|
|
15
53
|
static readonly EVENT_ERROR = "error";
|
|
54
|
+
/**
|
|
55
|
+
* Creates a new WebRtcManager instance.
|
|
56
|
+
* @param factory - Factory object providing WebRTC primitives (peer connection, media, devices).
|
|
57
|
+
* @param config - Optional configuration for the manager.
|
|
58
|
+
*/
|
|
16
59
|
constructor(factory: WebRtcFactory, config?: WebRtcManagerConfig);
|
|
17
60
|
/** Returns the current state of the WebRTC connection. */
|
|
18
61
|
get state(): WebRtcState;
|
|
@@ -28,6 +71,8 @@ export declare class WebRtcManager {
|
|
|
28
71
|
toMermaid(): string;
|
|
29
72
|
/**
|
|
30
73
|
* Subscribe to a specific WebRTC event.
|
|
74
|
+
* @param event - The event name to subscribe to (e.g., "state_change", "ice_candidate").
|
|
75
|
+
* @param handler - Callback function that receives the event data.
|
|
31
76
|
* @returns Unsubscribe function to remove the event listener.
|
|
32
77
|
*/
|
|
33
78
|
on(event: keyof WebRtcEvents, handler: (data: any) => void): () => void;
|
package/dist/webrtc-manager.js
CHANGED
|
@@ -1,34 +1,100 @@
|
|
|
1
1
|
import { FSM } from "@marianmeres/fsm";
|
|
2
2
|
import { PubSub } from "@marianmeres/pubsub";
|
|
3
3
|
import { WebRtcState, WebRtcFsmEvent, } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Default console-based logger that wraps console methods to satisfy the Logger interface.
|
|
6
|
+
* Returns string representation of the first argument for chaining.
|
|
7
|
+
*/
|
|
8
|
+
const createDefaultLogger = () => ({
|
|
9
|
+
debug: (...args) => {
|
|
10
|
+
console.debug(...args);
|
|
11
|
+
return String(args[0] ?? "");
|
|
12
|
+
},
|
|
13
|
+
log: (...args) => {
|
|
14
|
+
console.log(...args);
|
|
15
|
+
return String(args[0] ?? "");
|
|
16
|
+
},
|
|
17
|
+
warn: (...args) => {
|
|
18
|
+
console.warn(...args);
|
|
19
|
+
return String(args[0] ?? "");
|
|
20
|
+
},
|
|
21
|
+
error: (...args) => {
|
|
22
|
+
console.error(...args);
|
|
23
|
+
return String(args[0] ?? "");
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
/**
|
|
27
|
+
* WebRTC connection manager with FSM-based lifecycle and event-driven architecture.
|
|
28
|
+
*
|
|
29
|
+
* Provides a high-level API for managing WebRTC peer connections, audio streams,
|
|
30
|
+
* and data channels. The manager uses a finite state machine to handle connection
|
|
31
|
+
* lifecycle and emits events for all state changes and important occurrences.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```typescript
|
|
35
|
+
* const factory = {
|
|
36
|
+
* createPeerConnection: (config) => new RTCPeerConnection(config),
|
|
37
|
+
* getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints),
|
|
38
|
+
* enumerateDevices: () => navigator.mediaDevices.enumerateDevices(),
|
|
39
|
+
* };
|
|
40
|
+
*
|
|
41
|
+
* const manager = new WebRtcManager(factory, {
|
|
42
|
+
* peerConfig: { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] },
|
|
43
|
+
* enableMicrophone: true,
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* await manager.initialize();
|
|
47
|
+
* await manager.connect();
|
|
48
|
+
* const offer = await manager.createOffer();
|
|
49
|
+
* await manager.setLocalDescription(offer);
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
4
52
|
export class WebRtcManager {
|
|
5
|
-
|
|
53
|
+
/** Event emitted when connection state changes. Payload: {@link WebRtcState} */
|
|
6
54
|
static EVENT_STATE_CHANGE = "state_change";
|
|
55
|
+
/** Event emitted when local media stream changes. Payload: `MediaStream | null` */
|
|
7
56
|
static EVENT_LOCAL_STREAM = "local_stream";
|
|
57
|
+
/** Event emitted when remote media stream is received. Payload: `MediaStream | null` */
|
|
8
58
|
static EVENT_REMOTE_STREAM = "remote_stream";
|
|
59
|
+
/** Event emitted when a data channel opens. Payload: `RTCDataChannel` */
|
|
9
60
|
static EVENT_DATA_CHANNEL_OPEN = "data_channel_open";
|
|
61
|
+
/** Event emitted when a data channel receives a message. Payload: `{ channel: RTCDataChannel; data: any }` */
|
|
10
62
|
static EVENT_DATA_CHANNEL_MESSAGE = "data_channel_message";
|
|
63
|
+
/** Event emitted when a data channel closes. Payload: `RTCDataChannel` */
|
|
11
64
|
static EVENT_DATA_CHANNEL_CLOSE = "data_channel_close";
|
|
65
|
+
/** Event emitted when an ICE candidate is generated. Payload: `RTCIceCandidate | null` */
|
|
12
66
|
static EVENT_ICE_CANDIDATE = "ice_candidate";
|
|
67
|
+
/** Event emitted when reconnection is attempted. Payload: `{ attempt: number; strategy: "ice-restart" | "full" }` */
|
|
13
68
|
static EVENT_RECONNECTING = "reconnecting";
|
|
69
|
+
/** Event emitted when all reconnection attempts fail. Payload: `{ attempts: number }` */
|
|
14
70
|
static EVENT_RECONNECT_FAILED = "reconnect_failed";
|
|
71
|
+
/** Event emitted when audio devices change. Payload: `MediaDeviceInfo[]` */
|
|
15
72
|
static EVENT_DEVICE_CHANGED = "device_changed";
|
|
73
|
+
/** Event emitted when microphone access fails. Payload: `{ error?: any; reason?: string }` */
|
|
16
74
|
static EVENT_MICROPHONE_FAILED = "microphone_failed";
|
|
75
|
+
/** Event emitted when an error occurs. Payload: `Error` */
|
|
17
76
|
static EVENT_ERROR = "error";
|
|
18
77
|
#fsm;
|
|
19
78
|
#pubsub;
|
|
20
79
|
#pc = null;
|
|
21
80
|
#factory;
|
|
22
81
|
#config;
|
|
82
|
+
#logger;
|
|
23
83
|
#localStream = null;
|
|
24
84
|
#remoteStream = null;
|
|
25
85
|
#dataChannels = new Map();
|
|
26
86
|
#reconnectAttempts = 0;
|
|
27
87
|
#reconnectTimer = null;
|
|
28
88
|
#deviceChangeHandler = null;
|
|
89
|
+
/**
|
|
90
|
+
* Creates a new WebRtcManager instance.
|
|
91
|
+
* @param factory - Factory object providing WebRTC primitives (peer connection, media, devices).
|
|
92
|
+
* @param config - Optional configuration for the manager.
|
|
93
|
+
*/
|
|
29
94
|
constructor(factory, config = {}) {
|
|
30
95
|
this.#factory = factory;
|
|
31
96
|
this.#config = config;
|
|
97
|
+
this.#logger = config.logger ?? createDefaultLogger();
|
|
32
98
|
this.#pubsub = new PubSub();
|
|
33
99
|
// Initialize FSM
|
|
34
100
|
this.#fsm = new FSM({
|
|
@@ -103,6 +169,8 @@ export class WebRtcManager {
|
|
|
103
169
|
}
|
|
104
170
|
/**
|
|
105
171
|
* Subscribe to a specific WebRTC event.
|
|
172
|
+
* @param event - The event name to subscribe to (e.g., "state_change", "ice_candidate").
|
|
173
|
+
* @param handler - Callback function that receives the event data.
|
|
106
174
|
* @returns Unsubscribe function to remove the event listener.
|
|
107
175
|
*/
|
|
108
176
|
on(event, handler) {
|
|
@@ -149,7 +217,7 @@ export class WebRtcManager {
|
|
|
149
217
|
return devices.filter((d) => d.kind === "audioinput");
|
|
150
218
|
}
|
|
151
219
|
catch (e) {
|
|
152
|
-
|
|
220
|
+
this.#logger.error("[WebRtcManager] Failed to enumerate devices:", e);
|
|
153
221
|
return [];
|
|
154
222
|
}
|
|
155
223
|
}
|
|
@@ -160,7 +228,7 @@ export class WebRtcManager {
|
|
|
160
228
|
*/
|
|
161
229
|
async switchMicrophone(deviceId) {
|
|
162
230
|
if (!this.#pc || !this.#localStream) {
|
|
163
|
-
|
|
231
|
+
this.#logger.error("[WebRtcManager] Cannot switch microphone: not initialized or no active stream");
|
|
164
232
|
return false;
|
|
165
233
|
}
|
|
166
234
|
try {
|
|
@@ -196,7 +264,7 @@ export class WebRtcManager {
|
|
|
196
264
|
return true;
|
|
197
265
|
}
|
|
198
266
|
catch (e) {
|
|
199
|
-
|
|
267
|
+
this.#logger.error("[WebRtcManager] Failed to switch microphone:", e);
|
|
200
268
|
this.#error(e);
|
|
201
269
|
return false;
|
|
202
270
|
}
|
|
@@ -206,15 +274,20 @@ export class WebRtcManager {
|
|
|
206
274
|
* Must be called before creating offers or answers. Can only be called from IDLE state.
|
|
207
275
|
*/
|
|
208
276
|
async initialize() {
|
|
209
|
-
if (this.state !== WebRtcState.IDLE)
|
|
277
|
+
if (this.state !== WebRtcState.IDLE) {
|
|
278
|
+
this.#debug("initialize() called but state is not IDLE:", this.state);
|
|
210
279
|
return;
|
|
280
|
+
}
|
|
281
|
+
this.#debug("Initializing...");
|
|
211
282
|
this.#dispatch(WebRtcFsmEvent.INIT);
|
|
212
283
|
try {
|
|
213
284
|
this.#pc = this.#factory.createPeerConnection(this.#config.peerConfig);
|
|
285
|
+
this.#debug("Peer connection created");
|
|
214
286
|
this.#setupPcListeners();
|
|
215
287
|
// Setup device change detection now that we have a connection
|
|
216
288
|
this.#setupDeviceChangeListener();
|
|
217
289
|
if (this.#config.enableMicrophone) {
|
|
290
|
+
this.#debug("Enabling microphone (config enabled)");
|
|
218
291
|
const success = await this.enableMicrophone(true);
|
|
219
292
|
if (!success) {
|
|
220
293
|
this.#pubsub.publish(WebRtcManager.EVENT_MICROPHONE_FAILED, {
|
|
@@ -226,10 +299,13 @@ export class WebRtcManager {
|
|
|
226
299
|
// Always setup to receive audio, even if we don't enable microphone
|
|
227
300
|
// This ensures the SDP includes audio media line
|
|
228
301
|
this.#pc.addTransceiver("audio", { direction: "recvonly" });
|
|
302
|
+
this.#debug("Added recvonly audio transceiver");
|
|
229
303
|
}
|
|
230
304
|
if (this.#config.dataChannelLabel) {
|
|
305
|
+
this.#debug("Creating default data channel:", this.#config.dataChannelLabel);
|
|
231
306
|
this.createDataChannel(this.#config.dataChannelLabel);
|
|
232
307
|
}
|
|
308
|
+
this.#debug("Initialization complete");
|
|
233
309
|
}
|
|
234
310
|
catch (e) {
|
|
235
311
|
this.#error(e);
|
|
@@ -240,12 +316,15 @@ export class WebRtcManager {
|
|
|
240
316
|
* If disconnected, reinitializes the peer connection.
|
|
241
317
|
*/
|
|
242
318
|
async connect() {
|
|
319
|
+
this.#debug("connect() called, current state:", this.state);
|
|
243
320
|
// Initialize if needed
|
|
244
321
|
if (this.state === WebRtcState.IDLE) {
|
|
322
|
+
this.#debug("State is IDLE, initializing first");
|
|
245
323
|
await this.initialize();
|
|
246
324
|
}
|
|
247
325
|
// Reinitialize if disconnected (PeerConnection was closed)
|
|
248
326
|
if (this.state === WebRtcState.DISCONNECTED) {
|
|
327
|
+
this.#debug("State is DISCONNECTED, reinitializing");
|
|
249
328
|
// Clean up old connection
|
|
250
329
|
this.#cleanup();
|
|
251
330
|
// Reset to IDLE and reinitialize
|
|
@@ -255,8 +334,11 @@ export class WebRtcManager {
|
|
|
255
334
|
return;
|
|
256
335
|
}
|
|
257
336
|
if (this.state === WebRtcState.CONNECTED ||
|
|
258
|
-
this.state === WebRtcState.CONNECTING)
|
|
337
|
+
this.state === WebRtcState.CONNECTING) {
|
|
338
|
+
this.#debug("Already connected or connecting, skipping");
|
|
259
339
|
return;
|
|
340
|
+
}
|
|
341
|
+
this.#debug("Transitioning to CONNECTING");
|
|
260
342
|
this.#dispatch(WebRtcFsmEvent.CONNECT);
|
|
261
343
|
}
|
|
262
344
|
/**
|
|
@@ -265,14 +347,19 @@ export class WebRtcManager {
|
|
|
265
347
|
* @returns True if successful, false if failed to get user media.
|
|
266
348
|
*/
|
|
267
349
|
async enableMicrophone(enable) {
|
|
350
|
+
this.#debug("enableMicrophone() called:", enable);
|
|
268
351
|
if (enable) {
|
|
269
|
-
if (this.#localStream)
|
|
270
|
-
|
|
352
|
+
if (this.#localStream) {
|
|
353
|
+
this.#debug("Microphone already enabled");
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
271
356
|
try {
|
|
357
|
+
this.#debug("Requesting user media...");
|
|
272
358
|
const stream = await this.#factory.getUserMedia({
|
|
273
359
|
audio: true,
|
|
274
360
|
video: false,
|
|
275
361
|
});
|
|
362
|
+
this.#debug("User media obtained, tracks:", stream.getAudioTracks().length);
|
|
276
363
|
this.#localStream = stream;
|
|
277
364
|
this.#pubsub.publish(WebRtcManager.EVENT_LOCAL_STREAM, stream);
|
|
278
365
|
if (this.#pc) {
|
|
@@ -285,25 +372,31 @@ export class WebRtcManager {
|
|
|
285
372
|
await audioTransceiver.sender.replaceTrack(track);
|
|
286
373
|
// Update direction to sendrecv
|
|
287
374
|
audioTransceiver.direction = "sendrecv";
|
|
375
|
+
this.#debug("Replaced track in existing transceiver");
|
|
288
376
|
}
|
|
289
377
|
else {
|
|
290
378
|
// Add track normally
|
|
291
379
|
stream.getTracks().forEach((track) => {
|
|
292
380
|
this.#pc.addTrack(track, stream);
|
|
293
381
|
});
|
|
382
|
+
this.#debug("Added tracks to peer connection");
|
|
294
383
|
}
|
|
295
384
|
}
|
|
385
|
+
this.#debug("Microphone enabled successfully");
|
|
296
386
|
return true;
|
|
297
387
|
}
|
|
298
388
|
catch (e) {
|
|
299
|
-
|
|
389
|
+
this.#logger.error("[WebRtcManager] Failed to get user media:", e);
|
|
300
390
|
this.#pubsub.publish(WebRtcManager.EVENT_MICROPHONE_FAILED, { error: e });
|
|
301
391
|
return false;
|
|
302
392
|
}
|
|
303
393
|
}
|
|
304
394
|
else {
|
|
305
|
-
if (!this.#localStream)
|
|
395
|
+
if (!this.#localStream) {
|
|
396
|
+
this.#debug("Microphone already disabled");
|
|
306
397
|
return true;
|
|
398
|
+
}
|
|
399
|
+
this.#debug("Disabling microphone...");
|
|
307
400
|
this.#localStream.getTracks().forEach((track) => {
|
|
308
401
|
track.stop();
|
|
309
402
|
// Remove from PC if needed, or just stop sending
|
|
@@ -317,6 +410,7 @@ export class WebRtcManager {
|
|
|
317
410
|
});
|
|
318
411
|
this.#localStream = null;
|
|
319
412
|
this.#pubsub.publish(WebRtcManager.EVENT_LOCAL_STREAM, null);
|
|
413
|
+
this.#debug("Microphone disabled");
|
|
320
414
|
return true;
|
|
321
415
|
}
|
|
322
416
|
}
|
|
@@ -325,6 +419,7 @@ export class WebRtcManager {
|
|
|
325
419
|
* Transitions to DISCONNECTED state.
|
|
326
420
|
*/
|
|
327
421
|
disconnect() {
|
|
422
|
+
this.#debug("disconnect() called");
|
|
328
423
|
this.#cleanup();
|
|
329
424
|
this.#dispatch(WebRtcFsmEvent.DISCONNECT);
|
|
330
425
|
}
|
|
@@ -333,6 +428,7 @@ export class WebRtcManager {
|
|
|
333
428
|
* Cleans up all resources and allows reinitialization.
|
|
334
429
|
*/
|
|
335
430
|
reset() {
|
|
431
|
+
this.#debug("reset() called, current state:", this.state);
|
|
336
432
|
this.#cleanup();
|
|
337
433
|
// Reset from any non-IDLE state
|
|
338
434
|
if (this.state !== WebRtcState.IDLE) {
|
|
@@ -348,6 +444,7 @@ export class WebRtcManager {
|
|
|
348
444
|
this.#dispatch(WebRtcFsmEvent.RESET);
|
|
349
445
|
}
|
|
350
446
|
}
|
|
447
|
+
this.#debug("Reset complete, state:", this.state);
|
|
351
448
|
}
|
|
352
449
|
/**
|
|
353
450
|
* Creates a new data channel with the specified label.
|
|
@@ -357,14 +454,20 @@ export class WebRtcManager {
|
|
|
357
454
|
* @returns The created data channel, or null if peer connection not initialized.
|
|
358
455
|
*/
|
|
359
456
|
createDataChannel(label, options) {
|
|
360
|
-
|
|
457
|
+
this.#debug("createDataChannel() called:", label);
|
|
458
|
+
if (!this.#pc) {
|
|
459
|
+
this.#debug("Cannot create data channel: peer connection not initialized");
|
|
361
460
|
return null;
|
|
362
|
-
|
|
461
|
+
}
|
|
462
|
+
if (this.#dataChannels.has(label)) {
|
|
463
|
+
this.#debug("Returning existing data channel:", label);
|
|
363
464
|
return this.#dataChannels.get(label);
|
|
465
|
+
}
|
|
364
466
|
try {
|
|
365
467
|
const dc = this.#pc.createDataChannel(label, options);
|
|
366
468
|
this.#setupDataChannelListeners(dc);
|
|
367
469
|
this.#dataChannels.set(label, dc);
|
|
470
|
+
this.#debug("Data channel created:", label);
|
|
368
471
|
return dc;
|
|
369
472
|
}
|
|
370
473
|
catch (e) {
|
|
@@ -413,10 +516,14 @@ export class WebRtcManager {
|
|
|
413
516
|
* @returns The offer SDP, or null if peer connection not initialized.
|
|
414
517
|
*/
|
|
415
518
|
async createOffer(options) {
|
|
416
|
-
|
|
519
|
+
this.#debug("createOffer() called");
|
|
520
|
+
if (!this.#pc) {
|
|
521
|
+
this.#debug("Cannot create offer: peer connection not initialized");
|
|
417
522
|
return null;
|
|
523
|
+
}
|
|
418
524
|
try {
|
|
419
525
|
const offer = await this.#pc.createOffer(options);
|
|
526
|
+
this.#debug("Offer created:", offer.type);
|
|
420
527
|
return offer;
|
|
421
528
|
}
|
|
422
529
|
catch (e) {
|
|
@@ -430,10 +537,14 @@ export class WebRtcManager {
|
|
|
430
537
|
* @returns The answer SDP, or null if peer connection not initialized.
|
|
431
538
|
*/
|
|
432
539
|
async createAnswer(options) {
|
|
433
|
-
|
|
540
|
+
this.#debug("createAnswer() called");
|
|
541
|
+
if (!this.#pc) {
|
|
542
|
+
this.#debug("Cannot create answer: peer connection not initialized");
|
|
434
543
|
return null;
|
|
544
|
+
}
|
|
435
545
|
try {
|
|
436
546
|
const answer = await this.#pc.createAnswer(options);
|
|
547
|
+
this.#debug("Answer created:", answer.type);
|
|
437
548
|
return answer;
|
|
438
549
|
}
|
|
439
550
|
catch (e) {
|
|
@@ -447,10 +558,14 @@ export class WebRtcManager {
|
|
|
447
558
|
* @returns True if successful, false otherwise.
|
|
448
559
|
*/
|
|
449
560
|
async setLocalDescription(description) {
|
|
450
|
-
|
|
561
|
+
this.#debug("setLocalDescription() called:", description.type);
|
|
562
|
+
if (!this.#pc) {
|
|
563
|
+
this.#debug("Cannot set local description: peer connection not initialized");
|
|
451
564
|
return false;
|
|
565
|
+
}
|
|
452
566
|
try {
|
|
453
567
|
await this.#pc.setLocalDescription(description);
|
|
568
|
+
this.#debug("Local description set successfully");
|
|
454
569
|
return true;
|
|
455
570
|
}
|
|
456
571
|
catch (e) {
|
|
@@ -464,10 +579,14 @@ export class WebRtcManager {
|
|
|
464
579
|
* @returns True if successful, false otherwise.
|
|
465
580
|
*/
|
|
466
581
|
async setRemoteDescription(description) {
|
|
467
|
-
|
|
582
|
+
this.#debug("setRemoteDescription() called:", description.type);
|
|
583
|
+
if (!this.#pc) {
|
|
584
|
+
this.#debug("Cannot set remote description: peer connection not initialized");
|
|
468
585
|
return false;
|
|
586
|
+
}
|
|
469
587
|
try {
|
|
470
588
|
await this.#pc.setRemoteDescription(description);
|
|
589
|
+
this.#debug("Remote description set successfully");
|
|
471
590
|
return true;
|
|
472
591
|
}
|
|
473
592
|
catch (e) {
|
|
@@ -481,11 +600,15 @@ export class WebRtcManager {
|
|
|
481
600
|
* @returns True if successful, false otherwise.
|
|
482
601
|
*/
|
|
483
602
|
async addIceCandidate(candidate) {
|
|
484
|
-
|
|
603
|
+
this.#debug("addIceCandidate() called:", candidate ? "candidate" : "null (end-of-candidates)");
|
|
604
|
+
if (!this.#pc) {
|
|
605
|
+
this.#debug("Cannot add ICE candidate: peer connection not initialized");
|
|
485
606
|
return false;
|
|
607
|
+
}
|
|
486
608
|
try {
|
|
487
609
|
if (candidate) {
|
|
488
610
|
await this.#pc.addIceCandidate(candidate);
|
|
611
|
+
this.#debug("ICE candidate added");
|
|
489
612
|
}
|
|
490
613
|
return true;
|
|
491
614
|
}
|
|
@@ -500,11 +623,15 @@ export class WebRtcManager {
|
|
|
500
623
|
* @returns True if successful, false otherwise.
|
|
501
624
|
*/
|
|
502
625
|
async iceRestart() {
|
|
503
|
-
|
|
626
|
+
this.#debug("iceRestart() called");
|
|
627
|
+
if (!this.#pc) {
|
|
628
|
+
this.#debug("Cannot perform ICE restart: peer connection not initialized");
|
|
504
629
|
return false;
|
|
630
|
+
}
|
|
505
631
|
try {
|
|
506
632
|
const offer = await this.#pc.createOffer({ iceRestart: true });
|
|
507
633
|
await this.#pc.setLocalDescription(offer);
|
|
634
|
+
this.#debug("ICE restart initiated");
|
|
508
635
|
return true;
|
|
509
636
|
}
|
|
510
637
|
catch (e) {
|
|
@@ -547,24 +674,27 @@ export class WebRtcManager {
|
|
|
547
674
|
this.#fsm.transition(event);
|
|
548
675
|
const newState = this.#fsm.state;
|
|
549
676
|
if (oldState !== newState) {
|
|
677
|
+
this.#debug("State transition:", oldState, "->", newState, "(event:", event + ")");
|
|
550
678
|
this.#pubsub.publish(WebRtcManager.EVENT_STATE_CHANGE, newState);
|
|
551
679
|
}
|
|
552
680
|
}
|
|
553
681
|
#debug(...args) {
|
|
554
682
|
if (this.#config.debug) {
|
|
555
|
-
|
|
683
|
+
this.#logger.debug("[WebRtcManager]", ...args);
|
|
556
684
|
}
|
|
557
685
|
}
|
|
558
686
|
#error(error) {
|
|
559
|
-
|
|
687
|
+
this.#logger.error("[WebRtcManager]", error);
|
|
560
688
|
this.#dispatch(WebRtcFsmEvent.ERROR);
|
|
561
689
|
this.#pubsub.publish(WebRtcManager.EVENT_ERROR, error);
|
|
562
690
|
}
|
|
563
691
|
#setupPcListeners() {
|
|
564
692
|
if (!this.#pc)
|
|
565
693
|
return;
|
|
694
|
+
this.#debug("Setting up peer connection listeners");
|
|
566
695
|
this.#pc.onconnectionstatechange = () => {
|
|
567
696
|
const state = this.#pc.connectionState;
|
|
697
|
+
this.#debug("Connection state changed:", state);
|
|
568
698
|
if (state === "connected") {
|
|
569
699
|
// Connection successful - reset reconnect attempts
|
|
570
700
|
this.#reconnectAttempts = 0;
|
|
@@ -579,6 +709,7 @@ export class WebRtcManager {
|
|
|
579
709
|
}
|
|
580
710
|
};
|
|
581
711
|
this.#pc.ontrack = (event) => {
|
|
712
|
+
this.#debug("Remote track received:", event.track.kind);
|
|
582
713
|
if (event.streams && event.streams[0]) {
|
|
583
714
|
this.#remoteStream = event.streams[0];
|
|
584
715
|
this.#pubsub.publish(WebRtcManager.EVENT_REMOTE_STREAM, this.#remoteStream);
|
|
@@ -586,14 +717,17 @@ export class WebRtcManager {
|
|
|
586
717
|
};
|
|
587
718
|
this.#pc.ondatachannel = (event) => {
|
|
588
719
|
const dc = event.channel;
|
|
720
|
+
this.#debug("Remote data channel received:", dc.label);
|
|
589
721
|
this.#setupDataChannelListeners(dc);
|
|
590
722
|
this.#dataChannels.set(dc.label, dc);
|
|
591
723
|
};
|
|
592
724
|
this.#pc.onicecandidate = (event) => {
|
|
725
|
+
this.#debug("ICE candidate generated:", event.candidate ? "candidate" : "null (gathering complete)");
|
|
593
726
|
this.#pubsub.publish(WebRtcManager.EVENT_ICE_CANDIDATE, event.candidate);
|
|
594
727
|
};
|
|
595
728
|
}
|
|
596
729
|
#cleanup() {
|
|
730
|
+
this.#debug("Cleanup started");
|
|
597
731
|
// Clear any pending reconnect timers
|
|
598
732
|
if (this.#reconnectTimer !== null) {
|
|
599
733
|
clearTimeout(this.#reconnectTimer);
|
|
@@ -605,33 +739,43 @@ export class WebRtcManager {
|
|
|
605
739
|
this.#deviceChangeHandler = null;
|
|
606
740
|
}
|
|
607
741
|
// Close all data channels
|
|
742
|
+
const dcCount = this.#dataChannels.size;
|
|
608
743
|
this.#dataChannels.forEach((dc) => {
|
|
609
744
|
if (dc.readyState !== "closed") {
|
|
610
745
|
dc.close();
|
|
611
746
|
}
|
|
612
747
|
});
|
|
613
748
|
this.#dataChannels.clear();
|
|
749
|
+
if (dcCount > 0) {
|
|
750
|
+
this.#debug("Closed", dcCount, "data channel(s)");
|
|
751
|
+
}
|
|
614
752
|
// Stop local stream tracks
|
|
615
753
|
if (this.#localStream) {
|
|
616
754
|
this.#localStream.getTracks().forEach((track) => track.stop());
|
|
617
755
|
this.#localStream = null;
|
|
756
|
+
this.#debug("Local stream stopped");
|
|
618
757
|
}
|
|
619
758
|
// Close peer connection
|
|
620
759
|
if (this.#pc) {
|
|
621
760
|
this.#pc.close();
|
|
622
761
|
this.#pc = null;
|
|
762
|
+
this.#debug("Peer connection closed");
|
|
623
763
|
}
|
|
624
764
|
this.#remoteStream = null;
|
|
765
|
+
this.#debug("Cleanup complete");
|
|
625
766
|
}
|
|
626
767
|
#handleConnectionFailure() {
|
|
768
|
+
this.#debug("Handling connection failure");
|
|
627
769
|
this.#dispatch(WebRtcFsmEvent.DISCONNECT);
|
|
628
770
|
// Check if auto-reconnect is enabled
|
|
629
771
|
if (!this.#config.autoReconnect) {
|
|
772
|
+
this.#debug("Auto-reconnect disabled, not attempting reconnection");
|
|
630
773
|
return;
|
|
631
774
|
}
|
|
632
775
|
const maxAttempts = this.#config.maxReconnectAttempts ?? 5;
|
|
633
776
|
// Check if we've exceeded max attempts
|
|
634
777
|
if (this.#reconnectAttempts >= maxAttempts) {
|
|
778
|
+
this.#debug("Max reconnection attempts reached:", maxAttempts);
|
|
635
779
|
this.#pubsub.publish(WebRtcManager.EVENT_RECONNECT_FAILED, {
|
|
636
780
|
attempts: this.#reconnectAttempts,
|
|
637
781
|
});
|
|
@@ -648,6 +792,11 @@ export class WebRtcManager {
|
|
|
648
792
|
const delay = baseDelay * Math.pow(2, this.#reconnectAttempts - 1);
|
|
649
793
|
// Try ICE restart first (attempts 1-2), then full reconnect
|
|
650
794
|
const strategy = this.#reconnectAttempts <= 2 ? "ice-restart" : "full";
|
|
795
|
+
this.#debug("Attempting reconnection:", {
|
|
796
|
+
attempt: this.#reconnectAttempts,
|
|
797
|
+
strategy,
|
|
798
|
+
delay: delay + "ms",
|
|
799
|
+
});
|
|
651
800
|
this.#pubsub.publish(WebRtcManager.EVENT_RECONNECTING, {
|
|
652
801
|
attempt: this.#reconnectAttempts,
|
|
653
802
|
strategy,
|
|
@@ -674,7 +823,7 @@ export class WebRtcManager {
|
|
|
674
823
|
// If successful, onconnectionstatechange will reset attempts
|
|
675
824
|
}
|
|
676
825
|
catch (e) {
|
|
677
|
-
|
|
826
|
+
this.#logger.error("[WebRtcManager] Reconnection failed:", e);
|
|
678
827
|
this.#handleConnectionFailure();
|
|
679
828
|
}
|
|
680
829
|
}
|
|
@@ -695,7 +844,7 @@ export class WebRtcManager {
|
|
|
695
844
|
this.#pubsub.publish(WebRtcManager.EVENT_DEVICE_CHANGED, devices);
|
|
696
845
|
}
|
|
697
846
|
catch (e) {
|
|
698
|
-
|
|
847
|
+
this.#logger.error("[WebRtcManager] Error handling device change:", e);
|
|
699
848
|
}
|
|
700
849
|
};
|
|
701
850
|
navigator.mediaDevices.addEventListener("devicechange", this.#deviceChangeHandler);
|
|
@@ -718,7 +867,7 @@ export class WebRtcManager {
|
|
|
718
867
|
// Ignore "User-Initiated Abort" errors which occur during intentional close()
|
|
719
868
|
const isUserAbort = error?.error?.message?.includes("User-Initiated Abort");
|
|
720
869
|
if (!isUserAbort) {
|
|
721
|
-
|
|
870
|
+
this.#logger.error("[WebRtcManager] Data channel error:", error);
|
|
722
871
|
this.#pubsub.publish(WebRtcManager.EVENT_ERROR, error);
|
|
723
872
|
}
|
|
724
873
|
};
|