@marianmeres/webrtc 0.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/LICENSE +21 -0
- package/README.md +408 -0
- package/dist/mod.d.ts +2 -0
- package/dist/mod.js +2 -0
- package/dist/types.d.ts +70 -0
- package/dist/types.js +20 -0
- package/dist/webrtc-manager.d.ts +158 -0
- package/dist/webrtc-manager.js +726 -0
- package/package.json +20 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
import { FSM } from "@marianmeres/fsm";
|
|
2
|
+
import { PubSub } from "@marianmeres/pubsub";
|
|
3
|
+
import { WebRtcState, WebRtcFsmEvent, } from "./types.js";
|
|
4
|
+
export class WebRtcManager {
|
|
5
|
+
// Event name constants
|
|
6
|
+
static EVENT_STATE_CHANGE = "state_change";
|
|
7
|
+
static EVENT_LOCAL_STREAM = "local_stream";
|
|
8
|
+
static EVENT_REMOTE_STREAM = "remote_stream";
|
|
9
|
+
static EVENT_DATA_CHANNEL_OPEN = "data_channel_open";
|
|
10
|
+
static EVENT_DATA_CHANNEL_MESSAGE = "data_channel_message";
|
|
11
|
+
static EVENT_DATA_CHANNEL_CLOSE = "data_channel_close";
|
|
12
|
+
static EVENT_ICE_CANDIDATE = "ice_candidate";
|
|
13
|
+
static EVENT_RECONNECTING = "reconnecting";
|
|
14
|
+
static EVENT_RECONNECT_FAILED = "reconnect_failed";
|
|
15
|
+
static EVENT_DEVICE_CHANGED = "device_changed";
|
|
16
|
+
static EVENT_MICROPHONE_FAILED = "microphone_failed";
|
|
17
|
+
static EVENT_ERROR = "error";
|
|
18
|
+
#fsm;
|
|
19
|
+
#pubsub;
|
|
20
|
+
#pc = null;
|
|
21
|
+
#factory;
|
|
22
|
+
#config;
|
|
23
|
+
#localStream = null;
|
|
24
|
+
#remoteStream = null;
|
|
25
|
+
#dataChannels = new Map();
|
|
26
|
+
#reconnectAttempts = 0;
|
|
27
|
+
#reconnectTimer = null;
|
|
28
|
+
#deviceChangeHandler = null;
|
|
29
|
+
constructor(factory, config = {}) {
|
|
30
|
+
this.#factory = factory;
|
|
31
|
+
this.#config = config;
|
|
32
|
+
this.#pubsub = new PubSub();
|
|
33
|
+
// Initialize FSM
|
|
34
|
+
this.#fsm = new FSM({
|
|
35
|
+
initial: WebRtcState.IDLE,
|
|
36
|
+
states: {
|
|
37
|
+
[WebRtcState.IDLE]: {
|
|
38
|
+
on: { [WebRtcFsmEvent.INIT]: WebRtcState.INITIALIZING },
|
|
39
|
+
},
|
|
40
|
+
[WebRtcState.INITIALIZING]: {
|
|
41
|
+
on: {
|
|
42
|
+
[WebRtcFsmEvent.CONNECT]: WebRtcState.CONNECTING,
|
|
43
|
+
[WebRtcFsmEvent.ERROR]: WebRtcState.ERROR,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
[WebRtcState.CONNECTING]: {
|
|
47
|
+
on: {
|
|
48
|
+
[WebRtcFsmEvent.CONNECTED]: WebRtcState.CONNECTED,
|
|
49
|
+
[WebRtcFsmEvent.DISCONNECT]: WebRtcState.DISCONNECTED,
|
|
50
|
+
[WebRtcFsmEvent.ERROR]: WebRtcState.ERROR,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
[WebRtcState.CONNECTED]: {
|
|
54
|
+
on: {
|
|
55
|
+
[WebRtcFsmEvent.DISCONNECT]: WebRtcState.DISCONNECTED,
|
|
56
|
+
[WebRtcFsmEvent.ERROR]: WebRtcState.ERROR,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
[WebRtcState.RECONNECTING]: {
|
|
60
|
+
on: {
|
|
61
|
+
[WebRtcFsmEvent.CONNECT]: WebRtcState.CONNECTING,
|
|
62
|
+
[WebRtcFsmEvent.DISCONNECT]: WebRtcState.DISCONNECTED,
|
|
63
|
+
[WebRtcFsmEvent.RESET]: WebRtcState.IDLE,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
[WebRtcState.DISCONNECTED]: {
|
|
67
|
+
on: {
|
|
68
|
+
[WebRtcFsmEvent.CONNECT]: WebRtcState.CONNECTING,
|
|
69
|
+
[WebRtcFsmEvent.RECONNECTING]: WebRtcState.RECONNECTING,
|
|
70
|
+
[WebRtcFsmEvent.RESET]: WebRtcState.IDLE,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
[WebRtcState.ERROR]: {
|
|
74
|
+
on: { [WebRtcFsmEvent.RESET]: WebRtcState.IDLE },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// --- Public API ---
|
|
80
|
+
/** Returns the current state of the WebRTC connection. */
|
|
81
|
+
get state() {
|
|
82
|
+
return this.#fsm.state;
|
|
83
|
+
}
|
|
84
|
+
/** Returns a readonly map of all active data channels indexed by label. */
|
|
85
|
+
get dataChannels() {
|
|
86
|
+
return this.#dataChannels;
|
|
87
|
+
}
|
|
88
|
+
/** Returns the local media stream, or null if not initialized. */
|
|
89
|
+
get localStream() {
|
|
90
|
+
return this.#localStream;
|
|
91
|
+
}
|
|
92
|
+
/** Returns the remote media stream, or null if not connected. */
|
|
93
|
+
get remoteStream() {
|
|
94
|
+
return this.#remoteStream;
|
|
95
|
+
}
|
|
96
|
+
/** Returns the underlying RTCPeerConnection, or null if not initialized. */
|
|
97
|
+
get peerConnection() {
|
|
98
|
+
return this.#pc;
|
|
99
|
+
}
|
|
100
|
+
/** Returns a Mermaid diagram representation of the FSM state machine. */
|
|
101
|
+
toMermaid() {
|
|
102
|
+
return this.#fsm.toMermaid();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Subscribe to a specific WebRTC event.
|
|
106
|
+
* @returns Unsubscribe function to remove the event listener.
|
|
107
|
+
*/
|
|
108
|
+
on(event, handler) {
|
|
109
|
+
return this.#pubsub.subscribe(event, handler);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Subscribe to the overall state of the WebRTC manager.
|
|
113
|
+
* Compatible with Svelte stores - immediately calls handler with current state,
|
|
114
|
+
* then notifies on any changes to state, streams, or data channels.
|
|
115
|
+
* @param handler - Callback that receives the overall state object
|
|
116
|
+
* @returns Unsubscribe function to remove the event listener.
|
|
117
|
+
*/
|
|
118
|
+
subscribe(handler) {
|
|
119
|
+
// Helper to get current overall state
|
|
120
|
+
const getCurrentState = () => ({
|
|
121
|
+
state: this.state,
|
|
122
|
+
localStream: this.localStream,
|
|
123
|
+
remoteStream: this.remoteStream,
|
|
124
|
+
dataChannels: this.dataChannels,
|
|
125
|
+
peerConnection: this.peerConnection,
|
|
126
|
+
});
|
|
127
|
+
// Immediately call handler with current state (Svelte store compatibility)
|
|
128
|
+
handler(getCurrentState());
|
|
129
|
+
// Subscribe to relevant events that affect the overall state
|
|
130
|
+
const unsubscribers = [
|
|
131
|
+
this.#pubsub.subscribe(WebRtcManager.EVENT_STATE_CHANGE, () => handler(getCurrentState())),
|
|
132
|
+
this.#pubsub.subscribe(WebRtcManager.EVENT_LOCAL_STREAM, () => handler(getCurrentState())),
|
|
133
|
+
this.#pubsub.subscribe(WebRtcManager.EVENT_REMOTE_STREAM, () => handler(getCurrentState())),
|
|
134
|
+
this.#pubsub.subscribe(WebRtcManager.EVENT_DATA_CHANNEL_OPEN, () => handler(getCurrentState())),
|
|
135
|
+
this.#pubsub.subscribe(WebRtcManager.EVENT_DATA_CHANNEL_CLOSE, () => handler(getCurrentState())),
|
|
136
|
+
];
|
|
137
|
+
// Return combined unsubscribe function
|
|
138
|
+
return () => {
|
|
139
|
+
unsubscribers.forEach((unsub) => unsub());
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Retrieves all available audio input devices.
|
|
144
|
+
* @returns Array of audio input devices, or empty array on error.
|
|
145
|
+
*/
|
|
146
|
+
async getAudioInputDevices() {
|
|
147
|
+
try {
|
|
148
|
+
const devices = await this.#factory.enumerateDevices();
|
|
149
|
+
return devices.filter((d) => d.kind === "audioinput");
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
console.error("Failed to enumerate devices:", e);
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Switches the active microphone to a different audio input device.
|
|
158
|
+
* @param deviceId - The device ID of the audio input to switch to.
|
|
159
|
+
* @returns True if the switch was successful, false otherwise.
|
|
160
|
+
*/
|
|
161
|
+
async switchMicrophone(deviceId) {
|
|
162
|
+
if (!this.#pc || !this.#localStream) {
|
|
163
|
+
console.error("Cannot switch microphone: not initialized or no active stream");
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
// Get new stream from the specified device
|
|
168
|
+
const newStream = await this.#factory.getUserMedia({
|
|
169
|
+
audio: { deviceId: { exact: deviceId } },
|
|
170
|
+
video: false,
|
|
171
|
+
});
|
|
172
|
+
const newTrack = newStream.getAudioTracks()[0];
|
|
173
|
+
if (!newTrack) {
|
|
174
|
+
throw new Error("No audio track in new stream");
|
|
175
|
+
}
|
|
176
|
+
// Find the sender for the audio track - check both senders and transceivers
|
|
177
|
+
let sender = this.#pc.getSenders().find((s) => s.track?.kind === "audio");
|
|
178
|
+
if (!sender) {
|
|
179
|
+
// Try to find via transceiver
|
|
180
|
+
const transceivers = this.#pc.getTransceivers();
|
|
181
|
+
const audioTransceiver = transceivers.find((t) => t.receiver.track.kind === "audio");
|
|
182
|
+
if (audioTransceiver) {
|
|
183
|
+
sender = audioTransceiver.sender;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!sender) {
|
|
187
|
+
throw new Error("No audio sender found - enable microphone first");
|
|
188
|
+
}
|
|
189
|
+
// Replace the track
|
|
190
|
+
await sender.replaceTrack(newTrack);
|
|
191
|
+
// Stop old tracks
|
|
192
|
+
this.#localStream.getAudioTracks().forEach((track) => track.stop());
|
|
193
|
+
// Update local stream reference
|
|
194
|
+
this.#localStream = newStream;
|
|
195
|
+
this.#pubsub.publish(WebRtcManager.EVENT_LOCAL_STREAM, newStream);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
console.error("Failed to switch microphone:", e);
|
|
200
|
+
this.#error(e);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Initializes the WebRTC peer connection and sets up media tracks.
|
|
206
|
+
* Must be called before creating offers or answers. Can only be called from IDLE state.
|
|
207
|
+
*/
|
|
208
|
+
async initialize() {
|
|
209
|
+
if (this.state !== WebRtcState.IDLE)
|
|
210
|
+
return;
|
|
211
|
+
this.#dispatch(WebRtcFsmEvent.INIT);
|
|
212
|
+
try {
|
|
213
|
+
this.#pc = this.#factory.createPeerConnection(this.#config.peerConfig);
|
|
214
|
+
this.#setupPcListeners();
|
|
215
|
+
// Setup device change detection now that we have a connection
|
|
216
|
+
this.#setupDeviceChangeListener();
|
|
217
|
+
if (this.#config.enableMicrophone) {
|
|
218
|
+
const success = await this.enableMicrophone(true);
|
|
219
|
+
if (!success) {
|
|
220
|
+
this.#pubsub.publish(WebRtcManager.EVENT_MICROPHONE_FAILED, {
|
|
221
|
+
reason: "Failed to enable microphone during initialization",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// Always setup to receive audio, even if we don't enable microphone
|
|
227
|
+
// This ensures the SDP includes audio media line
|
|
228
|
+
this.#pc.addTransceiver("audio", { direction: "recvonly" });
|
|
229
|
+
}
|
|
230
|
+
if (this.#config.dataChannelLabel) {
|
|
231
|
+
this.createDataChannel(this.#config.dataChannelLabel);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
this.#error(e);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Transitions to the CONNECTING state. Automatically initializes if needed.
|
|
240
|
+
* If disconnected, reinitializes the peer connection.
|
|
241
|
+
*/
|
|
242
|
+
async connect() {
|
|
243
|
+
// Initialize if needed
|
|
244
|
+
if (this.state === WebRtcState.IDLE) {
|
|
245
|
+
await this.initialize();
|
|
246
|
+
}
|
|
247
|
+
// Reinitialize if disconnected (PeerConnection was closed)
|
|
248
|
+
if (this.state === WebRtcState.DISCONNECTED) {
|
|
249
|
+
// Clean up old connection
|
|
250
|
+
this.#cleanup();
|
|
251
|
+
// Reset to IDLE and reinitialize
|
|
252
|
+
this.#fsm.transition(WebRtcFsmEvent.RESET);
|
|
253
|
+
await this.initialize();
|
|
254
|
+
// Stay in INITIALIZING state - caller needs to create offer/answer
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (this.state === WebRtcState.CONNECTED ||
|
|
258
|
+
this.state === WebRtcState.CONNECTING)
|
|
259
|
+
return;
|
|
260
|
+
this.#dispatch(WebRtcFsmEvent.CONNECT);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Enables or disables the microphone and adds/removes audio tracks to the peer connection.
|
|
264
|
+
* @param enable - True to enable microphone, false to disable.
|
|
265
|
+
* @returns True if successful, false if failed to get user media.
|
|
266
|
+
*/
|
|
267
|
+
async enableMicrophone(enable) {
|
|
268
|
+
if (enable) {
|
|
269
|
+
if (this.#localStream)
|
|
270
|
+
return true; // Already enabled
|
|
271
|
+
try {
|
|
272
|
+
const stream = await this.#factory.getUserMedia({
|
|
273
|
+
audio: true,
|
|
274
|
+
video: false,
|
|
275
|
+
});
|
|
276
|
+
this.#localStream = stream;
|
|
277
|
+
this.#pubsub.publish(WebRtcManager.EVENT_LOCAL_STREAM, stream);
|
|
278
|
+
if (this.#pc) {
|
|
279
|
+
// Check if we have an existing audio transceiver
|
|
280
|
+
const transceivers = this.#pc.getTransceivers();
|
|
281
|
+
const audioTransceiver = transceivers.find((t) => t.receiver.track.kind === "audio");
|
|
282
|
+
if (audioTransceiver && audioTransceiver.sender) {
|
|
283
|
+
// Replace the track in existing transceiver
|
|
284
|
+
const track = stream.getAudioTracks()[0];
|
|
285
|
+
await audioTransceiver.sender.replaceTrack(track);
|
|
286
|
+
// Update direction to sendrecv
|
|
287
|
+
audioTransceiver.direction = "sendrecv";
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
// Add track normally
|
|
291
|
+
stream.getTracks().forEach((track) => {
|
|
292
|
+
this.#pc.addTrack(track, stream);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
console.error("Failed to get user media", e);
|
|
300
|
+
this.#pubsub.publish(WebRtcManager.EVENT_MICROPHONE_FAILED, { error: e });
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
if (!this.#localStream)
|
|
306
|
+
return true;
|
|
307
|
+
this.#localStream.getTracks().forEach((track) => {
|
|
308
|
+
track.stop();
|
|
309
|
+
// Remove from PC if needed, or just stop sending
|
|
310
|
+
if (this.#pc) {
|
|
311
|
+
const senders = this.#pc.getSenders();
|
|
312
|
+
const sender = senders.find((s) => s.track === track);
|
|
313
|
+
if (sender) {
|
|
314
|
+
this.#pc.removeTrack(sender);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
this.#localStream = null;
|
|
319
|
+
this.#pubsub.publish(WebRtcManager.EVENT_LOCAL_STREAM, null);
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Disconnects the peer connection and cleans up all resources.
|
|
325
|
+
* Transitions to DISCONNECTED state.
|
|
326
|
+
*/
|
|
327
|
+
disconnect() {
|
|
328
|
+
this.#cleanup();
|
|
329
|
+
this.#dispatch(WebRtcFsmEvent.DISCONNECT);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Resets the manager to IDLE state from any state.
|
|
333
|
+
* Cleans up all resources and allows reinitialization.
|
|
334
|
+
*/
|
|
335
|
+
reset() {
|
|
336
|
+
this.#cleanup();
|
|
337
|
+
// Reset from any non-IDLE state
|
|
338
|
+
if (this.state !== WebRtcState.IDLE) {
|
|
339
|
+
// Force transition to DISCONNECTED first if needed, then to IDLE
|
|
340
|
+
if (this.state === WebRtcState.ERROR ||
|
|
341
|
+
this.state === WebRtcState.DISCONNECTED ||
|
|
342
|
+
this.state === WebRtcState.RECONNECTING) {
|
|
343
|
+
this.#dispatch(WebRtcFsmEvent.RESET);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
// For other states, go through DISCONNECTED first
|
|
347
|
+
this.#dispatch(WebRtcFsmEvent.DISCONNECT);
|
|
348
|
+
this.#dispatch(WebRtcFsmEvent.RESET);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Creates a new data channel with the specified label.
|
|
354
|
+
* Returns existing channel if one with the same label already exists.
|
|
355
|
+
* @param label - The label for the data channel.
|
|
356
|
+
* @param options - Optional RTCDataChannelInit configuration.
|
|
357
|
+
* @returns The created data channel, or null if peer connection not initialized.
|
|
358
|
+
*/
|
|
359
|
+
createDataChannel(label, options) {
|
|
360
|
+
if (!this.#pc)
|
|
361
|
+
return null;
|
|
362
|
+
if (this.#dataChannels.has(label))
|
|
363
|
+
return this.#dataChannels.get(label);
|
|
364
|
+
try {
|
|
365
|
+
const dc = this.#pc.createDataChannel(label, options);
|
|
366
|
+
this.#setupDataChannelListeners(dc);
|
|
367
|
+
this.#dataChannels.set(label, dc);
|
|
368
|
+
return dc;
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
this.#error(e);
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Retrieves an existing data channel by label.
|
|
377
|
+
* @param label - The label of the data channel to retrieve.
|
|
378
|
+
* @returns The data channel if found, undefined otherwise.
|
|
379
|
+
*/
|
|
380
|
+
getDataChannel(label) {
|
|
381
|
+
return this.#dataChannels.get(label);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Sends data through a data channel identified by label.
|
|
385
|
+
* Checks that the channel exists and is in open state before sending.
|
|
386
|
+
* @param label - The label of the data channel to send through.
|
|
387
|
+
* @param data - The data to send (string, Blob, or ArrayBuffer).
|
|
388
|
+
* @returns True if data was sent successfully, false otherwise.
|
|
389
|
+
*/
|
|
390
|
+
sendData(label, data) {
|
|
391
|
+
const channel = this.#dataChannels.get(label);
|
|
392
|
+
if (!channel) {
|
|
393
|
+
this.#debug(`Data channel '${label}' not found`);
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
if (channel.readyState !== "open") {
|
|
397
|
+
this.#debug(`Data channel '${label}' is not open (state: ${channel.readyState})`);
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
channel.send(data);
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
catch (e) {
|
|
405
|
+
this.#error(e);
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// --- Signaling methods ---
|
|
410
|
+
/**
|
|
411
|
+
* Creates an SDP offer for initiating a WebRTC connection.
|
|
412
|
+
* @param options - Optional offer configuration.
|
|
413
|
+
* @returns The offer SDP, or null if peer connection not initialized.
|
|
414
|
+
*/
|
|
415
|
+
async createOffer(options) {
|
|
416
|
+
if (!this.#pc)
|
|
417
|
+
return null;
|
|
418
|
+
try {
|
|
419
|
+
const offer = await this.#pc.createOffer(options);
|
|
420
|
+
return offer;
|
|
421
|
+
}
|
|
422
|
+
catch (e) {
|
|
423
|
+
this.#error(e);
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Creates an SDP answer in response to a received offer.
|
|
429
|
+
* @param options - Optional answer configuration.
|
|
430
|
+
* @returns The answer SDP, or null if peer connection not initialized.
|
|
431
|
+
*/
|
|
432
|
+
async createAnswer(options) {
|
|
433
|
+
if (!this.#pc)
|
|
434
|
+
return null;
|
|
435
|
+
try {
|
|
436
|
+
const answer = await this.#pc.createAnswer(options);
|
|
437
|
+
return answer;
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
this.#error(e);
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Sets the local description for the peer connection.
|
|
446
|
+
* @param description - The SDP description (offer or answer).
|
|
447
|
+
* @returns True if successful, false otherwise.
|
|
448
|
+
*/
|
|
449
|
+
async setLocalDescription(description) {
|
|
450
|
+
if (!this.#pc)
|
|
451
|
+
return false;
|
|
452
|
+
try {
|
|
453
|
+
await this.#pc.setLocalDescription(description);
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
catch (e) {
|
|
457
|
+
this.#error(e);
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Sets the remote description received from the peer.
|
|
463
|
+
* @param description - The remote SDP description.
|
|
464
|
+
* @returns True if successful, false otherwise.
|
|
465
|
+
*/
|
|
466
|
+
async setRemoteDescription(description) {
|
|
467
|
+
if (!this.#pc)
|
|
468
|
+
return false;
|
|
469
|
+
try {
|
|
470
|
+
await this.#pc.setRemoteDescription(description);
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
catch (e) {
|
|
474
|
+
this.#error(e);
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Adds an ICE candidate received from the remote peer.
|
|
480
|
+
* @param candidate - The ICE candidate to add, or null for end-of-candidates.
|
|
481
|
+
* @returns True if successful, false otherwise.
|
|
482
|
+
*/
|
|
483
|
+
async addIceCandidate(candidate) {
|
|
484
|
+
if (!this.#pc)
|
|
485
|
+
return false;
|
|
486
|
+
try {
|
|
487
|
+
if (candidate) {
|
|
488
|
+
await this.#pc.addIceCandidate(candidate);
|
|
489
|
+
}
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
catch (e) {
|
|
493
|
+
this.#error(e);
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Performs an ICE restart to recover from connection issues.
|
|
499
|
+
* Creates a new offer with iceRestart flag and sets it as local description.
|
|
500
|
+
* @returns True if successful, false otherwise.
|
|
501
|
+
*/
|
|
502
|
+
async iceRestart() {
|
|
503
|
+
if (!this.#pc)
|
|
504
|
+
return false;
|
|
505
|
+
try {
|
|
506
|
+
const offer = await this.#pc.createOffer({ iceRestart: true });
|
|
507
|
+
await this.#pc.setLocalDescription(offer);
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
catch (e) {
|
|
511
|
+
this.#error(e);
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Returns the current local session description.
|
|
517
|
+
* @returns The local description, or null if not set.
|
|
518
|
+
*/
|
|
519
|
+
getLocalDescription() {
|
|
520
|
+
return this.#pc?.localDescription ?? null;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Returns the current remote session description.
|
|
524
|
+
* @returns The remote description, or null if not set.
|
|
525
|
+
*/
|
|
526
|
+
getRemoteDescription() {
|
|
527
|
+
return this.#pc?.remoteDescription ?? null;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Retrieves WebRTC statistics for the peer connection.
|
|
531
|
+
* @returns Stats report, or null if peer connection not initialized.
|
|
532
|
+
*/
|
|
533
|
+
async getStats() {
|
|
534
|
+
if (!this.#pc)
|
|
535
|
+
return null;
|
|
536
|
+
try {
|
|
537
|
+
return await this.#pc.getStats();
|
|
538
|
+
}
|
|
539
|
+
catch (e) {
|
|
540
|
+
this.#error(e);
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// --- Private ---
|
|
545
|
+
#dispatch(event) {
|
|
546
|
+
const oldState = this.#fsm.state;
|
|
547
|
+
this.#fsm.transition(event);
|
|
548
|
+
const newState = this.#fsm.state;
|
|
549
|
+
if (oldState !== newState) {
|
|
550
|
+
this.#pubsub.publish(WebRtcManager.EVENT_STATE_CHANGE, newState);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
#debug(...args) {
|
|
554
|
+
if (this.#config.debug) {
|
|
555
|
+
console.debug("[WebRtcManager]", ...args);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
#error(error) {
|
|
559
|
+
console.error(error);
|
|
560
|
+
this.#dispatch(WebRtcFsmEvent.ERROR);
|
|
561
|
+
this.#pubsub.publish(WebRtcManager.EVENT_ERROR, error);
|
|
562
|
+
}
|
|
563
|
+
#setupPcListeners() {
|
|
564
|
+
if (!this.#pc)
|
|
565
|
+
return;
|
|
566
|
+
this.#pc.onconnectionstatechange = () => {
|
|
567
|
+
const state = this.#pc.connectionState;
|
|
568
|
+
if (state === "connected") {
|
|
569
|
+
// Connection successful - reset reconnect attempts
|
|
570
|
+
this.#reconnectAttempts = 0;
|
|
571
|
+
this.#dispatch(WebRtcFsmEvent.CONNECTED);
|
|
572
|
+
}
|
|
573
|
+
else if (state === "failed") {
|
|
574
|
+
// Connection failed - attempt reconnection if enabled
|
|
575
|
+
this.#handleConnectionFailure();
|
|
576
|
+
}
|
|
577
|
+
else if (state === "disconnected" || state === "closed") {
|
|
578
|
+
this.#dispatch(WebRtcFsmEvent.DISCONNECT);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
this.#pc.ontrack = (event) => {
|
|
582
|
+
if (event.streams && event.streams[0]) {
|
|
583
|
+
this.#remoteStream = event.streams[0];
|
|
584
|
+
this.#pubsub.publish(WebRtcManager.EVENT_REMOTE_STREAM, this.#remoteStream);
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
this.#pc.ondatachannel = (event) => {
|
|
588
|
+
const dc = event.channel;
|
|
589
|
+
this.#setupDataChannelListeners(dc);
|
|
590
|
+
this.#dataChannels.set(dc.label, dc);
|
|
591
|
+
};
|
|
592
|
+
this.#pc.onicecandidate = (event) => {
|
|
593
|
+
this.#pubsub.publish(WebRtcManager.EVENT_ICE_CANDIDATE, event.candidate);
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
#cleanup() {
|
|
597
|
+
// Clear any pending reconnect timers
|
|
598
|
+
if (this.#reconnectTimer !== null) {
|
|
599
|
+
clearTimeout(this.#reconnectTimer);
|
|
600
|
+
this.#reconnectTimer = null;
|
|
601
|
+
}
|
|
602
|
+
// Remove device change listener
|
|
603
|
+
if (this.#deviceChangeHandler) {
|
|
604
|
+
navigator.mediaDevices.removeEventListener("devicechange", this.#deviceChangeHandler);
|
|
605
|
+
this.#deviceChangeHandler = null;
|
|
606
|
+
}
|
|
607
|
+
// Close all data channels
|
|
608
|
+
this.#dataChannels.forEach((dc) => {
|
|
609
|
+
if (dc.readyState !== "closed") {
|
|
610
|
+
dc.close();
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
this.#dataChannels.clear();
|
|
614
|
+
// Stop local stream tracks
|
|
615
|
+
if (this.#localStream) {
|
|
616
|
+
this.#localStream.getTracks().forEach((track) => track.stop());
|
|
617
|
+
this.#localStream = null;
|
|
618
|
+
}
|
|
619
|
+
// Close peer connection
|
|
620
|
+
if (this.#pc) {
|
|
621
|
+
this.#pc.close();
|
|
622
|
+
this.#pc = null;
|
|
623
|
+
}
|
|
624
|
+
this.#remoteStream = null;
|
|
625
|
+
}
|
|
626
|
+
#handleConnectionFailure() {
|
|
627
|
+
this.#dispatch(WebRtcFsmEvent.DISCONNECT);
|
|
628
|
+
// Check if auto-reconnect is enabled
|
|
629
|
+
if (!this.#config.autoReconnect) {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const maxAttempts = this.#config.maxReconnectAttempts ?? 5;
|
|
633
|
+
// Check if we've exceeded max attempts
|
|
634
|
+
if (this.#reconnectAttempts >= maxAttempts) {
|
|
635
|
+
this.#pubsub.publish(WebRtcManager.EVENT_RECONNECT_FAILED, {
|
|
636
|
+
attempts: this.#reconnectAttempts,
|
|
637
|
+
});
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
// Transition to RECONNECTING state
|
|
641
|
+
this.#dispatch(WebRtcFsmEvent.RECONNECTING);
|
|
642
|
+
// Attempt reconnection with exponential backoff
|
|
643
|
+
this.#attemptReconnect();
|
|
644
|
+
}
|
|
645
|
+
#attemptReconnect() {
|
|
646
|
+
this.#reconnectAttempts++;
|
|
647
|
+
const baseDelay = this.#config.reconnectDelay ?? 1000;
|
|
648
|
+
const delay = baseDelay * Math.pow(2, this.#reconnectAttempts - 1);
|
|
649
|
+
// Try ICE restart first (attempts 1-2), then full reconnect
|
|
650
|
+
const strategy = this.#reconnectAttempts <= 2 ? "ice-restart" : "full";
|
|
651
|
+
this.#pubsub.publish(WebRtcManager.EVENT_RECONNECTING, {
|
|
652
|
+
attempt: this.#reconnectAttempts,
|
|
653
|
+
strategy,
|
|
654
|
+
});
|
|
655
|
+
this.#reconnectTimer = setTimeout(async () => {
|
|
656
|
+
this.#reconnectTimer = null;
|
|
657
|
+
if (strategy === "ice-restart" && this.#pc) {
|
|
658
|
+
// Try ICE restart - keep existing connection
|
|
659
|
+
const success = await this.iceRestart();
|
|
660
|
+
if (!success) {
|
|
661
|
+
// ICE restart failed, will try again or switch to full reconnect
|
|
662
|
+
this.#handleConnectionFailure();
|
|
663
|
+
}
|
|
664
|
+
// If successful, onconnectionstatechange will reset attempts
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
// Full reconnection - create new connection
|
|
668
|
+
// IMPORTANT: This will only initialize the connection. Consumers MUST
|
|
669
|
+
// listen for the 'reconnecting' event with strategy='full' and manually
|
|
670
|
+
// perform the signaling handshake (create offer/answer exchange) to
|
|
671
|
+
// complete the reconnection.
|
|
672
|
+
try {
|
|
673
|
+
await this.connect();
|
|
674
|
+
// If successful, onconnectionstatechange will reset attempts
|
|
675
|
+
}
|
|
676
|
+
catch (e) {
|
|
677
|
+
console.error("Reconnection failed:", e);
|
|
678
|
+
this.#handleConnectionFailure();
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}, delay);
|
|
682
|
+
}
|
|
683
|
+
#setupDeviceChangeListener() {
|
|
684
|
+
// Only setup in browser environment with navigator.mediaDevices
|
|
685
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// Don't setup twice
|
|
689
|
+
if (this.#deviceChangeHandler) {
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
this.#deviceChangeHandler = async () => {
|
|
693
|
+
try {
|
|
694
|
+
const devices = await this.getAudioInputDevices();
|
|
695
|
+
this.#pubsub.publish(WebRtcManager.EVENT_DEVICE_CHANGED, devices);
|
|
696
|
+
}
|
|
697
|
+
catch (e) {
|
|
698
|
+
console.error("Error handling device change:", e);
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
navigator.mediaDevices.addEventListener("devicechange", this.#deviceChangeHandler);
|
|
702
|
+
}
|
|
703
|
+
#setupDataChannelListeners(dc) {
|
|
704
|
+
dc.onopen = () => {
|
|
705
|
+
this.#pubsub.publish(WebRtcManager.EVENT_DATA_CHANNEL_OPEN, dc);
|
|
706
|
+
};
|
|
707
|
+
dc.onmessage = (event) => {
|
|
708
|
+
this.#pubsub.publish(WebRtcManager.EVENT_DATA_CHANNEL_MESSAGE, {
|
|
709
|
+
channel: dc,
|
|
710
|
+
data: event.data,
|
|
711
|
+
});
|
|
712
|
+
};
|
|
713
|
+
dc.onclose = () => {
|
|
714
|
+
this.#pubsub.publish(WebRtcManager.EVENT_DATA_CHANNEL_CLOSE, dc);
|
|
715
|
+
this.#dataChannels.delete(dc.label);
|
|
716
|
+
};
|
|
717
|
+
dc.onerror = (error) => {
|
|
718
|
+
// Ignore "User-Initiated Abort" errors which occur during intentional close()
|
|
719
|
+
const isUserAbort = error?.error?.message?.includes("User-Initiated Abort");
|
|
720
|
+
if (!isUserAbort) {
|
|
721
|
+
console.error("Data Channel Error:", error);
|
|
722
|
+
this.#pubsub.publish(WebRtcManager.EVENT_ERROR, error);
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
}
|