@agentuity/frontend 1.0.1 → 1.0.3

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.
@@ -0,0 +1,1223 @@
1
+ import { createReconnectManager } from './reconnect';
2
+ // =============================================================================
3
+ // Track Sources
4
+ // =============================================================================
5
+ /**
6
+ * User media (camera/microphone) track source.
7
+ */
8
+ export class UserMediaSource {
9
+ constraints;
10
+ type = 'user-media';
11
+ stream = null;
12
+ constructor(constraints = { video: true, audio: true }) {
13
+ this.constraints = constraints;
14
+ }
15
+ async getStream() {
16
+ this.stream = await navigator.mediaDevices.getUserMedia(this.constraints);
17
+ return this.stream;
18
+ }
19
+ stop() {
20
+ if (this.stream) {
21
+ for (const track of this.stream.getTracks()) {
22
+ track.stop();
23
+ }
24
+ this.stream = null;
25
+ }
26
+ }
27
+ }
28
+ /**
29
+ * Display media (screen share) track source.
30
+ */
31
+ export class DisplayMediaSource {
32
+ constraints;
33
+ type = 'display-media';
34
+ stream = null;
35
+ constructor(constraints = { video: true, audio: false }) {
36
+ this.constraints = constraints;
37
+ }
38
+ async getStream() {
39
+ this.stream = await navigator.mediaDevices.getDisplayMedia(this.constraints);
40
+ return this.stream;
41
+ }
42
+ stop() {
43
+ if (this.stream) {
44
+ for (const track of this.stream.getTracks()) {
45
+ track.stop();
46
+ }
47
+ this.stream = null;
48
+ }
49
+ }
50
+ }
51
+ /**
52
+ * Custom stream track source - wraps a user-provided MediaStream.
53
+ */
54
+ export class CustomStreamSource {
55
+ stream;
56
+ type = 'custom';
57
+ constructor(stream) {
58
+ this.stream = stream;
59
+ }
60
+ async getStream() {
61
+ return this.stream;
62
+ }
63
+ stop() {
64
+ for (const track of this.stream.getTracks()) {
65
+ track.stop();
66
+ }
67
+ }
68
+ }
69
+ /**
70
+ * Default ICE servers (public STUN servers)
71
+ */
72
+ const DEFAULT_ICE_SERVERS = [
73
+ { urls: 'stun:stun.l.google.com:19302' },
74
+ { urls: 'stun:stun1.l.google.com:19302' },
75
+ ];
76
+ // =============================================================================
77
+ // WebRTCManager
78
+ // =============================================================================
79
+ /**
80
+ * Framework-agnostic WebRTC connection manager with multi-peer mesh networking,
81
+ * perfect negotiation, media/data channel handling, and screen sharing.
82
+ *
83
+ * Uses an explicit state machine for connection lifecycle:
84
+ * - idle: No resources allocated, ready to connect
85
+ * - connecting: Acquiring media + opening WebSocket
86
+ * - signaling: In room, waiting for peer(s)
87
+ * - negotiating: SDP/ICE exchange in progress with at least one peer
88
+ * - connected: At least one peer is connected
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * const manager = new WebRTCManager({
93
+ * signalUrl: 'wss://example.com/call/signal',
94
+ * roomId: 'my-room',
95
+ * callbacks: {
96
+ * onStateChange: (from, to, reason) => console.log(`${from} → ${to}`, reason),
97
+ * onConnect: () => console.log('Connected!'),
98
+ * onRemoteStream: (peerId, stream) => { remoteVideos[peerId].srcObject = stream; },
99
+ * },
100
+ * });
101
+ *
102
+ * await manager.connect();
103
+ * ```
104
+ */
105
+ export class WebRTCManager {
106
+ ws = null;
107
+ localStream = null;
108
+ trackSource = null;
109
+ previousVideoTrack = null;
110
+ peerId = null;
111
+ peers = new Map();
112
+ isAudioMuted = false;
113
+ isVideoMuted = false;
114
+ isScreenSharing = false;
115
+ _state = 'idle';
116
+ isConnecting = false;
117
+ basePolite;
118
+ options;
119
+ callbacks;
120
+ reconnectManager;
121
+ isReconnecting = false;
122
+ intentionalClose = false;
123
+ connectionTimeoutId = null;
124
+ recordings = new Map();
125
+ constructor(options) {
126
+ this.options = {
127
+ ...options,
128
+ autoReconnect: options.autoReconnect ?? true,
129
+ maxReconnectAttempts: options.maxReconnectAttempts ?? 5,
130
+ connectionTimeout: options.connectionTimeout ?? 30000,
131
+ iceGatheringTimeout: options.iceGatheringTimeout ?? 10000,
132
+ };
133
+ this.basePolite = options.polite;
134
+ this.callbacks = options.callbacks ?? {};
135
+ this.reconnectManager = createReconnectManager({
136
+ onReconnect: () => {
137
+ void this.reconnect();
138
+ },
139
+ baseDelay: 1000,
140
+ factor: 2,
141
+ maxDelay: 30000,
142
+ jitter: 0,
143
+ enabled: () => this.shouldAutoReconnect(),
144
+ });
145
+ }
146
+ /**
147
+ * Current connection state
148
+ */
149
+ get state() {
150
+ return this._state;
151
+ }
152
+ /**
153
+ * Get current manager state
154
+ */
155
+ getState() {
156
+ return {
157
+ state: this._state,
158
+ peerId: this.peerId,
159
+ remotePeerIds: Array.from(this.peers.keys()),
160
+ isAudioMuted: this.isAudioMuted,
161
+ isVideoMuted: this.isVideoMuted,
162
+ isScreenSharing: this.isScreenSharing,
163
+ };
164
+ }
165
+ /**
166
+ * Get local media stream
167
+ */
168
+ getLocalStream() {
169
+ return this.localStream;
170
+ }
171
+ /**
172
+ * Get remote media streams keyed by peer ID
173
+ */
174
+ getRemoteStreams() {
175
+ const streams = new Map();
176
+ for (const [peerId, session] of this.peers) {
177
+ if (session.remoteStream) {
178
+ streams.set(peerId, session.remoteStream);
179
+ }
180
+ }
181
+ return streams;
182
+ }
183
+ /**
184
+ * Get a specific peer's remote stream
185
+ */
186
+ getRemoteStream(peerId) {
187
+ return this.peers.get(peerId)?.remoteStream ?? null;
188
+ }
189
+ /**
190
+ * Whether this manager is in data-only mode (no media streams).
191
+ */
192
+ get isDataOnly() {
193
+ return this.options.media === false;
194
+ }
195
+ /**
196
+ * Get connected peer count
197
+ */
198
+ get peerCount() {
199
+ return this.peers.size;
200
+ }
201
+ // =========================================================================
202
+ // State Machine
203
+ // =========================================================================
204
+ setState(newState, reason) {
205
+ const prevState = this._state;
206
+ if (prevState === newState)
207
+ return;
208
+ this._state = newState;
209
+ this.handleStateTimeouts(newState);
210
+ this.callbacks.onStateChange?.(prevState, newState, reason);
211
+ if (newState === 'connected' && prevState !== 'connected') {
212
+ this.callbacks.onConnect?.();
213
+ }
214
+ if (newState === 'idle' && prevState !== 'idle') {
215
+ const disconnectReason = this.mapToDisconnectReason(reason);
216
+ this.callbacks.onDisconnect?.(disconnectReason);
217
+ }
218
+ }
219
+ mapToDisconnectReason(reason) {
220
+ if (reason === 'hangup')
221
+ return 'hangup';
222
+ if (reason === 'peer-left')
223
+ return 'peer-left';
224
+ if (reason?.includes('timeout'))
225
+ return 'timeout';
226
+ return 'error';
227
+ }
228
+ handleStateTimeouts(state) {
229
+ if (state === 'connecting' || state === 'negotiating') {
230
+ this.startConnectionTimeout();
231
+ return;
232
+ }
233
+ this.clearConnectionTimeout();
234
+ }
235
+ startConnectionTimeout() {
236
+ this.clearConnectionTimeout();
237
+ const timeoutMs = this.options.connectionTimeout ?? 30000;
238
+ this.connectionTimeoutId = setTimeout(() => {
239
+ if (this._state === 'connecting' || this._state === 'negotiating') {
240
+ const error = new Error('WebRTC connection timed out');
241
+ this.callbacks.onError?.(error, this._state);
242
+ this.handleTimeout('connection-timeout');
243
+ }
244
+ }, timeoutMs);
245
+ }
246
+ clearConnectionTimeout() {
247
+ if (this.connectionTimeoutId) {
248
+ clearTimeout(this.connectionTimeoutId);
249
+ this.connectionTimeoutId = null;
250
+ }
251
+ }
252
+ handleTimeout(reason) {
253
+ this.intentionalClose = true;
254
+ this.cleanupPeerSessions();
255
+ if (this.ws) {
256
+ this.ws.close();
257
+ this.ws = null;
258
+ }
259
+ this.peerId = null;
260
+ this.setState('idle', reason);
261
+ this.intentionalClose = false;
262
+ }
263
+ shouldAutoReconnect() {
264
+ return (this.options.autoReconnect ?? true) && !this.intentionalClose;
265
+ }
266
+ updateConnectionState() {
267
+ const connectedPeers = Array.from(this.peers.values()).filter((p) => p.pc.iceConnectionState === 'connected' || p.pc.iceConnectionState === 'completed');
268
+ if (connectedPeers.length > 0) {
269
+ if (this._state !== 'connected') {
270
+ this.setState('connected', 'peer connected');
271
+ }
272
+ }
273
+ else if (this.peers.size > 0) {
274
+ if (this._state === 'connected') {
275
+ this.setState('negotiating', 'no connected peers');
276
+ }
277
+ }
278
+ else if (this._state === 'connected' || this._state === 'negotiating') {
279
+ this.setState('signaling', 'all peers left');
280
+ }
281
+ }
282
+ send(msg) {
283
+ if (this.ws?.readyState === WebSocket.OPEN) {
284
+ this.ws.send(JSON.stringify(msg));
285
+ }
286
+ }
287
+ // =========================================================================
288
+ // Connection
289
+ // =========================================================================
290
+ /**
291
+ * Connect to the signaling server and start the call
292
+ */
293
+ async connect() {
294
+ if (this._state !== 'idle' || this.isConnecting)
295
+ return;
296
+ this.isConnecting = true;
297
+ this.intentionalClose = false;
298
+ this.reconnectManager.reset();
299
+ this.setState('connecting', 'connect() called');
300
+ try {
301
+ await this.ensureLocalStream();
302
+ this.openWebSocket();
303
+ }
304
+ catch (err) {
305
+ // Clean up local media on failure
306
+ if (this.localStream) {
307
+ for (const track of this.localStream.getTracks()) {
308
+ track.stop();
309
+ }
310
+ this.localStream = null;
311
+ }
312
+ if (this.trackSource) {
313
+ this.trackSource.stop();
314
+ this.trackSource = null;
315
+ }
316
+ const error = err instanceof Error ? err : new Error(String(err));
317
+ this.callbacks.onError?.(error, this._state);
318
+ this.isConnecting = false;
319
+ this.setState('idle', 'error');
320
+ }
321
+ finally {
322
+ this.isConnecting = false;
323
+ }
324
+ }
325
+ async ensureLocalStream() {
326
+ if (this.options.media === false || this.localStream)
327
+ return;
328
+ if (this.options.media &&
329
+ typeof this.options.media === 'object' &&
330
+ 'getStream' in this.options.media) {
331
+ this.trackSource = this.options.media;
332
+ }
333
+ else {
334
+ const constraints = this.options.media ?? {
335
+ video: true,
336
+ audio: true,
337
+ };
338
+ this.trackSource = new UserMediaSource(constraints);
339
+ }
340
+ this.localStream = await this.trackSource.getStream();
341
+ this.callbacks.onLocalStream?.(this.localStream);
342
+ }
343
+ openWebSocket() {
344
+ if (this.ws) {
345
+ const previous = this.ws;
346
+ this.ws = null;
347
+ previous.onclose = null;
348
+ previous.onerror = null;
349
+ previous.onmessage = null;
350
+ previous.onopen = null;
351
+ previous.close();
352
+ }
353
+ this.ws = new WebSocket(this.options.signalUrl);
354
+ this.ws.onopen = () => {
355
+ this.setState('signaling', 'WebSocket opened');
356
+ this.send({ t: 'join', roomId: this.options.roomId });
357
+ if (this.isReconnecting) {
358
+ this.isReconnecting = false;
359
+ this.reconnectManager.recordSuccess();
360
+ this.callbacks.onReconnected?.();
361
+ }
362
+ };
363
+ this.ws.onmessage = (event) => {
364
+ try {
365
+ const msg = JSON.parse(event.data);
366
+ void this.handleSignalingMessage(msg).catch((err) => {
367
+ this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err)), this._state);
368
+ });
369
+ }
370
+ catch (_err) {
371
+ this.callbacks.onError?.(new Error('Invalid signaling message'), this._state);
372
+ }
373
+ };
374
+ this.ws.onerror = () => {
375
+ const error = new Error('WebSocket connection error');
376
+ this.callbacks.onError?.(error, this._state);
377
+ };
378
+ this.ws.onclose = () => {
379
+ if (this._state === 'idle')
380
+ return;
381
+ if (this.intentionalClose) {
382
+ this.setState('idle', 'WebSocket closed');
383
+ return;
384
+ }
385
+ this.handleConnectionLoss('WebSocket closed');
386
+ };
387
+ }
388
+ handleConnectionLoss(reason) {
389
+ this.cleanupPeerSessions();
390
+ this.peerId = null;
391
+ if (this.shouldAutoReconnect()) {
392
+ this.scheduleReconnect(reason);
393
+ }
394
+ else {
395
+ this.setState('idle', reason);
396
+ }
397
+ }
398
+ scheduleReconnect(reason) {
399
+ const nextAttempt = this.reconnectManager.getAttempts() + 1;
400
+ const maxAttempts = this.options.maxReconnectAttempts ?? 5;
401
+ if (nextAttempt > maxAttempts) {
402
+ this.callbacks.onReconnectFailed?.();
403
+ this.setState('idle', 'reconnect-failed');
404
+ return;
405
+ }
406
+ this.isReconnecting = true;
407
+ this.callbacks.onReconnecting?.(nextAttempt);
408
+ this.setState('connecting', `reconnecting:${reason}`);
409
+ this.reconnectManager.recordFailure();
410
+ }
411
+ async reconnect() {
412
+ if (!this.shouldAutoReconnect())
413
+ return;
414
+ this.cleanupPeerSessions();
415
+ this.peerId = null;
416
+ try {
417
+ await this.ensureLocalStream();
418
+ this.openWebSocket();
419
+ }
420
+ catch (err) {
421
+ const error = err instanceof Error ? err : new Error(String(err));
422
+ this.callbacks.onError?.(error, this._state);
423
+ this.scheduleReconnect('reconnect-error');
424
+ }
425
+ }
426
+ async handleSignalingMessage(msg) {
427
+ switch (msg.t) {
428
+ case 'joined':
429
+ this.peerId = msg.peerId;
430
+ for (const existingPeerId of msg.peers) {
431
+ await this.createPeerSession(existingPeerId, true);
432
+ }
433
+ break;
434
+ case 'peer-joined':
435
+ this.callbacks.onPeerJoined?.(msg.peerId);
436
+ await this.createPeerSession(msg.peerId, false);
437
+ break;
438
+ case 'peer-left':
439
+ this.callbacks.onPeerLeft?.(msg.peerId);
440
+ this.closePeerSession(msg.peerId);
441
+ this.updateConnectionState();
442
+ break;
443
+ case 'sdp':
444
+ await this.handleRemoteSDP(msg.from, msg.description);
445
+ break;
446
+ case 'ice':
447
+ await this.handleRemoteICE(msg.from, msg.candidate);
448
+ break;
449
+ case 'error': {
450
+ const error = new Error(msg.message);
451
+ this.callbacks.onError?.(error, this._state);
452
+ break;
453
+ }
454
+ }
455
+ }
456
+ // =========================================================================
457
+ // Peer Session Management
458
+ // =========================================================================
459
+ async createPeerSession(remotePeerId, isOfferer) {
460
+ if (this.peers.has(remotePeerId)) {
461
+ return this.peers.get(remotePeerId);
462
+ }
463
+ const iceServers = this.options.iceServers ?? DEFAULT_ICE_SERVERS;
464
+ const pc = new RTCPeerConnection({ iceServers });
465
+ const session = {
466
+ peerId: remotePeerId,
467
+ pc,
468
+ remoteStream: null,
469
+ dataChannels: new Map(),
470
+ makingOffer: false,
471
+ ignoreOffer: false,
472
+ hasRemoteDescription: false,
473
+ pendingCandidates: [],
474
+ isOfferer,
475
+ negotiationStarted: false,
476
+ hasIceCandidate: false,
477
+ iceGatheringTimer: null,
478
+ };
479
+ this.peers.set(remotePeerId, session);
480
+ if (this.localStream) {
481
+ for (const track of this.localStream.getTracks()) {
482
+ pc.addTrack(track, this.localStream);
483
+ }
484
+ }
485
+ pc.ontrack = (event) => {
486
+ if (event.streams?.[0]) {
487
+ if (session.remoteStream !== event.streams[0]) {
488
+ session.remoteStream = event.streams[0];
489
+ this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
490
+ }
491
+ }
492
+ else {
493
+ if (!session.remoteStream) {
494
+ session.remoteStream = new MediaStream([event.track]);
495
+ this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
496
+ }
497
+ else {
498
+ session.remoteStream.addTrack(event.track);
499
+ this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
500
+ }
501
+ }
502
+ this.callbacks.onTrackAdded?.(remotePeerId, event.track, session.remoteStream);
503
+ this.updateConnectionState();
504
+ };
505
+ pc.ondatachannel = (event) => {
506
+ this.setupDataChannel(session, event.channel);
507
+ };
508
+ pc.onicecandidate = (event) => {
509
+ if (event.candidate) {
510
+ session.hasIceCandidate = true;
511
+ if (session.iceGatheringTimer) {
512
+ clearTimeout(session.iceGatheringTimer);
513
+ session.iceGatheringTimer = null;
514
+ }
515
+ this.callbacks.onIceCandidate?.(remotePeerId, event.candidate.toJSON());
516
+ this.send({
517
+ t: 'ice',
518
+ from: this.peerId,
519
+ to: remotePeerId,
520
+ candidate: event.candidate.toJSON(),
521
+ });
522
+ }
523
+ };
524
+ this.scheduleIceGatheringTimeout(session);
525
+ pc.onnegotiationneeded = async () => {
526
+ // If we're not the offerer and haven't received a remote description yet,
527
+ // don't send an offer - wait for the other peer's offer
528
+ if (!session.isOfferer && !session.hasRemoteDescription && !session.negotiationStarted) {
529
+ return;
530
+ }
531
+ try {
532
+ session.makingOffer = true;
533
+ await pc.setLocalDescription();
534
+ this.send({
535
+ t: 'sdp',
536
+ from: this.peerId,
537
+ to: remotePeerId,
538
+ description: pc.localDescription,
539
+ });
540
+ }
541
+ catch (err) {
542
+ const error = err instanceof Error ? err : new Error(String(err));
543
+ this.callbacks.onError?.(error, this._state);
544
+ }
545
+ finally {
546
+ session.makingOffer = false;
547
+ }
548
+ };
549
+ pc.oniceconnectionstatechange = () => {
550
+ const iceState = pc.iceConnectionState;
551
+ this.callbacks.onIceStateChange?.(remotePeerId, iceState);
552
+ this.updateConnectionState();
553
+ if (iceState === 'failed') {
554
+ const error = new Error(`ICE connection failed for peer ${remotePeerId}`);
555
+ this.callbacks.onError?.(error, this._state);
556
+ this.handleConnectionLoss('ice-failed');
557
+ }
558
+ };
559
+ if (isOfferer) {
560
+ if (this.options.dataChannels) {
561
+ for (const config of this.options.dataChannels) {
562
+ const channel = pc.createDataChannel(config.label, {
563
+ ordered: config.ordered ?? true,
564
+ maxPacketLifeTime: config.maxPacketLifeTime,
565
+ maxRetransmits: config.maxRetransmits,
566
+ protocol: config.protocol,
567
+ });
568
+ this.setupDataChannel(session, channel);
569
+ }
570
+ }
571
+ this.setState('negotiating', 'creating offer');
572
+ this.callbacks.onNegotiationStart?.(remotePeerId);
573
+ await this.createOffer(session);
574
+ }
575
+ return session;
576
+ }
577
+ async createOffer(session) {
578
+ try {
579
+ session.makingOffer = true;
580
+ session.negotiationStarted = true;
581
+ const offer = await session.pc.createOffer();
582
+ await session.pc.setLocalDescription(offer);
583
+ this.send({
584
+ t: 'sdp',
585
+ from: this.peerId,
586
+ to: session.peerId,
587
+ description: session.pc.localDescription,
588
+ });
589
+ }
590
+ finally {
591
+ session.makingOffer = false;
592
+ }
593
+ }
594
+ async handleRemoteSDP(fromPeerId, description) {
595
+ let session = this.peers.get(fromPeerId);
596
+ if (!session) {
597
+ session = await this.createPeerSession(fromPeerId, false);
598
+ }
599
+ const pc = session.pc;
600
+ const isOffer = description.type === 'offer';
601
+ const polite = this.basePolite ?? !this.isOffererFor(fromPeerId);
602
+ const offerCollision = isOffer && (session.makingOffer || pc.signalingState !== 'stable');
603
+ session.ignoreOffer = !polite && offerCollision;
604
+ if (session.ignoreOffer)
605
+ return;
606
+ if (this._state === 'signaling') {
607
+ this.setState('negotiating', 'received SDP');
608
+ this.callbacks.onNegotiationStart?.(fromPeerId);
609
+ }
610
+ await pc.setRemoteDescription(description);
611
+ session.hasRemoteDescription = true;
612
+ for (const candidate of session.pendingCandidates) {
613
+ try {
614
+ await pc.addIceCandidate(candidate);
615
+ }
616
+ catch (err) {
617
+ if (!session.ignoreOffer) {
618
+ console.warn('Failed to add buffered ICE candidate:', err);
619
+ }
620
+ }
621
+ }
622
+ session.pendingCandidates = [];
623
+ if (isOffer) {
624
+ session.negotiationStarted = true;
625
+ await pc.setLocalDescription();
626
+ this.send({
627
+ t: 'sdp',
628
+ from: this.peerId,
629
+ to: fromPeerId,
630
+ description: pc.localDescription,
631
+ });
632
+ }
633
+ this.callbacks.onNegotiationComplete?.(fromPeerId);
634
+ }
635
+ isOffererFor(remotePeerId) {
636
+ return this.peerId > remotePeerId;
637
+ }
638
+ async handleRemoteICE(fromPeerId, candidate) {
639
+ const session = this.peers.get(fromPeerId);
640
+ if (!session || !session.hasRemoteDescription) {
641
+ if (session) {
642
+ session.pendingCandidates.push(candidate);
643
+ }
644
+ return;
645
+ }
646
+ try {
647
+ await session.pc.addIceCandidate(candidate);
648
+ }
649
+ catch (err) {
650
+ if (!session.ignoreOffer) {
651
+ console.warn('Failed to add ICE candidate:', err);
652
+ }
653
+ }
654
+ }
655
+ closePeerSession(peerId) {
656
+ const session = this.peers.get(peerId);
657
+ if (!session)
658
+ return;
659
+ // Clear ICE gathering timer if exists
660
+ if (session.iceGatheringTimer) {
661
+ clearTimeout(session.iceGatheringTimer);
662
+ session.iceGatheringTimer = null;
663
+ }
664
+ // Close data channels
665
+ for (const channel of session.dataChannels.values()) {
666
+ channel.close();
667
+ }
668
+ session.dataChannels.clear();
669
+ // Clear all event handlers before closing to prevent memory leaks
670
+ const pc = session.pc;
671
+ pc.ontrack = null;
672
+ pc.ondatachannel = null;
673
+ pc.onicecandidate = null;
674
+ pc.onnegotiationneeded = null;
675
+ pc.oniceconnectionstatechange = null;
676
+ pc.close();
677
+ this.peers.delete(peerId);
678
+ }
679
+ cleanupPeerSessions() {
680
+ for (const peerId of this.peers.keys()) {
681
+ this.closePeerSession(peerId);
682
+ }
683
+ this.peers.clear();
684
+ }
685
+ scheduleIceGatheringTimeout(session) {
686
+ const timeoutMs = this.options.iceGatheringTimeout ?? 10000;
687
+ if (timeoutMs <= 0)
688
+ return;
689
+ if (session.iceGatheringTimer) {
690
+ clearTimeout(session.iceGatheringTimer);
691
+ }
692
+ session.iceGatheringTimer = setTimeout(() => {
693
+ if (!session.hasIceCandidate) {
694
+ console.warn(`ICE gathering timeout for peer ${session.peerId}`);
695
+ }
696
+ }, timeoutMs);
697
+ }
698
+ // =========================================================================
699
+ // Data Channel
700
+ // =========================================================================
701
+ setupDataChannel(session, channel) {
702
+ const label = channel.label;
703
+ const peerId = session.peerId;
704
+ session.dataChannels.set(label, channel);
705
+ channel.onopen = () => {
706
+ this.callbacks.onDataChannelOpen?.(peerId, label);
707
+ if (this.isDataOnly && this._state !== 'connected') {
708
+ this.updateConnectionState();
709
+ }
710
+ };
711
+ channel.onclose = () => {
712
+ session.dataChannels.delete(label);
713
+ this.callbacks.onDataChannelClose?.(peerId, label);
714
+ };
715
+ channel.onmessage = (event) => {
716
+ const data = event.data;
717
+ if (typeof data === 'string') {
718
+ try {
719
+ const parsed = JSON.parse(data);
720
+ this.callbacks.onDataChannelMessage?.(peerId, label, parsed);
721
+ }
722
+ catch {
723
+ this.callbacks.onDataChannelMessage?.(peerId, label, data);
724
+ }
725
+ }
726
+ else {
727
+ this.callbacks.onDataChannelMessage?.(peerId, label, data);
728
+ }
729
+ };
730
+ channel.onerror = (event) => {
731
+ const error = event instanceof ErrorEvent
732
+ ? new Error(event.message)
733
+ : new Error('Data channel error');
734
+ this.callbacks.onDataChannelError?.(peerId, label, error);
735
+ };
736
+ }
737
+ /**
738
+ * Create a new data channel to all connected peers.
739
+ */
740
+ createDataChannel(config) {
741
+ const channels = new Map();
742
+ for (const [peerId, session] of this.peers) {
743
+ const channel = session.pc.createDataChannel(config.label, {
744
+ ordered: config.ordered ?? true,
745
+ maxPacketLifeTime: config.maxPacketLifeTime,
746
+ maxRetransmits: config.maxRetransmits,
747
+ protocol: config.protocol,
748
+ });
749
+ this.setupDataChannel(session, channel);
750
+ channels.set(peerId, channel);
751
+ }
752
+ return channels;
753
+ }
754
+ /**
755
+ * Get a data channel by label from a specific peer.
756
+ */
757
+ getDataChannel(peerId, label) {
758
+ return this.peers.get(peerId)?.dataChannels.get(label);
759
+ }
760
+ /**
761
+ * Get all open data channel labels.
762
+ */
763
+ getDataChannelLabels() {
764
+ const labels = new Set();
765
+ for (const session of this.peers.values()) {
766
+ for (const label of session.dataChannels.keys()) {
767
+ labels.add(label);
768
+ }
769
+ }
770
+ return Array.from(labels);
771
+ }
772
+ /**
773
+ * Get the state of a data channel for a specific peer.
774
+ */
775
+ getDataChannelState(peerId, label) {
776
+ const channel = this.peers.get(peerId)?.dataChannels.get(label);
777
+ return channel ? channel.readyState : null;
778
+ }
779
+ /**
780
+ * Send a string message to all peers on a data channel.
781
+ */
782
+ sendString(label, data) {
783
+ let sent = false;
784
+ for (const session of this.peers.values()) {
785
+ const channel = session.dataChannels.get(label);
786
+ if (channel?.readyState === 'open') {
787
+ channel.send(data);
788
+ sent = true;
789
+ }
790
+ }
791
+ return sent;
792
+ }
793
+ /**
794
+ * Send a string message to a specific peer.
795
+ */
796
+ sendStringTo(peerId, label, data) {
797
+ const channel = this.peers.get(peerId)?.dataChannels.get(label);
798
+ if (!channel || channel.readyState !== 'open')
799
+ return false;
800
+ channel.send(data);
801
+ return true;
802
+ }
803
+ /**
804
+ * Send binary data to all peers on a data channel.
805
+ */
806
+ sendBinary(label, data) {
807
+ let sent = false;
808
+ const buffer = data instanceof ArrayBuffer
809
+ ? data
810
+ : (() => {
811
+ const buf = new ArrayBuffer(data.byteLength);
812
+ new Uint8Array(buf).set(data);
813
+ return buf;
814
+ })();
815
+ for (const session of this.peers.values()) {
816
+ const channel = session.dataChannels.get(label);
817
+ if (channel?.readyState === 'open') {
818
+ channel.send(buffer);
819
+ sent = true;
820
+ }
821
+ }
822
+ return sent;
823
+ }
824
+ /**
825
+ * Send binary data to a specific peer.
826
+ */
827
+ sendBinaryTo(peerId, label, data) {
828
+ const channel = this.peers.get(peerId)?.dataChannels.get(label);
829
+ if (!channel || channel.readyState !== 'open')
830
+ return false;
831
+ if (data instanceof ArrayBuffer) {
832
+ channel.send(data);
833
+ }
834
+ else {
835
+ const buffer = new ArrayBuffer(data.byteLength);
836
+ new Uint8Array(buffer).set(data);
837
+ channel.send(buffer);
838
+ }
839
+ return true;
840
+ }
841
+ /**
842
+ * Send JSON data to all peers on a data channel.
843
+ */
844
+ sendJSON(label, data) {
845
+ return this.sendString(label, JSON.stringify(data));
846
+ }
847
+ /**
848
+ * Send JSON data to a specific peer.
849
+ */
850
+ sendJSONTo(peerId, label, data) {
851
+ return this.sendStringTo(peerId, label, JSON.stringify(data));
852
+ }
853
+ /**
854
+ * Close a specific data channel on all peers.
855
+ */
856
+ closeDataChannel(label) {
857
+ let closed = false;
858
+ for (const session of this.peers.values()) {
859
+ const channel = session.dataChannels.get(label);
860
+ if (channel) {
861
+ channel.close();
862
+ session.dataChannels.delete(label);
863
+ closed = true;
864
+ }
865
+ }
866
+ return closed;
867
+ }
868
+ // =========================================================================
869
+ // Media Controls
870
+ // =========================================================================
871
+ /**
872
+ * Mute or unmute audio
873
+ */
874
+ muteAudio(muted) {
875
+ if (this.localStream) {
876
+ for (const track of this.localStream.getAudioTracks()) {
877
+ track.enabled = !muted;
878
+ }
879
+ }
880
+ this.isAudioMuted = muted;
881
+ }
882
+ /**
883
+ * Mute or unmute video
884
+ */
885
+ muteVideo(muted) {
886
+ if (this.localStream) {
887
+ for (const track of this.localStream.getVideoTracks()) {
888
+ track.enabled = !muted;
889
+ }
890
+ }
891
+ this.isVideoMuted = muted;
892
+ }
893
+ // =========================================================================
894
+ // Screen Sharing
895
+ // =========================================================================
896
+ /**
897
+ * Start screen sharing, replacing the current video track.
898
+ * @param options - Display media constraints
899
+ */
900
+ async startScreenShare(options = { video: true, audio: false }) {
901
+ if (this.isScreenSharing || this.isDataOnly)
902
+ return;
903
+ const screenStream = await navigator.mediaDevices.getDisplayMedia(options);
904
+ const screenTrack = screenStream.getVideoTracks()[0];
905
+ if (!screenTrack) {
906
+ throw new Error('Failed to get screen video track');
907
+ }
908
+ if (this.localStream) {
909
+ const currentVideoTrack = this.localStream.getVideoTracks()[0];
910
+ if (currentVideoTrack) {
911
+ this.previousVideoTrack = currentVideoTrack;
912
+ this.localStream.removeTrack(currentVideoTrack);
913
+ }
914
+ this.localStream.addTrack(screenTrack);
915
+ }
916
+ for (const session of this.peers.values()) {
917
+ const sender = session.pc.getSenders().find((s) => s.track?.kind === 'video');
918
+ if (sender) {
919
+ await sender.replaceTrack(screenTrack);
920
+ }
921
+ else {
922
+ session.pc.addTrack(screenTrack, this.localStream);
923
+ }
924
+ }
925
+ screenTrack.onended = () => {
926
+ this.stopScreenShare();
927
+ };
928
+ this.isScreenSharing = true;
929
+ this.callbacks.onScreenShareStart?.();
930
+ }
931
+ /**
932
+ * Stop screen sharing and restore the previous video track.
933
+ */
934
+ async stopScreenShare() {
935
+ if (!this.isScreenSharing)
936
+ return;
937
+ const screenTrack = this.localStream?.getVideoTracks()[0];
938
+ if (screenTrack) {
939
+ screenTrack.stop();
940
+ this.localStream?.removeTrack(screenTrack);
941
+ }
942
+ if (this.previousVideoTrack && this.localStream) {
943
+ this.localStream.addTrack(this.previousVideoTrack);
944
+ for (const session of this.peers.values()) {
945
+ const sender = session.pc.getSenders().find((s) => s.track?.kind === 'video');
946
+ if (sender) {
947
+ await sender.replaceTrack(this.previousVideoTrack);
948
+ }
949
+ }
950
+ }
951
+ this.previousVideoTrack = null;
952
+ this.isScreenSharing = false;
953
+ this.callbacks.onScreenShareStop?.();
954
+ }
955
+ // =========================================================================
956
+ // Connection Stats
957
+ // =========================================================================
958
+ /**
959
+ * Get raw stats for a peer connection.
960
+ */
961
+ async getRawStats(peerId) {
962
+ const session = this.peers.get(peerId);
963
+ if (!session)
964
+ return null;
965
+ return session.pc.getStats();
966
+ }
967
+ /**
968
+ * Get raw stats for all peer connections.
969
+ */
970
+ async getAllRawStats() {
971
+ const stats = new Map();
972
+ for (const [peerId, session] of this.peers) {
973
+ stats.set(peerId, await session.pc.getStats());
974
+ }
975
+ return stats;
976
+ }
977
+ /**
978
+ * Get a normalized quality summary for a peer connection.
979
+ */
980
+ async getQualitySummary(peerId) {
981
+ const session = this.peers.get(peerId);
982
+ if (!session)
983
+ return null;
984
+ const stats = await session.pc.getStats();
985
+ return this.parseStatsToSummary(stats, session);
986
+ }
987
+ /**
988
+ * Get quality summaries for all peer connections.
989
+ */
990
+ async getAllQualitySummaries() {
991
+ const summaries = new Map();
992
+ for (const [peerId, session] of this.peers) {
993
+ const stats = await session.pc.getStats();
994
+ summaries.set(peerId, this.parseStatsToSummary(stats, session));
995
+ }
996
+ return summaries;
997
+ }
998
+ parseStatsToSummary(stats, session) {
999
+ const summary = { timestamp: Date.now() };
1000
+ const now = Date.now();
1001
+ const prevStats = session.lastStats;
1002
+ const prevTime = session.lastStatsTime ?? now;
1003
+ const timeDelta = (now - prevTime) / 1000;
1004
+ const bitrate = {};
1005
+ stats.forEach((report) => {
1006
+ if (report.type === 'candidate-pair' && report.state === 'succeeded') {
1007
+ summary.rtt = report.currentRoundTripTime
1008
+ ? report.currentRoundTripTime * 1000
1009
+ : undefined;
1010
+ const localCandidateId = report.localCandidateId;
1011
+ const remoteCandidateId = report.remoteCandidateId;
1012
+ const localCandidate = this.getStatReport(stats, localCandidateId);
1013
+ const remoteCandidate = this.getStatReport(stats, remoteCandidateId);
1014
+ summary.candidatePair = {
1015
+ localType: localCandidate?.candidateType,
1016
+ remoteType: remoteCandidate?.candidateType,
1017
+ protocol: localCandidate?.protocol,
1018
+ usingRelay: localCandidate?.candidateType === 'relay' ||
1019
+ remoteCandidate?.candidateType === 'relay',
1020
+ };
1021
+ }
1022
+ if (report.type === 'inbound-rtp' && report.kind === 'audio') {
1023
+ summary.jitter = report.jitter ? report.jitter * 1000 : undefined;
1024
+ if (report.packetsLost !== undefined && report.packetsReceived !== undefined) {
1025
+ const total = report.packetsLost + report.packetsReceived;
1026
+ if (total > 0) {
1027
+ summary.packetLossPercent = (report.packetsLost / total) * 100;
1028
+ }
1029
+ }
1030
+ if (prevStats && timeDelta > 0) {
1031
+ const prev = this.findMatchingReport(prevStats, report.id);
1032
+ if (prev?.bytesReceived !== undefined && report.bytesReceived !== undefined) {
1033
+ bitrate.audio = bitrate.audio ?? {};
1034
+ bitrate.audio.inbound =
1035
+ ((report.bytesReceived - prev.bytesReceived) * 8) / timeDelta;
1036
+ }
1037
+ }
1038
+ }
1039
+ if (report.type === 'inbound-rtp' && report.kind === 'video') {
1040
+ summary.video = {
1041
+ framesPerSecond: report.framesPerSecond,
1042
+ framesDropped: report.framesDropped,
1043
+ frameWidth: report.frameWidth,
1044
+ frameHeight: report.frameHeight,
1045
+ };
1046
+ if (prevStats && timeDelta > 0) {
1047
+ const prev = this.findMatchingReport(prevStats, report.id);
1048
+ if (prev?.bytesReceived !== undefined && report.bytesReceived !== undefined) {
1049
+ bitrate.video = bitrate.video ?? {};
1050
+ bitrate.video.inbound =
1051
+ ((report.bytesReceived - prev.bytesReceived) * 8) / timeDelta;
1052
+ }
1053
+ }
1054
+ }
1055
+ if (report.type === 'outbound-rtp' && prevStats && timeDelta > 0) {
1056
+ const prev = this.findMatchingReport(prevStats, report.id);
1057
+ if (prev?.bytesSent !== undefined && report.bytesSent !== undefined) {
1058
+ const bps = ((report.bytesSent - prev.bytesSent) * 8) / timeDelta;
1059
+ if (report.kind === 'audio') {
1060
+ bitrate.audio = bitrate.audio ?? {};
1061
+ bitrate.audio.outbound = bps;
1062
+ }
1063
+ else if (report.kind === 'video') {
1064
+ bitrate.video = bitrate.video ?? {};
1065
+ bitrate.video.outbound = bps;
1066
+ }
1067
+ }
1068
+ }
1069
+ });
1070
+ if (Object.keys(bitrate).length > 0) {
1071
+ summary.bitrate = bitrate;
1072
+ }
1073
+ session.lastStats = stats;
1074
+ session.lastStatsTime = now;
1075
+ return summary;
1076
+ }
1077
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1078
+ findMatchingReport(stats, id) {
1079
+ return this.getStatReport(stats, id);
1080
+ }
1081
+ // RTCStatsReport extends Map but bun-types may not expose .get() properly
1082
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1083
+ getStatReport(stats, id) {
1084
+ // Use Map.prototype.get via cast
1085
+ const mapLike = stats;
1086
+ return mapLike.get(id);
1087
+ }
1088
+ // =========================================================================
1089
+ // Recording
1090
+ // =========================================================================
1091
+ /**
1092
+ * Start recording a stream.
1093
+ * @param streamId - 'local' for local stream, or a peer ID for remote stream
1094
+ * @param options - Recording options
1095
+ */
1096
+ startRecording(streamId, options) {
1097
+ const stream = streamId === 'local' ? this.localStream : this.getRemoteStream(streamId);
1098
+ if (!stream)
1099
+ return null;
1100
+ const mimeType = this.selectMimeType(stream, options?.mimeType);
1101
+ if (!mimeType)
1102
+ return null;
1103
+ const recorder = new MediaRecorder(stream, {
1104
+ mimeType,
1105
+ audioBitsPerSecond: options?.audioBitsPerSecond,
1106
+ videoBitsPerSecond: options?.videoBitsPerSecond,
1107
+ });
1108
+ const chunks = [];
1109
+ recorder.ondataavailable = (event) => {
1110
+ if (event.data.size > 0) {
1111
+ chunks.push(event.data);
1112
+ }
1113
+ };
1114
+ this.recordings.set(streamId, { recorder, chunks });
1115
+ recorder.start(1000);
1116
+ return {
1117
+ stop: () => new Promise((resolve) => {
1118
+ recorder.onstop = () => {
1119
+ this.recordings.delete(streamId);
1120
+ resolve(new Blob(chunks, { type: mimeType }));
1121
+ };
1122
+ recorder.stop();
1123
+ }),
1124
+ pause: () => recorder.pause(),
1125
+ resume: () => recorder.resume(),
1126
+ get state() {
1127
+ return recorder.state;
1128
+ },
1129
+ };
1130
+ }
1131
+ /**
1132
+ * Check if a stream is being recorded.
1133
+ */
1134
+ isRecording(streamId) {
1135
+ const recording = this.recordings.get(streamId);
1136
+ return recording?.recorder.state === 'recording';
1137
+ }
1138
+ /**
1139
+ * Stop all recordings and return the blobs.
1140
+ */
1141
+ async stopAllRecordings() {
1142
+ const blobs = new Map();
1143
+ const promises = [];
1144
+ for (const [streamId, { recorder, chunks }] of this.recordings) {
1145
+ const mimeType = recorder.mimeType;
1146
+ promises.push(new Promise((resolve) => {
1147
+ const timeout = setTimeout(() => {
1148
+ console.warn(`Recording stop timeout for stream ${streamId}`);
1149
+ resolve(); // Don't block other recordings
1150
+ }, 5000);
1151
+ recorder.onstop = () => {
1152
+ clearTimeout(timeout);
1153
+ blobs.set(streamId, new Blob(chunks, { type: mimeType }));
1154
+ resolve();
1155
+ };
1156
+ recorder.stop();
1157
+ }));
1158
+ }
1159
+ await Promise.all(promises);
1160
+ this.recordings.clear();
1161
+ return blobs;
1162
+ }
1163
+ selectMimeType(stream, preferred) {
1164
+ if (preferred && MediaRecorder.isTypeSupported(preferred)) {
1165
+ return preferred;
1166
+ }
1167
+ const hasVideo = stream.getVideoTracks().length > 0;
1168
+ const hasAudio = stream.getAudioTracks().length > 0;
1169
+ const videoTypes = [
1170
+ 'video/webm;codecs=vp9,opus',
1171
+ 'video/webm;codecs=vp8,opus',
1172
+ 'video/webm',
1173
+ 'video/mp4',
1174
+ ];
1175
+ const audioTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg'];
1176
+ const candidates = hasVideo ? videoTypes : hasAudio ? audioTypes : [];
1177
+ for (const type of candidates) {
1178
+ if (MediaRecorder.isTypeSupported(type)) {
1179
+ return type;
1180
+ }
1181
+ }
1182
+ return null;
1183
+ }
1184
+ // =========================================================================
1185
+ // Cleanup
1186
+ // =========================================================================
1187
+ /**
1188
+ * End the call and disconnect from all peers
1189
+ */
1190
+ hangup() {
1191
+ this.intentionalClose = true;
1192
+ this.reconnectManager.cancel();
1193
+ this.clearConnectionTimeout();
1194
+ this.cleanupPeerSessions();
1195
+ if (this.isScreenSharing) {
1196
+ const screenTrack = this.localStream?.getVideoTracks()[0];
1197
+ screenTrack?.stop();
1198
+ }
1199
+ if (this.trackSource) {
1200
+ this.trackSource.stop();
1201
+ this.trackSource = null;
1202
+ }
1203
+ this.localStream = null;
1204
+ this.previousVideoTrack = null;
1205
+ if (this.ws) {
1206
+ this.ws.close();
1207
+ this.ws = null;
1208
+ }
1209
+ this.peerId = null;
1210
+ this.isScreenSharing = false;
1211
+ this.setState('idle', 'hangup');
1212
+ this.intentionalClose = false;
1213
+ }
1214
+ /**
1215
+ * Clean up all resources
1216
+ */
1217
+ dispose() {
1218
+ this.stopAllRecordings();
1219
+ this.hangup();
1220
+ this.reconnectManager.dispose();
1221
+ }
1222
+ }
1223
+ //# sourceMappingURL=webrtc-manager.js.map