@marianmeres/webrtc 1.3.1 → 1.4.1
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 +157 -188
- 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,54 +732,48 @@ 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
764
|
// Only dispatch if in CONNECTING state (FSM can handle CONNECTED event)
|
|
796
765
|
// This guards against late connection success after user has disconnected
|
|
797
|
-
if (this.state ===
|
|
766
|
+
if (this.state === WebRTCState.CONNECTING) {
|
|
798
767
|
// Connection successful - reset reconnect attempts and clear any pending timeout
|
|
799
768
|
this.#reconnectAttempts = 0;
|
|
800
769
|
if (this.#fullReconnectTimeoutTimer !== null) {
|
|
801
770
|
clearTimeout(this.#fullReconnectTimeoutTimer);
|
|
802
771
|
this.#fullReconnectTimeoutTimer = null;
|
|
803
772
|
}
|
|
804
|
-
this.#dispatch(
|
|
773
|
+
this.#dispatch(WebRTCFsmEvent.CONNECTED);
|
|
805
774
|
}
|
|
806
775
|
else {
|
|
807
|
-
this.#
|
|
776
|
+
this.#logger.debug(`Ignoring late connection success (current state: ${this.state})`);
|
|
808
777
|
}
|
|
809
778
|
}
|
|
810
779
|
else if (state === "failed") {
|
|
@@ -813,33 +782,33 @@ export class WebRtcManager {
|
|
|
813
782
|
}
|
|
814
783
|
else if (state === "disconnected" || state === "closed") {
|
|
815
784
|
// Only dispatch if not already in a terminal state
|
|
816
|
-
if (this.state !==
|
|
817
|
-
this.state !==
|
|
818
|
-
this.state !==
|
|
819
|
-
this.#dispatch(
|
|
785
|
+
if (this.state !== WebRTCState.DISCONNECTED &&
|
|
786
|
+
this.state !== WebRTCState.ERROR &&
|
|
787
|
+
this.state !== WebRTCState.IDLE) {
|
|
788
|
+
this.#dispatch(WebRTCFsmEvent.DISCONNECT);
|
|
820
789
|
}
|
|
821
790
|
}
|
|
822
791
|
};
|
|
823
792
|
this.#pc.ontrack = (event) => {
|
|
824
|
-
this.#
|
|
793
|
+
this.#logger.debug("Remote track received:", event.track.kind);
|
|
825
794
|
if (event.streams && event.streams[0]) {
|
|
826
795
|
this.#remoteStream = event.streams[0];
|
|
827
|
-
this.#pubsub.publish(
|
|
796
|
+
this.#pubsub.publish(WebRTCManager.EVENT_REMOTE_STREAM, this.#remoteStream);
|
|
828
797
|
}
|
|
829
798
|
};
|
|
830
799
|
this.#pc.ondatachannel = (event) => {
|
|
831
800
|
const dc = event.channel;
|
|
832
|
-
this.#
|
|
801
|
+
this.#logger.debug("Remote data channel received:", dc.label);
|
|
833
802
|
this.#setupDataChannelListeners(dc);
|
|
834
803
|
this.#dataChannels.set(dc.label, dc);
|
|
835
804
|
};
|
|
836
805
|
this.#pc.onicecandidate = (event) => {
|
|
837
|
-
this.#
|
|
838
|
-
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);
|
|
839
808
|
};
|
|
840
809
|
}
|
|
841
810
|
#cleanup() {
|
|
842
|
-
this.#
|
|
811
|
+
this.#logger.debug("Cleanup started");
|
|
843
812
|
// Clear any pending reconnect timers
|
|
844
813
|
if (this.#reconnectTimer !== null) {
|
|
845
814
|
clearTimeout(this.#reconnectTimer);
|
|
@@ -864,41 +833,41 @@ export class WebRtcManager {
|
|
|
864
833
|
});
|
|
865
834
|
this.#dataChannels.clear();
|
|
866
835
|
if (dcCount > 0) {
|
|
867
|
-
this.#
|
|
836
|
+
this.#logger.debug("Closed", dcCount, "data channel(s)");
|
|
868
837
|
}
|
|
869
838
|
// Stop local stream tracks
|
|
870
839
|
if (this.#localStream) {
|
|
871
840
|
this.#localStream.getTracks().forEach((track) => track.stop());
|
|
872
841
|
this.#localStream = null;
|
|
873
|
-
this.#
|
|
842
|
+
this.#logger.debug("Local stream stopped");
|
|
874
843
|
}
|
|
875
844
|
// Close peer connection
|
|
876
845
|
if (this.#pc) {
|
|
877
846
|
this.#pc.close();
|
|
878
847
|
this.#pc = null;
|
|
879
|
-
this.#
|
|
848
|
+
this.#logger.debug("Peer connection closed");
|
|
880
849
|
}
|
|
881
850
|
this.#remoteStream = null;
|
|
882
|
-
this.#
|
|
851
|
+
this.#logger.debug("Cleanup complete");
|
|
883
852
|
}
|
|
884
853
|
#handleConnectionFailure() {
|
|
885
|
-
this.#
|
|
854
|
+
this.#logger.debug("Handling connection failure");
|
|
886
855
|
// Only dispatch DISCONNECT if not already in a terminal state
|
|
887
|
-
if (this.state !==
|
|
888
|
-
this.state !==
|
|
889
|
-
this.state !==
|
|
890
|
-
this.#dispatch(
|
|
856
|
+
if (this.state !== WebRTCState.DISCONNECTED &&
|
|
857
|
+
this.state !== WebRTCState.ERROR &&
|
|
858
|
+
this.state !== WebRTCState.IDLE) {
|
|
859
|
+
this.#dispatch(WebRTCFsmEvent.DISCONNECT);
|
|
891
860
|
}
|
|
892
861
|
// Check if auto-reconnect is enabled
|
|
893
862
|
if (!this.#config.autoReconnect) {
|
|
894
|
-
this.#
|
|
863
|
+
this.#logger.debug("Auto-reconnect disabled, not attempting reconnection");
|
|
895
864
|
return;
|
|
896
865
|
}
|
|
897
866
|
const maxAttempts = this.#config.maxReconnectAttempts ?? 5;
|
|
898
867
|
// Check if we've exceeded max attempts
|
|
899
868
|
if (this.#reconnectAttempts >= maxAttempts) {
|
|
900
|
-
this.#
|
|
901
|
-
this.#pubsub.publish(
|
|
869
|
+
this.#logger.debug("Max reconnection attempts reached:", maxAttempts);
|
|
870
|
+
this.#pubsub.publish(WebRTCManager.EVENT_RECONNECT_FAILED, {
|
|
902
871
|
attempts: this.#reconnectAttempts,
|
|
903
872
|
});
|
|
904
873
|
return;
|
|
@@ -914,12 +883,12 @@ export class WebRtcManager {
|
|
|
914
883
|
strategy,
|
|
915
884
|
});
|
|
916
885
|
if (!shouldProceed) {
|
|
917
|
-
this.#
|
|
886
|
+
this.#logger.debug("Reconnection suppressed by shouldReconnect callback");
|
|
918
887
|
return;
|
|
919
888
|
}
|
|
920
889
|
}
|
|
921
890
|
// Transition to RECONNECTING state
|
|
922
|
-
this.#dispatch(
|
|
891
|
+
this.#dispatch(WebRTCFsmEvent.RECONNECTING);
|
|
923
892
|
// Attempt reconnection with exponential backoff
|
|
924
893
|
this.#attemptReconnect();
|
|
925
894
|
}
|
|
@@ -929,12 +898,12 @@ export class WebRtcManager {
|
|
|
929
898
|
const delay = baseDelay * Math.pow(2, this.#reconnectAttempts - 1);
|
|
930
899
|
// Try ICE restart first (attempts 1-2), then full reconnect
|
|
931
900
|
const strategy = this.#reconnectAttempts <= 2 ? "ice-restart" : "full";
|
|
932
|
-
this.#
|
|
901
|
+
this.#logger.debug("Attempting reconnection:", {
|
|
933
902
|
attempt: this.#reconnectAttempts,
|
|
934
903
|
strategy,
|
|
935
904
|
delay: delay + "ms",
|
|
936
905
|
});
|
|
937
|
-
this.#pubsub.publish(
|
|
906
|
+
this.#pubsub.publish(WebRTCManager.EVENT_RECONNECTING, {
|
|
938
907
|
attempt: this.#reconnectAttempts,
|
|
939
908
|
strategy,
|
|
940
909
|
});
|
|
@@ -958,7 +927,7 @@ export class WebRtcManager {
|
|
|
958
927
|
try {
|
|
959
928
|
// Clean up old connection and reset to IDLE so connect() creates a new PC
|
|
960
929
|
this.#cleanup();
|
|
961
|
-
this.#dispatch(
|
|
930
|
+
this.#dispatch(WebRTCFsmEvent.RESET);
|
|
962
931
|
await this.connect();
|
|
963
932
|
// Start timeout for full reconnection - if connection doesn't succeed
|
|
964
933
|
// within the timeout, treat it as a failure
|
|
@@ -966,8 +935,8 @@ export class WebRtcManager {
|
|
|
966
935
|
this.#fullReconnectTimeoutTimer = setTimeout(() => {
|
|
967
936
|
this.#fullReconnectTimeoutTimer = null;
|
|
968
937
|
// Only trigger failure if still not connected
|
|
969
|
-
if (this.state !==
|
|
970
|
-
this.#
|
|
938
|
+
if (this.state !== WebRTCState.CONNECTED) {
|
|
939
|
+
this.#logger.debug("Full reconnection timeout reached, connection not established");
|
|
971
940
|
this.#handleConnectionFailure();
|
|
972
941
|
}
|
|
973
942
|
}, timeout);
|
|
@@ -991,7 +960,7 @@ export class WebRtcManager {
|
|
|
991
960
|
this.#deviceChangeHandler = async () => {
|
|
992
961
|
try {
|
|
993
962
|
const devices = await this.getAudioInputDevices();
|
|
994
|
-
this.#pubsub.publish(
|
|
963
|
+
this.#pubsub.publish(WebRTCManager.EVENT_DEVICE_CHANGED, devices);
|
|
995
964
|
}
|
|
996
965
|
catch (e) {
|
|
997
966
|
this.#logError("Error handling device change:", e);
|
|
@@ -1001,16 +970,16 @@ export class WebRtcManager {
|
|
|
1001
970
|
}
|
|
1002
971
|
#setupDataChannelListeners(dc) {
|
|
1003
972
|
dc.onopen = () => {
|
|
1004
|
-
this.#pubsub.publish(
|
|
973
|
+
this.#pubsub.publish(WebRTCManager.EVENT_DATA_CHANNEL_OPEN, dc);
|
|
1005
974
|
};
|
|
1006
975
|
dc.onmessage = (event) => {
|
|
1007
|
-
this.#pubsub.publish(
|
|
976
|
+
this.#pubsub.publish(WebRTCManager.EVENT_DATA_CHANNEL_MESSAGE, {
|
|
1008
977
|
channel: dc,
|
|
1009
978
|
data: event.data,
|
|
1010
979
|
});
|
|
1011
980
|
};
|
|
1012
981
|
dc.onclose = () => {
|
|
1013
|
-
this.#pubsub.publish(
|
|
982
|
+
this.#pubsub.publish(WebRTCManager.EVENT_DATA_CHANNEL_CLOSE, dc);
|
|
1014
983
|
this.#dataChannels.delete(dc.label);
|
|
1015
984
|
};
|
|
1016
985
|
// deno-lint-ignore no-explicit-any
|
|
@@ -1019,7 +988,7 @@ export class WebRtcManager {
|
|
|
1019
988
|
const isUserAbort = error?.error?.message?.includes("User-Initiated Abort");
|
|
1020
989
|
if (!isUserAbort) {
|
|
1021
990
|
this.#logError("Data channel error:", error);
|
|
1022
|
-
this.#pubsub.publish(
|
|
991
|
+
this.#pubsub.publish(WebRTCManager.EVENT_ERROR, error);
|
|
1023
992
|
}
|
|
1024
993
|
};
|
|
1025
994
|
}
|