@marianmeres/webrtc 1.3.0 → 1.4.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 +16 -17
- package/API.md +39 -43
- package/README.md +23 -24
- package/dist/types.d.ts +8 -10
- package/dist/types.js +20 -20
- package/dist/webrtc-manager.d.ts +11 -11
- package/dist/webrtc-manager.js +167 -191
- package/package.json +2 -2
package/dist/webrtc-manager.js
CHANGED
|
@@ -1,32 +1,7 @@
|
|
|
1
1
|
import { FSM } from "@marianmeres/fsm";
|
|
2
2
|
import { PubSub } from "@marianmeres/pubsub";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
* Default console-based logger that wraps console methods.
|
|
6
|
-
* Returns string representation of the first argument for chaining.
|
|
7
|
-
*/
|
|
8
|
-
const createDefaultLogger = () => ({
|
|
9
|
-
// deno-lint-ignore no-explicit-any
|
|
10
|
-
debug: (...args) => {
|
|
11
|
-
console.debug(...args);
|
|
12
|
-
return String(args[0] ?? "");
|
|
13
|
-
},
|
|
14
|
-
// deno-lint-ignore no-explicit-any
|
|
15
|
-
log: (...args) => {
|
|
16
|
-
console.log(...args);
|
|
17
|
-
return String(args[0] ?? "");
|
|
18
|
-
},
|
|
19
|
-
// deno-lint-ignore no-explicit-any
|
|
20
|
-
warn: (...args) => {
|
|
21
|
-
console.warn(...args);
|
|
22
|
-
return String(args[0] ?? "");
|
|
23
|
-
},
|
|
24
|
-
// deno-lint-ignore no-explicit-any
|
|
25
|
-
error: (...args) => {
|
|
26
|
-
console.error(...args);
|
|
27
|
-
return String(args[0] ?? "");
|
|
28
|
-
},
|
|
29
|
-
});
|
|
3
|
+
import { createClog, withNamespace } from "@marianmeres/clog";
|
|
4
|
+
import { WebRTCState, WebRTCFsmEvent, } from "./types.js";
|
|
30
5
|
/**
|
|
31
6
|
* WebRTC connection manager with FSM-based lifecycle and event-driven architecture.
|
|
32
7
|
*
|
|
@@ -42,7 +17,7 @@ const createDefaultLogger = () => ({
|
|
|
42
17
|
* enumerateDevices: () => navigator.mediaDevices.enumerateDevices(),
|
|
43
18
|
* };
|
|
44
19
|
*
|
|
45
|
-
* const manager = new
|
|
20
|
+
* const manager = new WebRTCManager(factory, {
|
|
46
21
|
* peerConfig: { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] },
|
|
47
22
|
* enableMicrophone: true,
|
|
48
23
|
* });
|
|
@@ -53,8 +28,8 @@ const createDefaultLogger = () => ({
|
|
|
53
28
|
* await manager.setLocalDescription(offer);
|
|
54
29
|
* ```
|
|
55
30
|
*/
|
|
56
|
-
export class
|
|
57
|
-
/** Event emitted when connection state changes. Payload: {@link
|
|
31
|
+
export class WebRTCManager {
|
|
32
|
+
/** Event emitted when connection state changes. Payload: {@link WebRTCState} */
|
|
58
33
|
static EVENT_STATE_CHANGE = "state_change";
|
|
59
34
|
/** Event emitted when local media stream changes. Payload: `MediaStream | null` */
|
|
60
35
|
static EVENT_LOCAL_STREAM = "local_stream";
|
|
@@ -96,12 +71,12 @@ export class WebRtcManager {
|
|
|
96
71
|
* @example
|
|
97
72
|
* ```typescript
|
|
98
73
|
* // With type parameter for full type safety:
|
|
99
|
-
* const manager = new
|
|
74
|
+
* const manager = new WebRTCManager<{ audioStream: MediaStream; sessionId: string }>(factory);
|
|
100
75
|
* manager.context = { audioStream: myStream, sessionId: '123' };
|
|
101
76
|
* manager.context.audioStream; // typed as MediaStream
|
|
102
77
|
*
|
|
103
78
|
* // Without type parameter (backwards compatible):
|
|
104
|
-
* const manager = new
|
|
79
|
+
* const manager = new WebRTCManager(factory);
|
|
105
80
|
* manager.context = { anything: 'goes' };
|
|
106
81
|
* ```
|
|
107
82
|
*/
|
|
@@ -110,57 +85,57 @@ export class WebRtcManager {
|
|
|
110
85
|
#fullReconnectTimeoutTimer = null;
|
|
111
86
|
#deviceChangeHandler = null;
|
|
112
87
|
/**
|
|
113
|
-
* Creates a new
|
|
88
|
+
* Creates a new WebRTCManager instance.
|
|
114
89
|
* @param factory - Factory object providing WebRTC primitives (peer connection, media, devices).
|
|
115
90
|
* @param config - Optional configuration for the manager.
|
|
116
91
|
*/
|
|
117
92
|
constructor(factory, config = {}) {
|
|
118
93
|
this.#factory = factory;
|
|
119
94
|
this.#config = config;
|
|
120
|
-
this.#logger = config.logger ??
|
|
95
|
+
this.#logger = withNamespace(config.logger ?? createClog(), "WebRTCManager");
|
|
121
96
|
this.#pubsub = new PubSub();
|
|
122
97
|
// Initialize FSM
|
|
123
98
|
this.#fsm = new FSM({
|
|
124
|
-
initial:
|
|
99
|
+
initial: WebRTCState.IDLE,
|
|
125
100
|
states: {
|
|
126
|
-
[
|
|
127
|
-
on: { [
|
|
101
|
+
[WebRTCState.IDLE]: {
|
|
102
|
+
on: { [WebRTCFsmEvent.INIT]: WebRTCState.INITIALIZING },
|
|
128
103
|
},
|
|
129
|
-
[
|
|
104
|
+
[WebRTCState.INITIALIZING]: {
|
|
130
105
|
on: {
|
|
131
|
-
[
|
|
132
|
-
[
|
|
106
|
+
[WebRTCFsmEvent.CONNECT]: WebRTCState.CONNECTING,
|
|
107
|
+
[WebRTCFsmEvent.ERROR]: WebRTCState.ERROR,
|
|
133
108
|
},
|
|
134
109
|
},
|
|
135
|
-
[
|
|
110
|
+
[WebRTCState.CONNECTING]: {
|
|
136
111
|
on: {
|
|
137
|
-
[
|
|
138
|
-
[
|
|
139
|
-
[
|
|
112
|
+
[WebRTCFsmEvent.CONNECTED]: WebRTCState.CONNECTED,
|
|
113
|
+
[WebRTCFsmEvent.DISCONNECT]: WebRTCState.DISCONNECTED,
|
|
114
|
+
[WebRTCFsmEvent.ERROR]: WebRTCState.ERROR,
|
|
140
115
|
},
|
|
141
116
|
},
|
|
142
|
-
[
|
|
117
|
+
[WebRTCState.CONNECTED]: {
|
|
143
118
|
on: {
|
|
144
|
-
[
|
|
145
|
-
[
|
|
119
|
+
[WebRTCFsmEvent.DISCONNECT]: WebRTCState.DISCONNECTED,
|
|
120
|
+
[WebRTCFsmEvent.ERROR]: WebRTCState.ERROR,
|
|
146
121
|
},
|
|
147
122
|
},
|
|
148
|
-
[
|
|
123
|
+
[WebRTCState.RECONNECTING]: {
|
|
149
124
|
on: {
|
|
150
|
-
[
|
|
151
|
-
[
|
|
152
|
-
[
|
|
125
|
+
[WebRTCFsmEvent.CONNECT]: WebRTCState.CONNECTING,
|
|
126
|
+
[WebRTCFsmEvent.DISCONNECT]: WebRTCState.DISCONNECTED,
|
|
127
|
+
[WebRTCFsmEvent.RESET]: WebRTCState.IDLE,
|
|
153
128
|
},
|
|
154
129
|
},
|
|
155
|
-
[
|
|
130
|
+
[WebRTCState.DISCONNECTED]: {
|
|
156
131
|
on: {
|
|
157
|
-
[
|
|
158
|
-
[
|
|
159
|
-
[
|
|
132
|
+
[WebRTCFsmEvent.CONNECT]: WebRTCState.CONNECTING,
|
|
133
|
+
[WebRTCFsmEvent.RECONNECTING]: WebRTCState.RECONNECTING,
|
|
134
|
+
[WebRTCFsmEvent.RESET]: WebRTCState.IDLE,
|
|
160
135
|
},
|
|
161
136
|
},
|
|
162
|
-
[
|
|
163
|
-
on: { [
|
|
137
|
+
[WebRTCState.ERROR]: {
|
|
138
|
+
on: { [WebRTCFsmEvent.RESET]: WebRTCState.IDLE },
|
|
164
139
|
},
|
|
165
140
|
},
|
|
166
141
|
});
|
|
@@ -220,11 +195,11 @@ export class WebRtcManager {
|
|
|
220
195
|
handler(getCurrentState());
|
|
221
196
|
// Subscribe to relevant events that affect the overall state
|
|
222
197
|
const unsubscribers = [
|
|
223
|
-
this.#pubsub.subscribe(
|
|
224
|
-
this.#pubsub.subscribe(
|
|
225
|
-
this.#pubsub.subscribe(
|
|
226
|
-
this.#pubsub.subscribe(
|
|
227
|
-
this.#pubsub.subscribe(
|
|
198
|
+
this.#pubsub.subscribe(WebRTCManager.EVENT_STATE_CHANGE, () => handler(getCurrentState())),
|
|
199
|
+
this.#pubsub.subscribe(WebRTCManager.EVENT_LOCAL_STREAM, () => handler(getCurrentState())),
|
|
200
|
+
this.#pubsub.subscribe(WebRTCManager.EVENT_REMOTE_STREAM, () => handler(getCurrentState())),
|
|
201
|
+
this.#pubsub.subscribe(WebRTCManager.EVENT_DATA_CHANNEL_OPEN, () => handler(getCurrentState())),
|
|
202
|
+
this.#pubsub.subscribe(WebRTCManager.EVENT_DATA_CHANNEL_CLOSE, () => handler(getCurrentState())),
|
|
228
203
|
];
|
|
229
204
|
// Return combined unsubscribe function
|
|
230
205
|
return () => {
|
|
@@ -284,7 +259,7 @@ export class WebRtcManager {
|
|
|
284
259
|
this.#localStream.getAudioTracks().forEach((track) => track.stop());
|
|
285
260
|
// Update local stream reference
|
|
286
261
|
this.#localStream = newStream;
|
|
287
|
-
this.#pubsub.publish(
|
|
262
|
+
this.#pubsub.publish(WebRTCManager.EVENT_LOCAL_STREAM, newStream);
|
|
288
263
|
return true;
|
|
289
264
|
}
|
|
290
265
|
catch (e) {
|
|
@@ -298,23 +273,23 @@ export class WebRtcManager {
|
|
|
298
273
|
* Must be called before creating offers or answers. Can only be called from IDLE state.
|
|
299
274
|
*/
|
|
300
275
|
async initialize() {
|
|
301
|
-
if (this.state !==
|
|
302
|
-
this.#
|
|
276
|
+
if (this.state !== WebRTCState.IDLE) {
|
|
277
|
+
this.#logger.debug("initialize() called but state is not IDLE:", this.state);
|
|
303
278
|
return;
|
|
304
279
|
}
|
|
305
|
-
this.#
|
|
306
|
-
this.#dispatch(
|
|
280
|
+
this.#logger.debug("Initializing...");
|
|
281
|
+
this.#dispatch(WebRTCFsmEvent.INIT);
|
|
307
282
|
try {
|
|
308
283
|
this.#pc = this.#factory.createPeerConnection(this.#config.peerConfig);
|
|
309
|
-
this.#
|
|
284
|
+
this.#logger.debug("Peer connection created");
|
|
310
285
|
this.#setupPcListeners();
|
|
311
286
|
// Setup device change detection now that we have a connection
|
|
312
287
|
this.#setupDeviceChangeListener();
|
|
313
288
|
if (this.#config.enableMicrophone) {
|
|
314
|
-
this.#
|
|
289
|
+
this.#logger.debug("Enabling microphone (config enabled)");
|
|
315
290
|
const success = await this.enableMicrophone(true);
|
|
316
291
|
if (!success) {
|
|
317
|
-
this.#pubsub.publish(
|
|
292
|
+
this.#pubsub.publish(WebRTCManager.EVENT_MICROPHONE_FAILED, {
|
|
318
293
|
reason: "Failed to enable microphone during initialization",
|
|
319
294
|
});
|
|
320
295
|
}
|
|
@@ -323,13 +298,13 @@ export class WebRtcManager {
|
|
|
323
298
|
// Always setup to receive audio, even if we don't enable microphone
|
|
324
299
|
// This ensures the SDP includes audio media line
|
|
325
300
|
this.#pc.addTransceiver("audio", { direction: "recvonly" });
|
|
326
|
-
this.#
|
|
301
|
+
this.#logger.debug("Added recvonly audio transceiver");
|
|
327
302
|
}
|
|
328
303
|
if (this.#config.dataChannelLabel) {
|
|
329
|
-
this.#
|
|
304
|
+
this.#logger.debug("Creating default data channel:", this.#config.dataChannelLabel);
|
|
330
305
|
this.createDataChannel(this.#config.dataChannelLabel);
|
|
331
306
|
}
|
|
332
|
-
this.#
|
|
307
|
+
this.#logger.debug("Initialization complete");
|
|
333
308
|
}
|
|
334
309
|
catch (e) {
|
|
335
310
|
this.#logError(e);
|
|
@@ -341,30 +316,30 @@ export class WebRtcManager {
|
|
|
341
316
|
* If disconnected, reinitializes the peer connection.
|
|
342
317
|
*/
|
|
343
318
|
async connect() {
|
|
344
|
-
this.#
|
|
319
|
+
this.#logger.debug("connect() called, current state:", this.state);
|
|
345
320
|
// Initialize if needed
|
|
346
|
-
if (this.state ===
|
|
347
|
-
this.#
|
|
321
|
+
if (this.state === WebRTCState.IDLE) {
|
|
322
|
+
this.#logger.debug("State is IDLE, initializing first");
|
|
348
323
|
await this.initialize();
|
|
349
324
|
}
|
|
350
325
|
// Reinitialize if disconnected (PeerConnection was closed)
|
|
351
|
-
if (this.state ===
|
|
352
|
-
this.#
|
|
326
|
+
if (this.state === WebRTCState.DISCONNECTED) {
|
|
327
|
+
this.#logger.debug("State is DISCONNECTED, reinitializing");
|
|
353
328
|
// Clean up old connection
|
|
354
329
|
this.#cleanup();
|
|
355
330
|
// Reset to IDLE and reinitialize
|
|
356
|
-
this.#fsm.transition(
|
|
331
|
+
this.#fsm.transition(WebRTCFsmEvent.RESET);
|
|
357
332
|
await this.initialize();
|
|
358
333
|
// Stay in INITIALIZING state - caller needs to create offer/answer
|
|
359
334
|
return;
|
|
360
335
|
}
|
|
361
|
-
if (this.state ===
|
|
362
|
-
this.state ===
|
|
363
|
-
this.#
|
|
336
|
+
if (this.state === WebRTCState.CONNECTED ||
|
|
337
|
+
this.state === WebRTCState.CONNECTING) {
|
|
338
|
+
this.#logger.debug("Already connected or connecting, skipping");
|
|
364
339
|
return;
|
|
365
340
|
}
|
|
366
|
-
this.#
|
|
367
|
-
this.#dispatch(
|
|
341
|
+
this.#logger.debug("Transitioning to CONNECTING");
|
|
342
|
+
this.#dispatch(WebRTCFsmEvent.CONNECT);
|
|
368
343
|
}
|
|
369
344
|
/**
|
|
370
345
|
* Enables or disables the microphone and adds/removes audio tracks to the peer connection.
|
|
@@ -372,21 +347,21 @@ export class WebRtcManager {
|
|
|
372
347
|
* @returns True if successful, false if failed to get user media.
|
|
373
348
|
*/
|
|
374
349
|
async enableMicrophone(enable) {
|
|
375
|
-
this.#
|
|
350
|
+
this.#logger.debug("enableMicrophone() called:", enable);
|
|
376
351
|
if (enable) {
|
|
377
352
|
if (this.#localStream) {
|
|
378
|
-
this.#
|
|
353
|
+
this.#logger.debug("Microphone already enabled");
|
|
379
354
|
return true;
|
|
380
355
|
}
|
|
381
356
|
try {
|
|
382
|
-
this.#
|
|
357
|
+
this.#logger.debug("Requesting user media...");
|
|
383
358
|
const stream = await this.#factory.getUserMedia({
|
|
384
359
|
audio: true,
|
|
385
360
|
video: false,
|
|
386
361
|
});
|
|
387
|
-
this.#
|
|
362
|
+
this.#logger.debug("User media obtained, tracks:", stream.getAudioTracks().length);
|
|
388
363
|
this.#localStream = stream;
|
|
389
|
-
this.#pubsub.publish(
|
|
364
|
+
this.#pubsub.publish(WebRTCManager.EVENT_LOCAL_STREAM, stream);
|
|
390
365
|
if (this.#pc) {
|
|
391
366
|
// Check if we have an existing audio transceiver
|
|
392
367
|
const transceivers = this.#pc.getTransceivers();
|
|
@@ -397,22 +372,22 @@ export class WebRtcManager {
|
|
|
397
372
|
await audioTransceiver.sender.replaceTrack(track);
|
|
398
373
|
// Update direction to sendrecv
|
|
399
374
|
audioTransceiver.direction = "sendrecv";
|
|
400
|
-
this.#
|
|
375
|
+
this.#logger.debug("Replaced track in existing transceiver");
|
|
401
376
|
}
|
|
402
377
|
else {
|
|
403
378
|
// Add track normally
|
|
404
379
|
stream.getTracks().forEach((track) => {
|
|
405
380
|
this.#pc.addTrack(track, stream);
|
|
406
381
|
});
|
|
407
|
-
this.#
|
|
382
|
+
this.#logger.debug("Added tracks to peer connection");
|
|
408
383
|
}
|
|
409
384
|
}
|
|
410
|
-
this.#
|
|
385
|
+
this.#logger.debug("Microphone enabled successfully");
|
|
411
386
|
return true;
|
|
412
387
|
}
|
|
413
388
|
catch (e) {
|
|
414
389
|
this.#logError("Failed to get user media:", e);
|
|
415
|
-
this.#pubsub.publish(
|
|
390
|
+
this.#pubsub.publish(WebRTCManager.EVENT_MICROPHONE_FAILED, {
|
|
416
391
|
error: e,
|
|
417
392
|
});
|
|
418
393
|
return false;
|
|
@@ -420,10 +395,10 @@ export class WebRtcManager {
|
|
|
420
395
|
}
|
|
421
396
|
else {
|
|
422
397
|
if (!this.#localStream) {
|
|
423
|
-
this.#
|
|
398
|
+
this.#logger.debug("Microphone already disabled");
|
|
424
399
|
return true;
|
|
425
400
|
}
|
|
426
|
-
this.#
|
|
401
|
+
this.#logger.debug("Disabling microphone...");
|
|
427
402
|
this.#localStream.getTracks().forEach((track) => {
|
|
428
403
|
track.stop();
|
|
429
404
|
// Remove from PC if needed, or just stop sending
|
|
@@ -436,8 +411,8 @@ export class WebRtcManager {
|
|
|
436
411
|
}
|
|
437
412
|
});
|
|
438
413
|
this.#localStream = null;
|
|
439
|
-
this.#pubsub.publish(
|
|
440
|
-
this.#
|
|
414
|
+
this.#pubsub.publish(WebRTCManager.EVENT_LOCAL_STREAM, null);
|
|
415
|
+
this.#logger.debug("Microphone disabled");
|
|
441
416
|
return true;
|
|
442
417
|
}
|
|
443
418
|
}
|
|
@@ -446,32 +421,32 @@ export class WebRtcManager {
|
|
|
446
421
|
* Transitions to DISCONNECTED state.
|
|
447
422
|
*/
|
|
448
423
|
disconnect() {
|
|
449
|
-
this.#
|
|
424
|
+
this.#logger.debug("disconnect() called");
|
|
450
425
|
this.#cleanup();
|
|
451
|
-
this.#dispatch(
|
|
426
|
+
this.#dispatch(WebRTCFsmEvent.DISCONNECT);
|
|
452
427
|
}
|
|
453
428
|
/**
|
|
454
429
|
* Resets the manager to IDLE state from any state.
|
|
455
430
|
* Cleans up all resources and allows reinitialization.
|
|
456
431
|
*/
|
|
457
432
|
reset() {
|
|
458
|
-
this.#
|
|
433
|
+
this.#logger.debug("reset() called, current state:", this.state);
|
|
459
434
|
this.#cleanup();
|
|
460
435
|
// Reset from any non-IDLE state
|
|
461
|
-
if (this.state !==
|
|
436
|
+
if (this.state !== WebRTCState.IDLE) {
|
|
462
437
|
// Force transition to DISCONNECTED first if needed, then to IDLE
|
|
463
|
-
if (this.state ===
|
|
464
|
-
this.state ===
|
|
465
|
-
this.state ===
|
|
466
|
-
this.#dispatch(
|
|
438
|
+
if (this.state === WebRTCState.ERROR ||
|
|
439
|
+
this.state === WebRTCState.DISCONNECTED ||
|
|
440
|
+
this.state === WebRTCState.RECONNECTING) {
|
|
441
|
+
this.#dispatch(WebRTCFsmEvent.RESET);
|
|
467
442
|
}
|
|
468
443
|
else {
|
|
469
444
|
// For other states, go through DISCONNECTED first
|
|
470
|
-
this.#dispatch(
|
|
471
|
-
this.#dispatch(
|
|
445
|
+
this.#dispatch(WebRTCFsmEvent.DISCONNECT);
|
|
446
|
+
this.#dispatch(WebRTCFsmEvent.RESET);
|
|
472
447
|
}
|
|
473
448
|
}
|
|
474
|
-
this.#
|
|
449
|
+
this.#logger.debug("Reset complete, state:", this.state);
|
|
475
450
|
}
|
|
476
451
|
/**
|
|
477
452
|
* Creates a new data channel with the specified label.
|
|
@@ -481,20 +456,20 @@ export class WebRtcManager {
|
|
|
481
456
|
* @returns The created data channel, or null if peer connection not initialized.
|
|
482
457
|
*/
|
|
483
458
|
createDataChannel(label, options) {
|
|
484
|
-
this.#
|
|
459
|
+
this.#logger.debug("createDataChannel() called:", label);
|
|
485
460
|
if (!this.#pc) {
|
|
486
|
-
this.#
|
|
461
|
+
this.#logger.debug("Cannot create data channel: peer connection not initialized");
|
|
487
462
|
return null;
|
|
488
463
|
}
|
|
489
464
|
if (this.#dataChannels.has(label)) {
|
|
490
|
-
this.#
|
|
465
|
+
this.#logger.debug("Returning existing data channel:", label);
|
|
491
466
|
return this.#dataChannels.get(label);
|
|
492
467
|
}
|
|
493
468
|
try {
|
|
494
469
|
const dc = this.#pc.createDataChannel(label, options);
|
|
495
470
|
this.#setupDataChannelListeners(dc);
|
|
496
471
|
this.#dataChannels.set(label, dc);
|
|
497
|
-
this.#
|
|
472
|
+
this.#logger.debug("Data channel created:", label);
|
|
498
473
|
return dc;
|
|
499
474
|
}
|
|
500
475
|
catch (e) {
|
|
@@ -521,11 +496,11 @@ export class WebRtcManager {
|
|
|
521
496
|
sendData(label, data) {
|
|
522
497
|
const channel = this.#dataChannels.get(label);
|
|
523
498
|
if (!channel) {
|
|
524
|
-
this.#
|
|
499
|
+
this.#logger.debug(`Data channel '${label}' not found`);
|
|
525
500
|
return false;
|
|
526
501
|
}
|
|
527
502
|
if (channel.readyState !== "open") {
|
|
528
|
-
this.#
|
|
503
|
+
this.#logger.debug(`Data channel '${label}' is not open (state: ${channel.readyState})`);
|
|
529
504
|
return false;
|
|
530
505
|
}
|
|
531
506
|
try {
|
|
@@ -546,14 +521,14 @@ export class WebRtcManager {
|
|
|
546
521
|
* @returns The offer SDP, or null if peer connection not initialized.
|
|
547
522
|
*/
|
|
548
523
|
async createOffer(options) {
|
|
549
|
-
this.#
|
|
524
|
+
this.#logger.debug("createOffer() called");
|
|
550
525
|
if (!this.#pc) {
|
|
551
|
-
this.#
|
|
526
|
+
this.#logger.debug("Cannot create offer: peer connection not initialized");
|
|
552
527
|
return null;
|
|
553
528
|
}
|
|
554
529
|
try {
|
|
555
530
|
const offer = await this.#pc.createOffer(options);
|
|
556
|
-
this.#
|
|
531
|
+
this.#logger.debug("Offer created:", offer.type);
|
|
557
532
|
return offer;
|
|
558
533
|
}
|
|
559
534
|
catch (e) {
|
|
@@ -568,14 +543,14 @@ export class WebRtcManager {
|
|
|
568
543
|
* @returns The answer SDP, or null if peer connection not initialized.
|
|
569
544
|
*/
|
|
570
545
|
async createAnswer(options) {
|
|
571
|
-
this.#
|
|
546
|
+
this.#logger.debug("createAnswer() called");
|
|
572
547
|
if (!this.#pc) {
|
|
573
|
-
this.#
|
|
548
|
+
this.#logger.debug("Cannot create answer: peer connection not initialized");
|
|
574
549
|
return null;
|
|
575
550
|
}
|
|
576
551
|
try {
|
|
577
552
|
const answer = await this.#pc.createAnswer(options);
|
|
578
|
-
this.#
|
|
553
|
+
this.#logger.debug("Answer created:", answer.type);
|
|
579
554
|
return answer;
|
|
580
555
|
}
|
|
581
556
|
catch (e) {
|
|
@@ -590,14 +565,14 @@ export class WebRtcManager {
|
|
|
590
565
|
* @returns True if successful, false otherwise.
|
|
591
566
|
*/
|
|
592
567
|
async setLocalDescription(description) {
|
|
593
|
-
this.#
|
|
568
|
+
this.#logger.debug("setLocalDescription() called:", description.type);
|
|
594
569
|
if (!this.#pc) {
|
|
595
|
-
this.#
|
|
570
|
+
this.#logger.debug("Cannot set local description: peer connection not initialized");
|
|
596
571
|
return false;
|
|
597
572
|
}
|
|
598
573
|
try {
|
|
599
574
|
await this.#pc.setLocalDescription(description);
|
|
600
|
-
this.#
|
|
575
|
+
this.#logger.debug("Local description set successfully");
|
|
601
576
|
return true;
|
|
602
577
|
}
|
|
603
578
|
catch (e) {
|
|
@@ -612,14 +587,14 @@ export class WebRtcManager {
|
|
|
612
587
|
* @returns True if successful, false otherwise.
|
|
613
588
|
*/
|
|
614
589
|
async setRemoteDescription(description) {
|
|
615
|
-
this.#
|
|
590
|
+
this.#logger.debug("setRemoteDescription() called:", description.type);
|
|
616
591
|
if (!this.#pc) {
|
|
617
|
-
this.#
|
|
592
|
+
this.#logger.debug("Cannot set remote description: peer connection not initialized");
|
|
618
593
|
return false;
|
|
619
594
|
}
|
|
620
595
|
try {
|
|
621
596
|
await this.#pc.setRemoteDescription(description);
|
|
622
|
-
this.#
|
|
597
|
+
this.#logger.debug("Remote description set successfully");
|
|
623
598
|
return true;
|
|
624
599
|
}
|
|
625
600
|
catch (e) {
|
|
@@ -634,15 +609,15 @@ export class WebRtcManager {
|
|
|
634
609
|
* @returns True if successful, false otherwise.
|
|
635
610
|
*/
|
|
636
611
|
async addIceCandidate(candidate) {
|
|
637
|
-
this.#
|
|
612
|
+
this.#logger.debug("addIceCandidate() called:", candidate ? "candidate" : "null (end-of-candidates)");
|
|
638
613
|
if (!this.#pc) {
|
|
639
|
-
this.#
|
|
614
|
+
this.#logger.debug("Cannot add ICE candidate: peer connection not initialized");
|
|
640
615
|
return false;
|
|
641
616
|
}
|
|
642
617
|
try {
|
|
643
618
|
if (candidate) {
|
|
644
619
|
await this.#pc.addIceCandidate(candidate);
|
|
645
|
-
this.#
|
|
620
|
+
this.#logger.debug("ICE candidate added");
|
|
646
621
|
}
|
|
647
622
|
return true;
|
|
648
623
|
}
|
|
@@ -658,15 +633,15 @@ export class WebRtcManager {
|
|
|
658
633
|
* @returns True if successful, false otherwise.
|
|
659
634
|
*/
|
|
660
635
|
async iceRestart() {
|
|
661
|
-
this.#
|
|
636
|
+
this.#logger.debug("iceRestart() called");
|
|
662
637
|
if (!this.#pc) {
|
|
663
|
-
this.#
|
|
638
|
+
this.#logger.debug("Cannot perform ICE restart: peer connection not initialized");
|
|
664
639
|
return false;
|
|
665
640
|
}
|
|
666
641
|
try {
|
|
667
642
|
const offer = await this.#pc.createOffer({ iceRestart: true });
|
|
668
643
|
await this.#pc.setLocalDescription(offer);
|
|
669
|
-
this.#
|
|
644
|
+
this.#logger.debug("ICE restart initiated");
|
|
670
645
|
return true;
|
|
671
646
|
}
|
|
672
647
|
catch (e) {
|
|
@@ -688,10 +663,10 @@ export class WebRtcManager {
|
|
|
688
663
|
}
|
|
689
664
|
const pc = this.#pc;
|
|
690
665
|
if (pc.iceGatheringState === "complete") {
|
|
691
|
-
this.#
|
|
666
|
+
this.#logger.debug("ICE gathering already complete");
|
|
692
667
|
return Promise.resolve();
|
|
693
668
|
}
|
|
694
|
-
this.#
|
|
669
|
+
this.#logger.debug("Waiting for ICE gathering to complete...");
|
|
695
670
|
return new Promise((resolve, reject) => {
|
|
696
671
|
const timer = setTimeout(() => {
|
|
697
672
|
cleanup();
|
|
@@ -704,7 +679,7 @@ export class WebRtcManager {
|
|
|
704
679
|
};
|
|
705
680
|
const checkState = () => {
|
|
706
681
|
if (pc.iceGatheringState === "complete") {
|
|
707
|
-
this.#
|
|
682
|
+
this.#logger.debug("ICE gathering complete (via state change)");
|
|
708
683
|
cleanup();
|
|
709
684
|
resolve();
|
|
710
685
|
}
|
|
@@ -712,7 +687,7 @@ export class WebRtcManager {
|
|
|
712
687
|
const handleCandidate = (event) => {
|
|
713
688
|
onCandidate?.(event.candidate);
|
|
714
689
|
if (event.candidate === null) {
|
|
715
|
-
this.#
|
|
690
|
+
this.#logger.debug("ICE gathering complete (null candidate)");
|
|
716
691
|
cleanup();
|
|
717
692
|
resolve();
|
|
718
693
|
}
|
|
@@ -757,48 +732,49 @@ export class WebRtcManager {
|
|
|
757
732
|
this.#fsm.transition(event);
|
|
758
733
|
const newState = this.#fsm.state;
|
|
759
734
|
if (oldState !== newState) {
|
|
760
|
-
this.#
|
|
761
|
-
this.#pubsub.publish(
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
// deno-lint-ignore no-explicit-any
|
|
765
|
-
#logDebug(...args) {
|
|
766
|
-
if (this.#config.debug) {
|
|
767
|
-
this.#logger.debug("[WebRtcManager]", ...args);
|
|
735
|
+
this.#logger.debug("State transition:", oldState, "->", newState, "(event:", event + ")");
|
|
736
|
+
this.#pubsub.publish(WebRTCManager.EVENT_STATE_CHANGE, newState);
|
|
768
737
|
}
|
|
769
738
|
}
|
|
770
739
|
// deno-lint-ignore no-explicit-any
|
|
771
740
|
#log(...args) {
|
|
772
|
-
this.#logger.log(
|
|
741
|
+
this.#logger.log(...args);
|
|
773
742
|
}
|
|
774
743
|
// deno-lint-ignore no-explicit-any
|
|
775
744
|
#logWarn(...args) {
|
|
776
|
-
this.#logger.warn(
|
|
745
|
+
this.#logger.warn(...args);
|
|
777
746
|
}
|
|
778
747
|
// deno-lint-ignore no-explicit-any
|
|
779
748
|
#logError(...args) {
|
|
780
|
-
this.#logger.error(
|
|
749
|
+
this.#logger.error(...args);
|
|
781
750
|
}
|
|
782
751
|
// deno-lint-ignore no-explicit-any
|
|
783
752
|
#handleError(error) {
|
|
784
|
-
this.#dispatch(
|
|
785
|
-
this.#pubsub.publish(
|
|
753
|
+
this.#dispatch(WebRTCFsmEvent.ERROR);
|
|
754
|
+
this.#pubsub.publish(WebRTCManager.EVENT_ERROR, error);
|
|
786
755
|
}
|
|
787
756
|
#setupPcListeners() {
|
|
788
757
|
if (!this.#pc)
|
|
789
758
|
return;
|
|
790
|
-
this.#
|
|
759
|
+
this.#logger.debug("Setting up peer connection listeners");
|
|
791
760
|
this.#pc.onconnectionstatechange = () => {
|
|
792
761
|
const state = this.#pc.connectionState;
|
|
793
|
-
this.#
|
|
762
|
+
this.#logger.debug("Connection state changed:", state);
|
|
794
763
|
if (state === "connected") {
|
|
795
|
-
//
|
|
796
|
-
|
|
797
|
-
if (this
|
|
798
|
-
|
|
799
|
-
this.#
|
|
764
|
+
// Only dispatch if in CONNECTING state (FSM can handle CONNECTED event)
|
|
765
|
+
// This guards against late connection success after user has disconnected
|
|
766
|
+
if (this.state === WebRTCState.CONNECTING) {
|
|
767
|
+
// Connection successful - reset reconnect attempts and clear any pending timeout
|
|
768
|
+
this.#reconnectAttempts = 0;
|
|
769
|
+
if (this.#fullReconnectTimeoutTimer !== null) {
|
|
770
|
+
clearTimeout(this.#fullReconnectTimeoutTimer);
|
|
771
|
+
this.#fullReconnectTimeoutTimer = null;
|
|
772
|
+
}
|
|
773
|
+
this.#dispatch(WebRTCFsmEvent.CONNECTED);
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
this.#logger.debug(`Ignoring late connection success (current state: ${this.state})`);
|
|
800
777
|
}
|
|
801
|
-
this.#dispatch(WebRtcFsmEvent.CONNECTED);
|
|
802
778
|
}
|
|
803
779
|
else if (state === "failed") {
|
|
804
780
|
// Connection failed - attempt reconnection if enabled
|
|
@@ -806,33 +782,33 @@ export class WebRtcManager {
|
|
|
806
782
|
}
|
|
807
783
|
else if (state === "disconnected" || state === "closed") {
|
|
808
784
|
// Only dispatch if not already in a terminal state
|
|
809
|
-
if (this.state !==
|
|
810
|
-
this.state !==
|
|
811
|
-
this.state !==
|
|
812
|
-
this.#dispatch(
|
|
785
|
+
if (this.state !== WebRTCState.DISCONNECTED &&
|
|
786
|
+
this.state !== WebRTCState.ERROR &&
|
|
787
|
+
this.state !== WebRTCState.IDLE) {
|
|
788
|
+
this.#dispatch(WebRTCFsmEvent.DISCONNECT);
|
|
813
789
|
}
|
|
814
790
|
}
|
|
815
791
|
};
|
|
816
792
|
this.#pc.ontrack = (event) => {
|
|
817
|
-
this.#
|
|
793
|
+
this.#logger.debug("Remote track received:", event.track.kind);
|
|
818
794
|
if (event.streams && event.streams[0]) {
|
|
819
795
|
this.#remoteStream = event.streams[0];
|
|
820
|
-
this.#pubsub.publish(
|
|
796
|
+
this.#pubsub.publish(WebRTCManager.EVENT_REMOTE_STREAM, this.#remoteStream);
|
|
821
797
|
}
|
|
822
798
|
};
|
|
823
799
|
this.#pc.ondatachannel = (event) => {
|
|
824
800
|
const dc = event.channel;
|
|
825
|
-
this.#
|
|
801
|
+
this.#logger.debug("Remote data channel received:", dc.label);
|
|
826
802
|
this.#setupDataChannelListeners(dc);
|
|
827
803
|
this.#dataChannels.set(dc.label, dc);
|
|
828
804
|
};
|
|
829
805
|
this.#pc.onicecandidate = (event) => {
|
|
830
|
-
this.#
|
|
831
|
-
this.#pubsub.publish(
|
|
806
|
+
this.#logger.debug("ICE candidate generated:", event.candidate ? "candidate" : "null (gathering complete)");
|
|
807
|
+
this.#pubsub.publish(WebRTCManager.EVENT_ICE_CANDIDATE, event.candidate);
|
|
832
808
|
};
|
|
833
809
|
}
|
|
834
810
|
#cleanup() {
|
|
835
|
-
this.#
|
|
811
|
+
this.#logger.debug("Cleanup started");
|
|
836
812
|
// Clear any pending reconnect timers
|
|
837
813
|
if (this.#reconnectTimer !== null) {
|
|
838
814
|
clearTimeout(this.#reconnectTimer);
|
|
@@ -857,41 +833,41 @@ export class WebRtcManager {
|
|
|
857
833
|
});
|
|
858
834
|
this.#dataChannels.clear();
|
|
859
835
|
if (dcCount > 0) {
|
|
860
|
-
this.#
|
|
836
|
+
this.#logger.debug("Closed", dcCount, "data channel(s)");
|
|
861
837
|
}
|
|
862
838
|
// Stop local stream tracks
|
|
863
839
|
if (this.#localStream) {
|
|
864
840
|
this.#localStream.getTracks().forEach((track) => track.stop());
|
|
865
841
|
this.#localStream = null;
|
|
866
|
-
this.#
|
|
842
|
+
this.#logger.debug("Local stream stopped");
|
|
867
843
|
}
|
|
868
844
|
// Close peer connection
|
|
869
845
|
if (this.#pc) {
|
|
870
846
|
this.#pc.close();
|
|
871
847
|
this.#pc = null;
|
|
872
|
-
this.#
|
|
848
|
+
this.#logger.debug("Peer connection closed");
|
|
873
849
|
}
|
|
874
850
|
this.#remoteStream = null;
|
|
875
|
-
this.#
|
|
851
|
+
this.#logger.debug("Cleanup complete");
|
|
876
852
|
}
|
|
877
853
|
#handleConnectionFailure() {
|
|
878
|
-
this.#
|
|
854
|
+
this.#logger.debug("Handling connection failure");
|
|
879
855
|
// Only dispatch DISCONNECT if not already in a terminal state
|
|
880
|
-
if (this.state !==
|
|
881
|
-
this.state !==
|
|
882
|
-
this.state !==
|
|
883
|
-
this.#dispatch(
|
|
856
|
+
if (this.state !== WebRTCState.DISCONNECTED &&
|
|
857
|
+
this.state !== WebRTCState.ERROR &&
|
|
858
|
+
this.state !== WebRTCState.IDLE) {
|
|
859
|
+
this.#dispatch(WebRTCFsmEvent.DISCONNECT);
|
|
884
860
|
}
|
|
885
861
|
// Check if auto-reconnect is enabled
|
|
886
862
|
if (!this.#config.autoReconnect) {
|
|
887
|
-
this.#
|
|
863
|
+
this.#logger.debug("Auto-reconnect disabled, not attempting reconnection");
|
|
888
864
|
return;
|
|
889
865
|
}
|
|
890
866
|
const maxAttempts = this.#config.maxReconnectAttempts ?? 5;
|
|
891
867
|
// Check if we've exceeded max attempts
|
|
892
868
|
if (this.#reconnectAttempts >= maxAttempts) {
|
|
893
|
-
this.#
|
|
894
|
-
this.#pubsub.publish(
|
|
869
|
+
this.#logger.debug("Max reconnection attempts reached:", maxAttempts);
|
|
870
|
+
this.#pubsub.publish(WebRTCManager.EVENT_RECONNECT_FAILED, {
|
|
895
871
|
attempts: this.#reconnectAttempts,
|
|
896
872
|
});
|
|
897
873
|
return;
|
|
@@ -907,12 +883,12 @@ export class WebRtcManager {
|
|
|
907
883
|
strategy,
|
|
908
884
|
});
|
|
909
885
|
if (!shouldProceed) {
|
|
910
|
-
this.#
|
|
886
|
+
this.#logger.debug("Reconnection suppressed by shouldReconnect callback");
|
|
911
887
|
return;
|
|
912
888
|
}
|
|
913
889
|
}
|
|
914
890
|
// Transition to RECONNECTING state
|
|
915
|
-
this.#dispatch(
|
|
891
|
+
this.#dispatch(WebRTCFsmEvent.RECONNECTING);
|
|
916
892
|
// Attempt reconnection with exponential backoff
|
|
917
893
|
this.#attemptReconnect();
|
|
918
894
|
}
|
|
@@ -922,12 +898,12 @@ export class WebRtcManager {
|
|
|
922
898
|
const delay = baseDelay * Math.pow(2, this.#reconnectAttempts - 1);
|
|
923
899
|
// Try ICE restart first (attempts 1-2), then full reconnect
|
|
924
900
|
const strategy = this.#reconnectAttempts <= 2 ? "ice-restart" : "full";
|
|
925
|
-
this.#
|
|
901
|
+
this.#logger.debug("Attempting reconnection:", {
|
|
926
902
|
attempt: this.#reconnectAttempts,
|
|
927
903
|
strategy,
|
|
928
904
|
delay: delay + "ms",
|
|
929
905
|
});
|
|
930
|
-
this.#pubsub.publish(
|
|
906
|
+
this.#pubsub.publish(WebRTCManager.EVENT_RECONNECTING, {
|
|
931
907
|
attempt: this.#reconnectAttempts,
|
|
932
908
|
strategy,
|
|
933
909
|
});
|
|
@@ -951,7 +927,7 @@ export class WebRtcManager {
|
|
|
951
927
|
try {
|
|
952
928
|
// Clean up old connection and reset to IDLE so connect() creates a new PC
|
|
953
929
|
this.#cleanup();
|
|
954
|
-
this.#dispatch(
|
|
930
|
+
this.#dispatch(WebRTCFsmEvent.RESET);
|
|
955
931
|
await this.connect();
|
|
956
932
|
// Start timeout for full reconnection - if connection doesn't succeed
|
|
957
933
|
// within the timeout, treat it as a failure
|
|
@@ -959,8 +935,8 @@ export class WebRtcManager {
|
|
|
959
935
|
this.#fullReconnectTimeoutTimer = setTimeout(() => {
|
|
960
936
|
this.#fullReconnectTimeoutTimer = null;
|
|
961
937
|
// Only trigger failure if still not connected
|
|
962
|
-
if (this.state !==
|
|
963
|
-
this.#
|
|
938
|
+
if (this.state !== WebRTCState.CONNECTED) {
|
|
939
|
+
this.#logger.debug("Full reconnection timeout reached, connection not established");
|
|
964
940
|
this.#handleConnectionFailure();
|
|
965
941
|
}
|
|
966
942
|
}, timeout);
|
|
@@ -984,7 +960,7 @@ export class WebRtcManager {
|
|
|
984
960
|
this.#deviceChangeHandler = async () => {
|
|
985
961
|
try {
|
|
986
962
|
const devices = await this.getAudioInputDevices();
|
|
987
|
-
this.#pubsub.publish(
|
|
963
|
+
this.#pubsub.publish(WebRTCManager.EVENT_DEVICE_CHANGED, devices);
|
|
988
964
|
}
|
|
989
965
|
catch (e) {
|
|
990
966
|
this.#logError("Error handling device change:", e);
|
|
@@ -994,16 +970,16 @@ export class WebRtcManager {
|
|
|
994
970
|
}
|
|
995
971
|
#setupDataChannelListeners(dc) {
|
|
996
972
|
dc.onopen = () => {
|
|
997
|
-
this.#pubsub.publish(
|
|
973
|
+
this.#pubsub.publish(WebRTCManager.EVENT_DATA_CHANNEL_OPEN, dc);
|
|
998
974
|
};
|
|
999
975
|
dc.onmessage = (event) => {
|
|
1000
|
-
this.#pubsub.publish(
|
|
976
|
+
this.#pubsub.publish(WebRTCManager.EVENT_DATA_CHANNEL_MESSAGE, {
|
|
1001
977
|
channel: dc,
|
|
1002
978
|
data: event.data,
|
|
1003
979
|
});
|
|
1004
980
|
};
|
|
1005
981
|
dc.onclose = () => {
|
|
1006
|
-
this.#pubsub.publish(
|
|
982
|
+
this.#pubsub.publish(WebRTCManager.EVENT_DATA_CHANNEL_CLOSE, dc);
|
|
1007
983
|
this.#dataChannels.delete(dc.label);
|
|
1008
984
|
};
|
|
1009
985
|
// deno-lint-ignore no-explicit-any
|
|
@@ -1012,7 +988,7 @@ export class WebRtcManager {
|
|
|
1012
988
|
const isUserAbort = error?.error?.message?.includes("User-Initiated Abort");
|
|
1013
989
|
if (!isUserAbort) {
|
|
1014
990
|
this.#logError("Data channel error:", error);
|
|
1015
|
-
this.#pubsub.publish(
|
|
991
|
+
this.#pubsub.publish(WebRTCManager.EVENT_ERROR, error);
|
|
1016
992
|
}
|
|
1017
993
|
};
|
|
1018
994
|
}
|