@agentuity/frontend 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1221 @@
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 && typeof this.options.media === 'object' && 'getStream' in this.options.media) {
329
+ this.trackSource = this.options.media;
330
+ }
331
+ else {
332
+ const constraints = this.options.media ?? {
333
+ video: true,
334
+ audio: true,
335
+ };
336
+ this.trackSource = new UserMediaSource(constraints);
337
+ }
338
+ this.localStream = await this.trackSource.getStream();
339
+ this.callbacks.onLocalStream?.(this.localStream);
340
+ }
341
+ openWebSocket() {
342
+ if (this.ws) {
343
+ const previous = this.ws;
344
+ this.ws = null;
345
+ previous.onclose = null;
346
+ previous.onerror = null;
347
+ previous.onmessage = null;
348
+ previous.onopen = null;
349
+ previous.close();
350
+ }
351
+ this.ws = new WebSocket(this.options.signalUrl);
352
+ this.ws.onopen = () => {
353
+ this.setState('signaling', 'WebSocket opened');
354
+ this.send({ t: 'join', roomId: this.options.roomId });
355
+ if (this.isReconnecting) {
356
+ this.isReconnecting = false;
357
+ this.reconnectManager.recordSuccess();
358
+ this.callbacks.onReconnected?.();
359
+ }
360
+ };
361
+ this.ws.onmessage = (event) => {
362
+ try {
363
+ const msg = JSON.parse(event.data);
364
+ void this.handleSignalingMessage(msg).catch((err) => {
365
+ this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err)), this._state);
366
+ });
367
+ }
368
+ catch (_err) {
369
+ this.callbacks.onError?.(new Error('Invalid signaling message'), this._state);
370
+ }
371
+ };
372
+ this.ws.onerror = () => {
373
+ const error = new Error('WebSocket connection error');
374
+ this.callbacks.onError?.(error, this._state);
375
+ };
376
+ this.ws.onclose = () => {
377
+ if (this._state === 'idle')
378
+ return;
379
+ if (this.intentionalClose) {
380
+ this.setState('idle', 'WebSocket closed');
381
+ return;
382
+ }
383
+ this.handleConnectionLoss('WebSocket closed');
384
+ };
385
+ }
386
+ handleConnectionLoss(reason) {
387
+ this.cleanupPeerSessions();
388
+ this.peerId = null;
389
+ if (this.shouldAutoReconnect()) {
390
+ this.scheduleReconnect(reason);
391
+ }
392
+ else {
393
+ this.setState('idle', reason);
394
+ }
395
+ }
396
+ scheduleReconnect(reason) {
397
+ const nextAttempt = this.reconnectManager.getAttempts() + 1;
398
+ const maxAttempts = this.options.maxReconnectAttempts ?? 5;
399
+ if (nextAttempt > maxAttempts) {
400
+ this.callbacks.onReconnectFailed?.();
401
+ this.setState('idle', 'reconnect-failed');
402
+ return;
403
+ }
404
+ this.isReconnecting = true;
405
+ this.callbacks.onReconnecting?.(nextAttempt);
406
+ this.setState('connecting', `reconnecting:${reason}`);
407
+ this.reconnectManager.recordFailure();
408
+ }
409
+ async reconnect() {
410
+ if (!this.shouldAutoReconnect())
411
+ return;
412
+ this.cleanupPeerSessions();
413
+ this.peerId = null;
414
+ try {
415
+ await this.ensureLocalStream();
416
+ this.openWebSocket();
417
+ }
418
+ catch (err) {
419
+ const error = err instanceof Error ? err : new Error(String(err));
420
+ this.callbacks.onError?.(error, this._state);
421
+ this.scheduleReconnect('reconnect-error');
422
+ }
423
+ }
424
+ async handleSignalingMessage(msg) {
425
+ switch (msg.t) {
426
+ case 'joined':
427
+ this.peerId = msg.peerId;
428
+ for (const existingPeerId of msg.peers) {
429
+ await this.createPeerSession(existingPeerId, true);
430
+ }
431
+ break;
432
+ case 'peer-joined':
433
+ this.callbacks.onPeerJoined?.(msg.peerId);
434
+ await this.createPeerSession(msg.peerId, false);
435
+ break;
436
+ case 'peer-left':
437
+ this.callbacks.onPeerLeft?.(msg.peerId);
438
+ this.closePeerSession(msg.peerId);
439
+ this.updateConnectionState();
440
+ break;
441
+ case 'sdp':
442
+ await this.handleRemoteSDP(msg.from, msg.description);
443
+ break;
444
+ case 'ice':
445
+ await this.handleRemoteICE(msg.from, msg.candidate);
446
+ break;
447
+ case 'error': {
448
+ const error = new Error(msg.message);
449
+ this.callbacks.onError?.(error, this._state);
450
+ break;
451
+ }
452
+ }
453
+ }
454
+ // =========================================================================
455
+ // Peer Session Management
456
+ // =========================================================================
457
+ async createPeerSession(remotePeerId, isOfferer) {
458
+ if (this.peers.has(remotePeerId)) {
459
+ return this.peers.get(remotePeerId);
460
+ }
461
+ const iceServers = this.options.iceServers ?? DEFAULT_ICE_SERVERS;
462
+ const pc = new RTCPeerConnection({ iceServers });
463
+ const session = {
464
+ peerId: remotePeerId,
465
+ pc,
466
+ remoteStream: null,
467
+ dataChannels: new Map(),
468
+ makingOffer: false,
469
+ ignoreOffer: false,
470
+ hasRemoteDescription: false,
471
+ pendingCandidates: [],
472
+ isOfferer,
473
+ negotiationStarted: false,
474
+ hasIceCandidate: false,
475
+ iceGatheringTimer: null,
476
+ };
477
+ this.peers.set(remotePeerId, session);
478
+ if (this.localStream) {
479
+ for (const track of this.localStream.getTracks()) {
480
+ pc.addTrack(track, this.localStream);
481
+ }
482
+ }
483
+ pc.ontrack = (event) => {
484
+ if (event.streams?.[0]) {
485
+ if (session.remoteStream !== event.streams[0]) {
486
+ session.remoteStream = event.streams[0];
487
+ this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
488
+ }
489
+ }
490
+ else {
491
+ if (!session.remoteStream) {
492
+ session.remoteStream = new MediaStream([event.track]);
493
+ this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
494
+ }
495
+ else {
496
+ session.remoteStream.addTrack(event.track);
497
+ this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
498
+ }
499
+ }
500
+ this.callbacks.onTrackAdded?.(remotePeerId, event.track, session.remoteStream);
501
+ this.updateConnectionState();
502
+ };
503
+ pc.ondatachannel = (event) => {
504
+ this.setupDataChannel(session, event.channel);
505
+ };
506
+ pc.onicecandidate = (event) => {
507
+ if (event.candidate) {
508
+ session.hasIceCandidate = true;
509
+ if (session.iceGatheringTimer) {
510
+ clearTimeout(session.iceGatheringTimer);
511
+ session.iceGatheringTimer = null;
512
+ }
513
+ this.callbacks.onIceCandidate?.(remotePeerId, event.candidate.toJSON());
514
+ this.send({
515
+ t: 'ice',
516
+ from: this.peerId,
517
+ to: remotePeerId,
518
+ candidate: event.candidate.toJSON(),
519
+ });
520
+ }
521
+ };
522
+ this.scheduleIceGatheringTimeout(session);
523
+ pc.onnegotiationneeded = async () => {
524
+ // If we're not the offerer and haven't received a remote description yet,
525
+ // don't send an offer - wait for the other peer's offer
526
+ if (!session.isOfferer && !session.hasRemoteDescription && !session.negotiationStarted) {
527
+ return;
528
+ }
529
+ try {
530
+ session.makingOffer = true;
531
+ await pc.setLocalDescription();
532
+ this.send({
533
+ t: 'sdp',
534
+ from: this.peerId,
535
+ to: remotePeerId,
536
+ description: pc.localDescription,
537
+ });
538
+ }
539
+ catch (err) {
540
+ const error = err instanceof Error ? err : new Error(String(err));
541
+ this.callbacks.onError?.(error, this._state);
542
+ }
543
+ finally {
544
+ session.makingOffer = false;
545
+ }
546
+ };
547
+ pc.oniceconnectionstatechange = () => {
548
+ const iceState = pc.iceConnectionState;
549
+ this.callbacks.onIceStateChange?.(remotePeerId, iceState);
550
+ this.updateConnectionState();
551
+ if (iceState === 'failed') {
552
+ const error = new Error(`ICE connection failed for peer ${remotePeerId}`);
553
+ this.callbacks.onError?.(error, this._state);
554
+ this.handleConnectionLoss('ice-failed');
555
+ }
556
+ };
557
+ if (isOfferer) {
558
+ if (this.options.dataChannels) {
559
+ for (const config of this.options.dataChannels) {
560
+ const channel = pc.createDataChannel(config.label, {
561
+ ordered: config.ordered ?? true,
562
+ maxPacketLifeTime: config.maxPacketLifeTime,
563
+ maxRetransmits: config.maxRetransmits,
564
+ protocol: config.protocol,
565
+ });
566
+ this.setupDataChannel(session, channel);
567
+ }
568
+ }
569
+ this.setState('negotiating', 'creating offer');
570
+ this.callbacks.onNegotiationStart?.(remotePeerId);
571
+ await this.createOffer(session);
572
+ }
573
+ return session;
574
+ }
575
+ async createOffer(session) {
576
+ try {
577
+ session.makingOffer = true;
578
+ session.negotiationStarted = true;
579
+ const offer = await session.pc.createOffer();
580
+ await session.pc.setLocalDescription(offer);
581
+ this.send({
582
+ t: 'sdp',
583
+ from: this.peerId,
584
+ to: session.peerId,
585
+ description: session.pc.localDescription,
586
+ });
587
+ }
588
+ finally {
589
+ session.makingOffer = false;
590
+ }
591
+ }
592
+ async handleRemoteSDP(fromPeerId, description) {
593
+ let session = this.peers.get(fromPeerId);
594
+ if (!session) {
595
+ session = await this.createPeerSession(fromPeerId, false);
596
+ }
597
+ const pc = session.pc;
598
+ const isOffer = description.type === 'offer';
599
+ const polite = this.basePolite ?? !this.isOffererFor(fromPeerId);
600
+ const offerCollision = isOffer && (session.makingOffer || pc.signalingState !== 'stable');
601
+ session.ignoreOffer = !polite && offerCollision;
602
+ if (session.ignoreOffer)
603
+ return;
604
+ if (this._state === 'signaling') {
605
+ this.setState('negotiating', 'received SDP');
606
+ this.callbacks.onNegotiationStart?.(fromPeerId);
607
+ }
608
+ await pc.setRemoteDescription(description);
609
+ session.hasRemoteDescription = true;
610
+ for (const candidate of session.pendingCandidates) {
611
+ try {
612
+ await pc.addIceCandidate(candidate);
613
+ }
614
+ catch (err) {
615
+ if (!session.ignoreOffer) {
616
+ console.warn('Failed to add buffered ICE candidate:', err);
617
+ }
618
+ }
619
+ }
620
+ session.pendingCandidates = [];
621
+ if (isOffer) {
622
+ session.negotiationStarted = true;
623
+ await pc.setLocalDescription();
624
+ this.send({
625
+ t: 'sdp',
626
+ from: this.peerId,
627
+ to: fromPeerId,
628
+ description: pc.localDescription,
629
+ });
630
+ }
631
+ this.callbacks.onNegotiationComplete?.(fromPeerId);
632
+ }
633
+ isOffererFor(remotePeerId) {
634
+ return this.peerId > remotePeerId;
635
+ }
636
+ async handleRemoteICE(fromPeerId, candidate) {
637
+ const session = this.peers.get(fromPeerId);
638
+ if (!session || !session.hasRemoteDescription) {
639
+ if (session) {
640
+ session.pendingCandidates.push(candidate);
641
+ }
642
+ return;
643
+ }
644
+ try {
645
+ await session.pc.addIceCandidate(candidate);
646
+ }
647
+ catch (err) {
648
+ if (!session.ignoreOffer) {
649
+ console.warn('Failed to add ICE candidate:', err);
650
+ }
651
+ }
652
+ }
653
+ closePeerSession(peerId) {
654
+ const session = this.peers.get(peerId);
655
+ if (!session)
656
+ return;
657
+ // Clear ICE gathering timer if exists
658
+ if (session.iceGatheringTimer) {
659
+ clearTimeout(session.iceGatheringTimer);
660
+ session.iceGatheringTimer = null;
661
+ }
662
+ // Close data channels
663
+ for (const channel of session.dataChannels.values()) {
664
+ channel.close();
665
+ }
666
+ session.dataChannels.clear();
667
+ // Clear all event handlers before closing to prevent memory leaks
668
+ const pc = session.pc;
669
+ pc.ontrack = null;
670
+ pc.ondatachannel = null;
671
+ pc.onicecandidate = null;
672
+ pc.onnegotiationneeded = null;
673
+ pc.oniceconnectionstatechange = null;
674
+ pc.close();
675
+ this.peers.delete(peerId);
676
+ }
677
+ cleanupPeerSessions() {
678
+ for (const peerId of this.peers.keys()) {
679
+ this.closePeerSession(peerId);
680
+ }
681
+ this.peers.clear();
682
+ }
683
+ scheduleIceGatheringTimeout(session) {
684
+ const timeoutMs = this.options.iceGatheringTimeout ?? 10000;
685
+ if (timeoutMs <= 0)
686
+ return;
687
+ if (session.iceGatheringTimer) {
688
+ clearTimeout(session.iceGatheringTimer);
689
+ }
690
+ session.iceGatheringTimer = setTimeout(() => {
691
+ if (!session.hasIceCandidate) {
692
+ console.warn(`ICE gathering timeout for peer ${session.peerId}`);
693
+ }
694
+ }, timeoutMs);
695
+ }
696
+ // =========================================================================
697
+ // Data Channel
698
+ // =========================================================================
699
+ setupDataChannel(session, channel) {
700
+ const label = channel.label;
701
+ const peerId = session.peerId;
702
+ session.dataChannels.set(label, channel);
703
+ channel.onopen = () => {
704
+ this.callbacks.onDataChannelOpen?.(peerId, label);
705
+ if (this.isDataOnly && this._state !== 'connected') {
706
+ this.updateConnectionState();
707
+ }
708
+ };
709
+ channel.onclose = () => {
710
+ session.dataChannels.delete(label);
711
+ this.callbacks.onDataChannelClose?.(peerId, label);
712
+ };
713
+ channel.onmessage = (event) => {
714
+ const data = event.data;
715
+ if (typeof data === 'string') {
716
+ try {
717
+ const parsed = JSON.parse(data);
718
+ this.callbacks.onDataChannelMessage?.(peerId, label, parsed);
719
+ }
720
+ catch {
721
+ this.callbacks.onDataChannelMessage?.(peerId, label, data);
722
+ }
723
+ }
724
+ else {
725
+ this.callbacks.onDataChannelMessage?.(peerId, label, data);
726
+ }
727
+ };
728
+ channel.onerror = (event) => {
729
+ const error = event instanceof ErrorEvent
730
+ ? new Error(event.message)
731
+ : new Error('Data channel error');
732
+ this.callbacks.onDataChannelError?.(peerId, label, error);
733
+ };
734
+ }
735
+ /**
736
+ * Create a new data channel to all connected peers.
737
+ */
738
+ createDataChannel(config) {
739
+ const channels = new Map();
740
+ for (const [peerId, session] of this.peers) {
741
+ const channel = session.pc.createDataChannel(config.label, {
742
+ ordered: config.ordered ?? true,
743
+ maxPacketLifeTime: config.maxPacketLifeTime,
744
+ maxRetransmits: config.maxRetransmits,
745
+ protocol: config.protocol,
746
+ });
747
+ this.setupDataChannel(session, channel);
748
+ channels.set(peerId, channel);
749
+ }
750
+ return channels;
751
+ }
752
+ /**
753
+ * Get a data channel by label from a specific peer.
754
+ */
755
+ getDataChannel(peerId, label) {
756
+ return this.peers.get(peerId)?.dataChannels.get(label);
757
+ }
758
+ /**
759
+ * Get all open data channel labels.
760
+ */
761
+ getDataChannelLabels() {
762
+ const labels = new Set();
763
+ for (const session of this.peers.values()) {
764
+ for (const label of session.dataChannels.keys()) {
765
+ labels.add(label);
766
+ }
767
+ }
768
+ return Array.from(labels);
769
+ }
770
+ /**
771
+ * Get the state of a data channel for a specific peer.
772
+ */
773
+ getDataChannelState(peerId, label) {
774
+ const channel = this.peers.get(peerId)?.dataChannels.get(label);
775
+ return channel ? channel.readyState : null;
776
+ }
777
+ /**
778
+ * Send a string message to all peers on a data channel.
779
+ */
780
+ sendString(label, data) {
781
+ let sent = false;
782
+ for (const session of this.peers.values()) {
783
+ const channel = session.dataChannels.get(label);
784
+ if (channel?.readyState === 'open') {
785
+ channel.send(data);
786
+ sent = true;
787
+ }
788
+ }
789
+ return sent;
790
+ }
791
+ /**
792
+ * Send a string message to a specific peer.
793
+ */
794
+ sendStringTo(peerId, label, data) {
795
+ const channel = this.peers.get(peerId)?.dataChannels.get(label);
796
+ if (!channel || channel.readyState !== 'open')
797
+ return false;
798
+ channel.send(data);
799
+ return true;
800
+ }
801
+ /**
802
+ * Send binary data to all peers on a data channel.
803
+ */
804
+ sendBinary(label, data) {
805
+ let sent = false;
806
+ const buffer = data instanceof ArrayBuffer
807
+ ? data
808
+ : (() => {
809
+ const buf = new ArrayBuffer(data.byteLength);
810
+ new Uint8Array(buf).set(data);
811
+ return buf;
812
+ })();
813
+ for (const session of this.peers.values()) {
814
+ const channel = session.dataChannels.get(label);
815
+ if (channel?.readyState === 'open') {
816
+ channel.send(buffer);
817
+ sent = true;
818
+ }
819
+ }
820
+ return sent;
821
+ }
822
+ /**
823
+ * Send binary data to a specific peer.
824
+ */
825
+ sendBinaryTo(peerId, label, data) {
826
+ const channel = this.peers.get(peerId)?.dataChannels.get(label);
827
+ if (!channel || channel.readyState !== 'open')
828
+ return false;
829
+ if (data instanceof ArrayBuffer) {
830
+ channel.send(data);
831
+ }
832
+ else {
833
+ const buffer = new ArrayBuffer(data.byteLength);
834
+ new Uint8Array(buffer).set(data);
835
+ channel.send(buffer);
836
+ }
837
+ return true;
838
+ }
839
+ /**
840
+ * Send JSON data to all peers on a data channel.
841
+ */
842
+ sendJSON(label, data) {
843
+ return this.sendString(label, JSON.stringify(data));
844
+ }
845
+ /**
846
+ * Send JSON data to a specific peer.
847
+ */
848
+ sendJSONTo(peerId, label, data) {
849
+ return this.sendStringTo(peerId, label, JSON.stringify(data));
850
+ }
851
+ /**
852
+ * Close a specific data channel on all peers.
853
+ */
854
+ closeDataChannel(label) {
855
+ let closed = false;
856
+ for (const session of this.peers.values()) {
857
+ const channel = session.dataChannels.get(label);
858
+ if (channel) {
859
+ channel.close();
860
+ session.dataChannels.delete(label);
861
+ closed = true;
862
+ }
863
+ }
864
+ return closed;
865
+ }
866
+ // =========================================================================
867
+ // Media Controls
868
+ // =========================================================================
869
+ /**
870
+ * Mute or unmute audio
871
+ */
872
+ muteAudio(muted) {
873
+ if (this.localStream) {
874
+ for (const track of this.localStream.getAudioTracks()) {
875
+ track.enabled = !muted;
876
+ }
877
+ }
878
+ this.isAudioMuted = muted;
879
+ }
880
+ /**
881
+ * Mute or unmute video
882
+ */
883
+ muteVideo(muted) {
884
+ if (this.localStream) {
885
+ for (const track of this.localStream.getVideoTracks()) {
886
+ track.enabled = !muted;
887
+ }
888
+ }
889
+ this.isVideoMuted = muted;
890
+ }
891
+ // =========================================================================
892
+ // Screen Sharing
893
+ // =========================================================================
894
+ /**
895
+ * Start screen sharing, replacing the current video track.
896
+ * @param options - Display media constraints
897
+ */
898
+ async startScreenShare(options = { video: true, audio: false }) {
899
+ if (this.isScreenSharing || this.isDataOnly)
900
+ return;
901
+ const screenStream = await navigator.mediaDevices.getDisplayMedia(options);
902
+ const screenTrack = screenStream.getVideoTracks()[0];
903
+ if (!screenTrack) {
904
+ throw new Error('Failed to get screen video track');
905
+ }
906
+ if (this.localStream) {
907
+ const currentVideoTrack = this.localStream.getVideoTracks()[0];
908
+ if (currentVideoTrack) {
909
+ this.previousVideoTrack = currentVideoTrack;
910
+ this.localStream.removeTrack(currentVideoTrack);
911
+ }
912
+ this.localStream.addTrack(screenTrack);
913
+ }
914
+ for (const session of this.peers.values()) {
915
+ const sender = session.pc.getSenders().find((s) => s.track?.kind === 'video');
916
+ if (sender) {
917
+ await sender.replaceTrack(screenTrack);
918
+ }
919
+ else {
920
+ session.pc.addTrack(screenTrack, this.localStream);
921
+ }
922
+ }
923
+ screenTrack.onended = () => {
924
+ this.stopScreenShare();
925
+ };
926
+ this.isScreenSharing = true;
927
+ this.callbacks.onScreenShareStart?.();
928
+ }
929
+ /**
930
+ * Stop screen sharing and restore the previous video track.
931
+ */
932
+ async stopScreenShare() {
933
+ if (!this.isScreenSharing)
934
+ return;
935
+ const screenTrack = this.localStream?.getVideoTracks()[0];
936
+ if (screenTrack) {
937
+ screenTrack.stop();
938
+ this.localStream?.removeTrack(screenTrack);
939
+ }
940
+ if (this.previousVideoTrack && this.localStream) {
941
+ this.localStream.addTrack(this.previousVideoTrack);
942
+ for (const session of this.peers.values()) {
943
+ const sender = session.pc.getSenders().find((s) => s.track?.kind === 'video');
944
+ if (sender) {
945
+ await sender.replaceTrack(this.previousVideoTrack);
946
+ }
947
+ }
948
+ }
949
+ this.previousVideoTrack = null;
950
+ this.isScreenSharing = false;
951
+ this.callbacks.onScreenShareStop?.();
952
+ }
953
+ // =========================================================================
954
+ // Connection Stats
955
+ // =========================================================================
956
+ /**
957
+ * Get raw stats for a peer connection.
958
+ */
959
+ async getRawStats(peerId) {
960
+ const session = this.peers.get(peerId);
961
+ if (!session)
962
+ return null;
963
+ return session.pc.getStats();
964
+ }
965
+ /**
966
+ * Get raw stats for all peer connections.
967
+ */
968
+ async getAllRawStats() {
969
+ const stats = new Map();
970
+ for (const [peerId, session] of this.peers) {
971
+ stats.set(peerId, await session.pc.getStats());
972
+ }
973
+ return stats;
974
+ }
975
+ /**
976
+ * Get a normalized quality summary for a peer connection.
977
+ */
978
+ async getQualitySummary(peerId) {
979
+ const session = this.peers.get(peerId);
980
+ if (!session)
981
+ return null;
982
+ const stats = await session.pc.getStats();
983
+ return this.parseStatsToSummary(stats, session);
984
+ }
985
+ /**
986
+ * Get quality summaries for all peer connections.
987
+ */
988
+ async getAllQualitySummaries() {
989
+ const summaries = new Map();
990
+ for (const [peerId, session] of this.peers) {
991
+ const stats = await session.pc.getStats();
992
+ summaries.set(peerId, this.parseStatsToSummary(stats, session));
993
+ }
994
+ return summaries;
995
+ }
996
+ parseStatsToSummary(stats, session) {
997
+ const summary = { timestamp: Date.now() };
998
+ const now = Date.now();
999
+ const prevStats = session.lastStats;
1000
+ const prevTime = session.lastStatsTime ?? now;
1001
+ const timeDelta = (now - prevTime) / 1000;
1002
+ const bitrate = {};
1003
+ stats.forEach((report) => {
1004
+ if (report.type === 'candidate-pair' && report.state === 'succeeded') {
1005
+ summary.rtt = report.currentRoundTripTime
1006
+ ? report.currentRoundTripTime * 1000
1007
+ : undefined;
1008
+ const localCandidateId = report.localCandidateId;
1009
+ const remoteCandidateId = report.remoteCandidateId;
1010
+ const localCandidate = this.getStatReport(stats, localCandidateId);
1011
+ const remoteCandidate = this.getStatReport(stats, remoteCandidateId);
1012
+ summary.candidatePair = {
1013
+ localType: localCandidate?.candidateType,
1014
+ remoteType: remoteCandidate?.candidateType,
1015
+ protocol: localCandidate?.protocol,
1016
+ usingRelay: localCandidate?.candidateType === 'relay' ||
1017
+ remoteCandidate?.candidateType === 'relay',
1018
+ };
1019
+ }
1020
+ if (report.type === 'inbound-rtp' && report.kind === 'audio') {
1021
+ summary.jitter = report.jitter ? report.jitter * 1000 : undefined;
1022
+ if (report.packetsLost !== undefined && report.packetsReceived !== undefined) {
1023
+ const total = report.packetsLost + report.packetsReceived;
1024
+ if (total > 0) {
1025
+ summary.packetLossPercent = (report.packetsLost / total) * 100;
1026
+ }
1027
+ }
1028
+ if (prevStats && timeDelta > 0) {
1029
+ const prev = this.findMatchingReport(prevStats, report.id);
1030
+ if (prev?.bytesReceived !== undefined && report.bytesReceived !== undefined) {
1031
+ bitrate.audio = bitrate.audio ?? {};
1032
+ bitrate.audio.inbound =
1033
+ ((report.bytesReceived - prev.bytesReceived) * 8) / timeDelta;
1034
+ }
1035
+ }
1036
+ }
1037
+ if (report.type === 'inbound-rtp' && report.kind === 'video') {
1038
+ summary.video = {
1039
+ framesPerSecond: report.framesPerSecond,
1040
+ framesDropped: report.framesDropped,
1041
+ frameWidth: report.frameWidth,
1042
+ frameHeight: report.frameHeight,
1043
+ };
1044
+ if (prevStats && timeDelta > 0) {
1045
+ const prev = this.findMatchingReport(prevStats, report.id);
1046
+ if (prev?.bytesReceived !== undefined && report.bytesReceived !== undefined) {
1047
+ bitrate.video = bitrate.video ?? {};
1048
+ bitrate.video.inbound =
1049
+ ((report.bytesReceived - prev.bytesReceived) * 8) / timeDelta;
1050
+ }
1051
+ }
1052
+ }
1053
+ if (report.type === 'outbound-rtp' && prevStats && timeDelta > 0) {
1054
+ const prev = this.findMatchingReport(prevStats, report.id);
1055
+ if (prev?.bytesSent !== undefined && report.bytesSent !== undefined) {
1056
+ const bps = ((report.bytesSent - prev.bytesSent) * 8) / timeDelta;
1057
+ if (report.kind === 'audio') {
1058
+ bitrate.audio = bitrate.audio ?? {};
1059
+ bitrate.audio.outbound = bps;
1060
+ }
1061
+ else if (report.kind === 'video') {
1062
+ bitrate.video = bitrate.video ?? {};
1063
+ bitrate.video.outbound = bps;
1064
+ }
1065
+ }
1066
+ }
1067
+ });
1068
+ if (Object.keys(bitrate).length > 0) {
1069
+ summary.bitrate = bitrate;
1070
+ }
1071
+ session.lastStats = stats;
1072
+ session.lastStatsTime = now;
1073
+ return summary;
1074
+ }
1075
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1076
+ findMatchingReport(stats, id) {
1077
+ return this.getStatReport(stats, id);
1078
+ }
1079
+ // RTCStatsReport extends Map but bun-types may not expose .get() properly
1080
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1081
+ getStatReport(stats, id) {
1082
+ // Use Map.prototype.get via cast
1083
+ const mapLike = stats;
1084
+ return mapLike.get(id);
1085
+ }
1086
+ // =========================================================================
1087
+ // Recording
1088
+ // =========================================================================
1089
+ /**
1090
+ * Start recording a stream.
1091
+ * @param streamId - 'local' for local stream, or a peer ID for remote stream
1092
+ * @param options - Recording options
1093
+ */
1094
+ startRecording(streamId, options) {
1095
+ const stream = streamId === 'local' ? this.localStream : this.getRemoteStream(streamId);
1096
+ if (!stream)
1097
+ return null;
1098
+ const mimeType = this.selectMimeType(stream, options?.mimeType);
1099
+ if (!mimeType)
1100
+ return null;
1101
+ const recorder = new MediaRecorder(stream, {
1102
+ mimeType,
1103
+ audioBitsPerSecond: options?.audioBitsPerSecond,
1104
+ videoBitsPerSecond: options?.videoBitsPerSecond,
1105
+ });
1106
+ const chunks = [];
1107
+ recorder.ondataavailable = (event) => {
1108
+ if (event.data.size > 0) {
1109
+ chunks.push(event.data);
1110
+ }
1111
+ };
1112
+ this.recordings.set(streamId, { recorder, chunks });
1113
+ recorder.start(1000);
1114
+ return {
1115
+ stop: () => new Promise((resolve) => {
1116
+ recorder.onstop = () => {
1117
+ this.recordings.delete(streamId);
1118
+ resolve(new Blob(chunks, { type: mimeType }));
1119
+ };
1120
+ recorder.stop();
1121
+ }),
1122
+ pause: () => recorder.pause(),
1123
+ resume: () => recorder.resume(),
1124
+ get state() {
1125
+ return recorder.state;
1126
+ },
1127
+ };
1128
+ }
1129
+ /**
1130
+ * Check if a stream is being recorded.
1131
+ */
1132
+ isRecording(streamId) {
1133
+ const recording = this.recordings.get(streamId);
1134
+ return recording?.recorder.state === 'recording';
1135
+ }
1136
+ /**
1137
+ * Stop all recordings and return the blobs.
1138
+ */
1139
+ async stopAllRecordings() {
1140
+ const blobs = new Map();
1141
+ const promises = [];
1142
+ for (const [streamId, { recorder, chunks }] of this.recordings) {
1143
+ const mimeType = recorder.mimeType;
1144
+ promises.push(new Promise((resolve) => {
1145
+ const timeout = setTimeout(() => {
1146
+ console.warn(`Recording stop timeout for stream ${streamId}`);
1147
+ resolve(); // Don't block other recordings
1148
+ }, 5000);
1149
+ recorder.onstop = () => {
1150
+ clearTimeout(timeout);
1151
+ blobs.set(streamId, new Blob(chunks, { type: mimeType }));
1152
+ resolve();
1153
+ };
1154
+ recorder.stop();
1155
+ }));
1156
+ }
1157
+ await Promise.all(promises);
1158
+ this.recordings.clear();
1159
+ return blobs;
1160
+ }
1161
+ selectMimeType(stream, preferred) {
1162
+ if (preferred && MediaRecorder.isTypeSupported(preferred)) {
1163
+ return preferred;
1164
+ }
1165
+ const hasVideo = stream.getVideoTracks().length > 0;
1166
+ const hasAudio = stream.getAudioTracks().length > 0;
1167
+ const videoTypes = [
1168
+ 'video/webm;codecs=vp9,opus',
1169
+ 'video/webm;codecs=vp8,opus',
1170
+ 'video/webm',
1171
+ 'video/mp4',
1172
+ ];
1173
+ const audioTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg'];
1174
+ const candidates = hasVideo ? videoTypes : hasAudio ? audioTypes : [];
1175
+ for (const type of candidates) {
1176
+ if (MediaRecorder.isTypeSupported(type)) {
1177
+ return type;
1178
+ }
1179
+ }
1180
+ return null;
1181
+ }
1182
+ // =========================================================================
1183
+ // Cleanup
1184
+ // =========================================================================
1185
+ /**
1186
+ * End the call and disconnect from all peers
1187
+ */
1188
+ hangup() {
1189
+ this.intentionalClose = true;
1190
+ this.reconnectManager.cancel();
1191
+ this.clearConnectionTimeout();
1192
+ this.cleanupPeerSessions();
1193
+ if (this.isScreenSharing) {
1194
+ const screenTrack = this.localStream?.getVideoTracks()[0];
1195
+ screenTrack?.stop();
1196
+ }
1197
+ if (this.trackSource) {
1198
+ this.trackSource.stop();
1199
+ this.trackSource = null;
1200
+ }
1201
+ this.localStream = null;
1202
+ this.previousVideoTrack = null;
1203
+ if (this.ws) {
1204
+ this.ws.close();
1205
+ this.ws = null;
1206
+ }
1207
+ this.peerId = null;
1208
+ this.isScreenSharing = false;
1209
+ this.setState('idle', 'hangup');
1210
+ this.intentionalClose = false;
1211
+ }
1212
+ /**
1213
+ * Clean up all resources
1214
+ */
1215
+ dispose() {
1216
+ this.stopAllRecordings();
1217
+ this.hangup();
1218
+ this.reconnectManager.dispose();
1219
+ }
1220
+ }
1221
+ //# sourceMappingURL=webrtc-manager.js.map