@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,1631 @@
1
+ import type {
2
+ SignalMessage,
3
+ WebRTCConnectionState,
4
+ WebRTCDisconnectReason,
5
+ DataChannelConfig,
6
+ DataChannelState,
7
+ ConnectionQualitySummary,
8
+ RecordingOptions,
9
+ RecordingHandle,
10
+ RecordingState,
11
+ TrackSource as CoreTrackSource,
12
+ } from '@agentuity/core';
13
+ import { createReconnectManager, type ReconnectManager } from './reconnect';
14
+
15
+ /**
16
+ * Track source interface extended for browser environment.
17
+ */
18
+ export interface TrackSource extends Omit<CoreTrackSource, 'getStream'> {
19
+ getStream(): Promise<MediaStream>;
20
+ }
21
+
22
+ // =============================================================================
23
+ // Track Sources
24
+ // =============================================================================
25
+
26
+ /**
27
+ * User media (camera/microphone) track source.
28
+ */
29
+ export class UserMediaSource implements TrackSource {
30
+ readonly type = 'user-media' as const;
31
+ private stream: MediaStream | null = null;
32
+
33
+ constructor(private constraints: MediaStreamConstraints = { video: true, audio: true }) {}
34
+
35
+ async getStream(): Promise<MediaStream> {
36
+ this.stream = await navigator.mediaDevices.getUserMedia(this.constraints);
37
+ return this.stream;
38
+ }
39
+
40
+ stop(): void {
41
+ if (this.stream) {
42
+ for (const track of this.stream.getTracks()) {
43
+ track.stop();
44
+ }
45
+ this.stream = null;
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Display media (screen share) track source.
52
+ */
53
+ export class DisplayMediaSource implements TrackSource {
54
+ readonly type = 'display-media' as const;
55
+ private stream: MediaStream | null = null;
56
+
57
+ constructor(private constraints: DisplayMediaStreamOptions = { video: true, audio: false }) {}
58
+
59
+ async getStream(): Promise<MediaStream> {
60
+ this.stream = await navigator.mediaDevices.getDisplayMedia(this.constraints);
61
+ return this.stream;
62
+ }
63
+
64
+ stop(): void {
65
+ if (this.stream) {
66
+ for (const track of this.stream.getTracks()) {
67
+ track.stop();
68
+ }
69
+ this.stream = null;
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Custom stream track source - wraps a user-provided MediaStream.
76
+ */
77
+ export class CustomStreamSource implements TrackSource {
78
+ readonly type = 'custom' as const;
79
+
80
+ constructor(private stream: MediaStream) {}
81
+
82
+ async getStream(): Promise<MediaStream> {
83
+ return this.stream;
84
+ }
85
+
86
+ stop(): void {
87
+ for (const track of this.stream.getTracks()) {
88
+ track.stop();
89
+ }
90
+ }
91
+ }
92
+
93
+ // =============================================================================
94
+ // Per-Peer Session
95
+ // =============================================================================
96
+
97
+ /**
98
+ * Represents a connection to a single remote peer.
99
+ */
100
+ interface PeerSession {
101
+ peerId: string;
102
+ pc: RTCPeerConnection;
103
+ remoteStream: MediaStream | null;
104
+ dataChannels: Map<string, RTCDataChannel>;
105
+ makingOffer: boolean;
106
+ ignoreOffer: boolean;
107
+ hasRemoteDescription: boolean;
108
+ pendingCandidates: RTCIceCandidateInit[];
109
+ isOfferer: boolean;
110
+ negotiationStarted: boolean;
111
+ lastStats?: RTCStatsReport;
112
+ lastStatsTime?: number;
113
+ hasIceCandidate?: boolean;
114
+ iceGatheringTimer?: ReturnType<typeof setTimeout> | null;
115
+ }
116
+
117
+ // =============================================================================
118
+ // Callbacks
119
+ // =============================================================================
120
+
121
+ /**
122
+ * Callbacks for WebRTC client state changes and events.
123
+ * All callbacks are optional - only subscribe to events you care about.
124
+ */
125
+ export interface WebRTCClientCallbacks {
126
+ /**
127
+ * Called on every state transition.
128
+ */
129
+ onStateChange?: (
130
+ from: WebRTCConnectionState,
131
+ to: WebRTCConnectionState,
132
+ reason?: string
133
+ ) => void;
134
+
135
+ /**
136
+ * Called when connected to at least one peer.
137
+ */
138
+ onConnect?: () => void;
139
+
140
+ /**
141
+ * Called when disconnected from all peers.
142
+ */
143
+ onDisconnect?: (reason: WebRTCDisconnectReason) => void;
144
+
145
+ /**
146
+ * Called when local media stream is acquired.
147
+ */
148
+ onLocalStream?: (stream: MediaStream) => void;
149
+
150
+ /**
151
+ * Called when a remote media stream is received.
152
+ */
153
+ onRemoteStream?: (peerId: string, stream: MediaStream) => void;
154
+
155
+ /**
156
+ * Called when a new track is added to a stream.
157
+ */
158
+ onTrackAdded?: (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
159
+
160
+ /**
161
+ * Called when a track is removed from a stream.
162
+ */
163
+ onTrackRemoved?: (peerId: string, track: MediaStreamTrack) => void;
164
+
165
+ /**
166
+ * Called when a peer joins the room.
167
+ */
168
+ onPeerJoined?: (peerId: string) => void;
169
+
170
+ /**
171
+ * Called when a peer leaves the room.
172
+ */
173
+ onPeerLeft?: (peerId: string) => void;
174
+
175
+ /**
176
+ * Called when negotiation starts with a peer.
177
+ */
178
+ onNegotiationStart?: (peerId: string) => void;
179
+
180
+ /**
181
+ * Called when negotiation completes with a peer.
182
+ */
183
+ onNegotiationComplete?: (peerId: string) => void;
184
+
185
+ /**
186
+ * Called for each ICE candidate generated.
187
+ */
188
+ onIceCandidate?: (peerId: string, candidate: RTCIceCandidateInit) => void;
189
+
190
+ /**
191
+ * Called when ICE connection state changes for a peer.
192
+ */
193
+ onIceStateChange?: (peerId: string, state: string) => void;
194
+
195
+ /**
196
+ * Called when an error occurs.
197
+ */
198
+ onError?: (error: Error, state: WebRTCConnectionState) => void;
199
+
200
+ /**
201
+ * Called when a data channel is opened.
202
+ */
203
+ onDataChannelOpen?: (peerId: string, label: string) => void;
204
+
205
+ /**
206
+ * Called when a data channel is closed.
207
+ */
208
+ onDataChannelClose?: (peerId: string, label: string) => void;
209
+
210
+ /**
211
+ * Called when a message is received on a data channel.
212
+ *
213
+ * **Note:** String messages are automatically parsed as JSON if valid.
214
+ * - If the message is valid JSON, `data` will be the parsed object/array/value
215
+ * - If the message is not valid JSON, `data` will be the raw string
216
+ * - Binary messages (ArrayBuffer) are passed through unchanged
217
+ *
218
+ * To distinguish between parsed JSON and raw strings, check the type:
219
+ * ```ts
220
+ * onDataChannelMessage: (peerId, label, data) => {
221
+ * if (typeof data === 'string') {
222
+ * // Raw string (failed JSON parse)
223
+ * } else if (data instanceof ArrayBuffer) {
224
+ * // Binary data
225
+ * } else {
226
+ * // Parsed JSON object/array/primitive
227
+ * }
228
+ * }
229
+ * ```
230
+ */
231
+ onDataChannelMessage?: (
232
+ peerId: string,
233
+ label: string,
234
+ data: string | ArrayBuffer | unknown
235
+ ) => void;
236
+
237
+ /**
238
+ * Called when a data channel error occurs.
239
+ */
240
+ onDataChannelError?: (peerId: string, label: string, error: Error) => void;
241
+
242
+ /**
243
+ * Called when screen sharing starts.
244
+ */
245
+ onScreenShareStart?: () => void;
246
+
247
+ /**
248
+ * Called when screen sharing stops.
249
+ */
250
+ onScreenShareStop?: () => void;
251
+
252
+ /**
253
+ * Called when a reconnect attempt is scheduled.
254
+ */
255
+ onReconnecting?: (attempt: number) => void;
256
+
257
+ /**
258
+ * Called after a successful reconnection.
259
+ */
260
+ onReconnected?: () => void;
261
+
262
+ /**
263
+ * Called when reconnect attempts are exhausted.
264
+ */
265
+ onReconnectFailed?: () => void;
266
+ }
267
+
268
+ // =============================================================================
269
+ // Options and State
270
+ // =============================================================================
271
+
272
+ /**
273
+ * Options for WebRTCManager
274
+ */
275
+ export interface WebRTCManagerOptions {
276
+ /** WebSocket signaling URL */
277
+ signalUrl: string;
278
+ /** Room ID to join */
279
+ roomId: string;
280
+ /** Whether this peer is "polite" in perfect negotiation (default: auto-determined) */
281
+ polite?: boolean;
282
+ /** ICE servers configuration */
283
+ iceServers?: RTCIceServer[];
284
+ /**
285
+ * Media source configuration.
286
+ * - `false`: Data-only mode (no media)
287
+ * - `MediaStreamConstraints`: Use getUserMedia with these constraints
288
+ * - `TrackSource`: Use a custom track source
289
+ * Default: { video: true, audio: true }
290
+ */
291
+ media?: MediaStreamConstraints | TrackSource | false;
292
+ /**
293
+ * Data channels to create when connection is established.
294
+ * Only the offerer (late joiner) creates channels; the answerer receives them.
295
+ */
296
+ dataChannels?: DataChannelConfig[];
297
+ /**
298
+ * Callbacks for state changes and events.
299
+ */
300
+ callbacks?: WebRTCClientCallbacks;
301
+ /**
302
+ * Whether to auto-reconnect on WebSocket/ICE failures (default: true)
303
+ */
304
+ autoReconnect?: boolean;
305
+ /**
306
+ * Maximum reconnection attempts before giving up (default: 5)
307
+ */
308
+ maxReconnectAttempts?: number;
309
+ /**
310
+ * Connection timeout in ms for connecting/negotiating (default: 30000)
311
+ */
312
+ connectionTimeout?: number;
313
+ /**
314
+ * ICE gathering timeout in ms (default: 10000)
315
+ */
316
+ iceGatheringTimeout?: number;
317
+ }
318
+
319
+ /**
320
+ * WebRTC manager state
321
+ */
322
+ export interface WebRTCManagerState {
323
+ state: WebRTCConnectionState;
324
+ peerId: string | null;
325
+ remotePeerIds: string[];
326
+ isAudioMuted: boolean;
327
+ isVideoMuted: boolean;
328
+ isScreenSharing: boolean;
329
+ }
330
+
331
+ /**
332
+ * Default ICE servers (public STUN servers)
333
+ */
334
+ const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
335
+ { urls: 'stun:stun.l.google.com:19302' },
336
+ { urls: 'stun:stun1.l.google.com:19302' },
337
+ ];
338
+
339
+ // =============================================================================
340
+ // WebRTCManager
341
+ // =============================================================================
342
+
343
+ /**
344
+ * Framework-agnostic WebRTC connection manager with multi-peer mesh networking,
345
+ * perfect negotiation, media/data channel handling, and screen sharing.
346
+ *
347
+ * Uses an explicit state machine for connection lifecycle:
348
+ * - idle: No resources allocated, ready to connect
349
+ * - connecting: Acquiring media + opening WebSocket
350
+ * - signaling: In room, waiting for peer(s)
351
+ * - negotiating: SDP/ICE exchange in progress with at least one peer
352
+ * - connected: At least one peer is connected
353
+ *
354
+ * @example
355
+ * ```ts
356
+ * const manager = new WebRTCManager({
357
+ * signalUrl: 'wss://example.com/call/signal',
358
+ * roomId: 'my-room',
359
+ * callbacks: {
360
+ * onStateChange: (from, to, reason) => console.log(`${from} → ${to}`, reason),
361
+ * onConnect: () => console.log('Connected!'),
362
+ * onRemoteStream: (peerId, stream) => { remoteVideos[peerId].srcObject = stream; },
363
+ * },
364
+ * });
365
+ *
366
+ * await manager.connect();
367
+ * ```
368
+ */
369
+ export class WebRTCManager {
370
+ private ws: WebSocket | null = null;
371
+ private localStream: MediaStream | null = null;
372
+ private trackSource: TrackSource | null = null;
373
+ private previousVideoTrack: MediaStreamTrack | null = null;
374
+
375
+ private peerId: string | null = null;
376
+ private peers = new Map<string, PeerSession>();
377
+ private isAudioMuted = false;
378
+ private isVideoMuted = false;
379
+ private isScreenSharing = false;
380
+
381
+ private _state: WebRTCConnectionState = 'idle';
382
+ private isConnecting = false;
383
+ private basePolite: boolean | undefined;
384
+
385
+ private options: WebRTCManagerOptions;
386
+ private callbacks: WebRTCClientCallbacks;
387
+ private reconnectManager: ReconnectManager;
388
+ private isReconnecting = false;
389
+ private intentionalClose = false;
390
+ private connectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
391
+
392
+ private recordings = new Map<string, { recorder: MediaRecorder; chunks: Blob[] }>();
393
+
394
+ constructor(options: WebRTCManagerOptions) {
395
+ this.options = {
396
+ ...options,
397
+ autoReconnect: options.autoReconnect ?? true,
398
+ maxReconnectAttempts: options.maxReconnectAttempts ?? 5,
399
+ connectionTimeout: options.connectionTimeout ?? 30000,
400
+ iceGatheringTimeout: options.iceGatheringTimeout ?? 10000,
401
+ };
402
+ this.basePolite = options.polite;
403
+ this.callbacks = options.callbacks ?? {};
404
+ this.reconnectManager = createReconnectManager({
405
+ onReconnect: () => {
406
+ void this.reconnect();
407
+ },
408
+ baseDelay: 1000,
409
+ factor: 2,
410
+ maxDelay: 30000,
411
+ jitter: 0,
412
+ enabled: () => this.shouldAutoReconnect(),
413
+ });
414
+ }
415
+
416
+ /**
417
+ * Current connection state
418
+ */
419
+ get state(): WebRTCConnectionState {
420
+ return this._state;
421
+ }
422
+
423
+ /**
424
+ * Get current manager state
425
+ */
426
+ getState(): WebRTCManagerState {
427
+ return {
428
+ state: this._state,
429
+ peerId: this.peerId,
430
+ remotePeerIds: Array.from(this.peers.keys()),
431
+ isAudioMuted: this.isAudioMuted,
432
+ isVideoMuted: this.isVideoMuted,
433
+ isScreenSharing: this.isScreenSharing,
434
+ };
435
+ }
436
+
437
+ /**
438
+ * Get local media stream
439
+ */
440
+ getLocalStream(): MediaStream | null {
441
+ return this.localStream;
442
+ }
443
+
444
+ /**
445
+ * Get remote media streams keyed by peer ID
446
+ */
447
+ getRemoteStreams(): Map<string, MediaStream> {
448
+ const streams = new Map<string, MediaStream>();
449
+ for (const [peerId, session] of this.peers) {
450
+ if (session.remoteStream) {
451
+ streams.set(peerId, session.remoteStream);
452
+ }
453
+ }
454
+ return streams;
455
+ }
456
+
457
+ /**
458
+ * Get a specific peer's remote stream
459
+ */
460
+ getRemoteStream(peerId: string): MediaStream | null {
461
+ return this.peers.get(peerId)?.remoteStream ?? null;
462
+ }
463
+
464
+ /**
465
+ * Whether this manager is in data-only mode (no media streams).
466
+ */
467
+ get isDataOnly(): boolean {
468
+ return this.options.media === false;
469
+ }
470
+
471
+ /**
472
+ * Get connected peer count
473
+ */
474
+ get peerCount(): number {
475
+ return this.peers.size;
476
+ }
477
+
478
+ // =========================================================================
479
+ // State Machine
480
+ // =========================================================================
481
+
482
+ private setState(newState: WebRTCConnectionState, reason?: string): void {
483
+ const prevState = this._state;
484
+ if (prevState === newState) return;
485
+
486
+ this._state = newState;
487
+ this.handleStateTimeouts(newState);
488
+ this.callbacks.onStateChange?.(prevState, newState, reason);
489
+
490
+ if (newState === 'connected' && prevState !== 'connected') {
491
+ this.callbacks.onConnect?.();
492
+ }
493
+
494
+ if (newState === 'idle' && prevState !== 'idle') {
495
+ const disconnectReason = this.mapToDisconnectReason(reason);
496
+ this.callbacks.onDisconnect?.(disconnectReason);
497
+ }
498
+ }
499
+
500
+ private mapToDisconnectReason(reason?: string): WebRTCDisconnectReason {
501
+ if (reason === 'hangup') return 'hangup';
502
+ if (reason === 'peer-left') return 'peer-left';
503
+ if (reason?.includes('timeout')) return 'timeout';
504
+ return 'error';
505
+ }
506
+
507
+ private handleStateTimeouts(state: WebRTCConnectionState): void {
508
+ if (state === 'connecting' || state === 'negotiating') {
509
+ this.startConnectionTimeout();
510
+ return;
511
+ }
512
+ this.clearConnectionTimeout();
513
+ }
514
+
515
+ private startConnectionTimeout(): void {
516
+ this.clearConnectionTimeout();
517
+ const timeoutMs = this.options.connectionTimeout ?? 30000;
518
+ this.connectionTimeoutId = setTimeout(() => {
519
+ if (this._state === 'connecting' || this._state === 'negotiating') {
520
+ const error = new Error('WebRTC connection timed out');
521
+ this.callbacks.onError?.(error, this._state);
522
+ this.handleTimeout('connection-timeout');
523
+ }
524
+ }, timeoutMs);
525
+ }
526
+
527
+ private clearConnectionTimeout(): void {
528
+ if (this.connectionTimeoutId) {
529
+ clearTimeout(this.connectionTimeoutId);
530
+ this.connectionTimeoutId = null;
531
+ }
532
+ }
533
+
534
+ private handleTimeout(reason: string): void {
535
+ this.intentionalClose = true;
536
+ this.cleanupPeerSessions();
537
+ if (this.ws) {
538
+ this.ws.close();
539
+ this.ws = null;
540
+ }
541
+ this.peerId = null;
542
+ this.setState('idle', reason);
543
+ this.intentionalClose = false;
544
+ }
545
+
546
+ private shouldAutoReconnect(): boolean {
547
+ return (this.options.autoReconnect ?? true) && !this.intentionalClose;
548
+ }
549
+
550
+ private updateConnectionState(): void {
551
+ const connectedPeers = Array.from(this.peers.values()).filter(
552
+ (p) => p.pc.iceConnectionState === 'connected' || p.pc.iceConnectionState === 'completed'
553
+ );
554
+
555
+ if (connectedPeers.length > 0) {
556
+ if (this._state !== 'connected') {
557
+ this.setState('connected', 'peer connected');
558
+ }
559
+ } else if (this.peers.size > 0) {
560
+ if (this._state === 'connected') {
561
+ this.setState('negotiating', 'no connected peers');
562
+ }
563
+ } else if (this._state === 'connected' || this._state === 'negotiating') {
564
+ this.setState('signaling', 'all peers left');
565
+ }
566
+ }
567
+
568
+ private send(msg: SignalMessage): void {
569
+ if (this.ws?.readyState === WebSocket.OPEN) {
570
+ this.ws.send(JSON.stringify(msg));
571
+ }
572
+ }
573
+
574
+ // =========================================================================
575
+ // Connection
576
+ // =========================================================================
577
+
578
+ /**
579
+ * Connect to the signaling server and start the call
580
+ */
581
+ async connect(): Promise<void> {
582
+ if (this._state !== 'idle' || this.isConnecting) return;
583
+ this.isConnecting = true;
584
+ this.intentionalClose = false;
585
+ this.reconnectManager.reset();
586
+
587
+ this.setState('connecting', 'connect() called');
588
+
589
+ try {
590
+ await this.ensureLocalStream();
591
+ this.openWebSocket();
592
+ } catch (err) {
593
+ // Clean up local media on failure
594
+ if (this.localStream) {
595
+ for (const track of this.localStream.getTracks()) {
596
+ track.stop();
597
+ }
598
+ this.localStream = null;
599
+ }
600
+ if (this.trackSource) {
601
+ this.trackSource.stop();
602
+ this.trackSource = null;
603
+ }
604
+ const error = err instanceof Error ? err : new Error(String(err));
605
+ this.callbacks.onError?.(error, this._state);
606
+ this.isConnecting = false;
607
+ this.setState('idle', 'error');
608
+ } finally {
609
+ this.isConnecting = false;
610
+ }
611
+ }
612
+
613
+ private async ensureLocalStream(): Promise<void> {
614
+ if (this.options.media === false || this.localStream) return;
615
+ if (this.options.media && typeof this.options.media === 'object' && 'getStream' in this.options.media) {
616
+ this.trackSource = this.options.media;
617
+ } else {
618
+ const constraints = (this.options.media as MediaStreamConstraints) ?? {
619
+ video: true,
620
+ audio: true,
621
+ };
622
+ this.trackSource = new UserMediaSource(constraints);
623
+ }
624
+ this.localStream = await this.trackSource.getStream();
625
+ this.callbacks.onLocalStream?.(this.localStream);
626
+ }
627
+
628
+ private openWebSocket(): void {
629
+ if (this.ws) {
630
+ const previous = this.ws;
631
+ this.ws = null;
632
+ previous.onclose = null;
633
+ previous.onerror = null;
634
+ previous.onmessage = null;
635
+ previous.onopen = null;
636
+ previous.close();
637
+ }
638
+
639
+ this.ws = new WebSocket(this.options.signalUrl);
640
+
641
+ this.ws.onopen = () => {
642
+ this.setState('signaling', 'WebSocket opened');
643
+ this.send({ t: 'join', roomId: this.options.roomId });
644
+ if (this.isReconnecting) {
645
+ this.isReconnecting = false;
646
+ this.reconnectManager.recordSuccess();
647
+ this.callbacks.onReconnected?.();
648
+ }
649
+ };
650
+
651
+ this.ws.onmessage = (event) => {
652
+ try {
653
+ const msg = JSON.parse(event.data) as SignalMessage;
654
+ void this.handleSignalingMessage(msg).catch((err) => {
655
+ this.callbacks.onError?.(
656
+ err instanceof Error ? err : new Error(String(err)),
657
+ this._state
658
+ );
659
+ });
660
+ } catch (_err) {
661
+ this.callbacks.onError?.(new Error('Invalid signaling message'), this._state);
662
+ }
663
+ };
664
+
665
+ this.ws.onerror = () => {
666
+ const error = new Error('WebSocket connection error');
667
+ this.callbacks.onError?.(error, this._state);
668
+ };
669
+
670
+ this.ws.onclose = () => {
671
+ if (this._state === 'idle') return;
672
+ if (this.intentionalClose) {
673
+ this.setState('idle', 'WebSocket closed');
674
+ return;
675
+ }
676
+ this.handleConnectionLoss('WebSocket closed');
677
+ };
678
+ }
679
+
680
+ private handleConnectionLoss(reason: string): void {
681
+ this.cleanupPeerSessions();
682
+ this.peerId = null;
683
+ if (this.shouldAutoReconnect()) {
684
+ this.scheduleReconnect(reason);
685
+ } else {
686
+ this.setState('idle', reason);
687
+ }
688
+ }
689
+
690
+ private scheduleReconnect(reason: string): void {
691
+ const nextAttempt = this.reconnectManager.getAttempts() + 1;
692
+ const maxAttempts = this.options.maxReconnectAttempts ?? 5;
693
+ if (nextAttempt > maxAttempts) {
694
+ this.callbacks.onReconnectFailed?.();
695
+ this.setState('idle', 'reconnect-failed');
696
+ return;
697
+ }
698
+
699
+ this.isReconnecting = true;
700
+ this.callbacks.onReconnecting?.(nextAttempt);
701
+ this.setState('connecting', `reconnecting:${reason}`);
702
+ this.reconnectManager.recordFailure();
703
+ }
704
+
705
+ private async reconnect(): Promise<void> {
706
+ if (!this.shouldAutoReconnect()) return;
707
+ this.cleanupPeerSessions();
708
+ this.peerId = null;
709
+ try {
710
+ await this.ensureLocalStream();
711
+ this.openWebSocket();
712
+ } catch (err) {
713
+ const error = err instanceof Error ? err : new Error(String(err));
714
+ this.callbacks.onError?.(error, this._state);
715
+ this.scheduleReconnect('reconnect-error');
716
+ }
717
+ }
718
+
719
+ private async handleSignalingMessage(msg: SignalMessage): Promise<void> {
720
+ switch (msg.t) {
721
+ case 'joined':
722
+ this.peerId = msg.peerId;
723
+ for (const existingPeerId of msg.peers) {
724
+ await this.createPeerSession(existingPeerId, true);
725
+ }
726
+ break;
727
+
728
+ case 'peer-joined':
729
+ this.callbacks.onPeerJoined?.(msg.peerId);
730
+ await this.createPeerSession(msg.peerId, false);
731
+ break;
732
+
733
+ case 'peer-left':
734
+ this.callbacks.onPeerLeft?.(msg.peerId);
735
+ this.closePeerSession(msg.peerId);
736
+ this.updateConnectionState();
737
+ break;
738
+
739
+ case 'sdp':
740
+ await this.handleRemoteSDP(msg.from, msg.description);
741
+ break;
742
+
743
+ case 'ice':
744
+ await this.handleRemoteICE(msg.from, msg.candidate);
745
+ break;
746
+
747
+ case 'error': {
748
+ const error = new Error(msg.message);
749
+ this.callbacks.onError?.(error, this._state);
750
+ break;
751
+ }
752
+ }
753
+ }
754
+
755
+ // =========================================================================
756
+ // Peer Session Management
757
+ // =========================================================================
758
+
759
+ private async createPeerSession(remotePeerId: string, isOfferer: boolean): Promise<PeerSession> {
760
+ if (this.peers.has(remotePeerId)) {
761
+ return this.peers.get(remotePeerId)!;
762
+ }
763
+
764
+ const iceServers = this.options.iceServers ?? DEFAULT_ICE_SERVERS;
765
+ const pc = new RTCPeerConnection({ iceServers });
766
+
767
+ const session: PeerSession = {
768
+ peerId: remotePeerId,
769
+ pc,
770
+ remoteStream: null,
771
+ dataChannels: new Map(),
772
+ makingOffer: false,
773
+ ignoreOffer: false,
774
+ hasRemoteDescription: false,
775
+ pendingCandidates: [],
776
+ isOfferer,
777
+ negotiationStarted: false,
778
+ hasIceCandidate: false,
779
+ iceGatheringTimer: null,
780
+ };
781
+
782
+ this.peers.set(remotePeerId, session);
783
+
784
+ if (this.localStream) {
785
+ for (const track of this.localStream.getTracks()) {
786
+ pc.addTrack(track, this.localStream);
787
+ }
788
+ }
789
+
790
+ pc.ontrack = (event) => {
791
+ if (event.streams?.[0]) {
792
+ if (session.remoteStream !== event.streams[0]) {
793
+ session.remoteStream = event.streams[0];
794
+ this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
795
+ }
796
+ } else {
797
+ if (!session.remoteStream) {
798
+ session.remoteStream = new MediaStream([event.track]);
799
+ this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
800
+ } else {
801
+ session.remoteStream.addTrack(event.track);
802
+ this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
803
+ }
804
+ }
805
+
806
+ this.callbacks.onTrackAdded?.(remotePeerId, event.track, session.remoteStream!);
807
+ this.updateConnectionState();
808
+ };
809
+
810
+ pc.ondatachannel = (event) => {
811
+ this.setupDataChannel(session, event.channel);
812
+ };
813
+
814
+ pc.onicecandidate = (event) => {
815
+ if (event.candidate) {
816
+ session.hasIceCandidate = true;
817
+ if (session.iceGatheringTimer) {
818
+ clearTimeout(session.iceGatheringTimer);
819
+ session.iceGatheringTimer = null;
820
+ }
821
+ this.callbacks.onIceCandidate?.(remotePeerId, event.candidate.toJSON());
822
+ this.send({
823
+ t: 'ice',
824
+ from: this.peerId!,
825
+ to: remotePeerId,
826
+ candidate: event.candidate.toJSON(),
827
+ });
828
+ }
829
+ };
830
+
831
+ this.scheduleIceGatheringTimeout(session);
832
+
833
+ pc.onnegotiationneeded = async () => {
834
+ // If we're not the offerer and haven't received a remote description yet,
835
+ // don't send an offer - wait for the other peer's offer
836
+ if (!session.isOfferer && !session.hasRemoteDescription && !session.negotiationStarted) {
837
+ return;
838
+ }
839
+
840
+ try {
841
+ session.makingOffer = true;
842
+ await pc.setLocalDescription();
843
+ this.send({
844
+ t: 'sdp',
845
+ from: this.peerId!,
846
+ to: remotePeerId,
847
+ description: pc.localDescription!,
848
+ });
849
+ } catch (err) {
850
+ const error = err instanceof Error ? err : new Error(String(err));
851
+ this.callbacks.onError?.(error, this._state);
852
+ } finally {
853
+ session.makingOffer = false;
854
+ }
855
+ };
856
+
857
+ pc.oniceconnectionstatechange = () => {
858
+ const iceState = pc.iceConnectionState;
859
+ this.callbacks.onIceStateChange?.(remotePeerId, iceState);
860
+ this.updateConnectionState();
861
+
862
+ if (iceState === 'failed') {
863
+ const error = new Error(`ICE connection failed for peer ${remotePeerId}`);
864
+ this.callbacks.onError?.(error, this._state);
865
+ this.handleConnectionLoss('ice-failed');
866
+ }
867
+ };
868
+
869
+ if (isOfferer) {
870
+ if (this.options.dataChannels) {
871
+ for (const config of this.options.dataChannels) {
872
+ const channel = pc.createDataChannel(config.label, {
873
+ ordered: config.ordered ?? true,
874
+ maxPacketLifeTime: config.maxPacketLifeTime,
875
+ maxRetransmits: config.maxRetransmits,
876
+ protocol: config.protocol,
877
+ });
878
+ this.setupDataChannel(session, channel);
879
+ }
880
+ }
881
+
882
+ this.setState('negotiating', 'creating offer');
883
+ this.callbacks.onNegotiationStart?.(remotePeerId);
884
+ await this.createOffer(session);
885
+ }
886
+
887
+ return session;
888
+ }
889
+
890
+ private async createOffer(session: PeerSession): Promise<void> {
891
+ try {
892
+ session.makingOffer = true;
893
+ session.negotiationStarted = true;
894
+ const offer = await session.pc.createOffer();
895
+ await session.pc.setLocalDescription(offer);
896
+
897
+ this.send({
898
+ t: 'sdp',
899
+ from: this.peerId!,
900
+ to: session.peerId,
901
+ description: session.pc.localDescription!,
902
+ });
903
+ } finally {
904
+ session.makingOffer = false;
905
+ }
906
+ }
907
+
908
+ private async handleRemoteSDP(
909
+ fromPeerId: string,
910
+ description: RTCSessionDescriptionInit
911
+ ): Promise<void> {
912
+ let session = this.peers.get(fromPeerId);
913
+ if (!session) {
914
+ session = await this.createPeerSession(fromPeerId, false);
915
+ }
916
+
917
+ const pc = session.pc;
918
+ const isOffer = description.type === 'offer';
919
+ const polite = this.basePolite ?? !this.isOffererFor(fromPeerId);
920
+ const offerCollision = isOffer && (session.makingOffer || pc.signalingState !== 'stable');
921
+
922
+ session.ignoreOffer = !polite && offerCollision;
923
+ if (session.ignoreOffer) return;
924
+
925
+ if (this._state === 'signaling') {
926
+ this.setState('negotiating', 'received SDP');
927
+ this.callbacks.onNegotiationStart?.(fromPeerId);
928
+ }
929
+
930
+ await pc.setRemoteDescription(description);
931
+ session.hasRemoteDescription = true;
932
+
933
+ for (const candidate of session.pendingCandidates) {
934
+ try {
935
+ await pc.addIceCandidate(candidate);
936
+ } catch (err) {
937
+ if (!session.ignoreOffer) {
938
+ console.warn('Failed to add buffered ICE candidate:', err);
939
+ }
940
+ }
941
+ }
942
+ session.pendingCandidates = [];
943
+
944
+ if (isOffer) {
945
+ session.negotiationStarted = true;
946
+ await pc.setLocalDescription();
947
+ this.send({
948
+ t: 'sdp',
949
+ from: this.peerId!,
950
+ to: fromPeerId,
951
+ description: pc.localDescription!,
952
+ });
953
+ }
954
+
955
+ this.callbacks.onNegotiationComplete?.(fromPeerId);
956
+ }
957
+
958
+ private isOffererFor(remotePeerId: string): boolean {
959
+ return this.peerId! > remotePeerId;
960
+ }
961
+
962
+ private async handleRemoteICE(
963
+ fromPeerId: string,
964
+ candidate: RTCIceCandidateInit
965
+ ): Promise<void> {
966
+ const session = this.peers.get(fromPeerId);
967
+ if (!session || !session.hasRemoteDescription) {
968
+ if (session) {
969
+ session.pendingCandidates.push(candidate);
970
+ }
971
+ return;
972
+ }
973
+
974
+ try {
975
+ await session.pc.addIceCandidate(candidate);
976
+ } catch (err) {
977
+ if (!session.ignoreOffer) {
978
+ console.warn('Failed to add ICE candidate:', err);
979
+ }
980
+ }
981
+ }
982
+
983
+ private closePeerSession(peerId: string): void {
984
+ const session = this.peers.get(peerId);
985
+ if (!session) return;
986
+
987
+ // Clear ICE gathering timer if exists
988
+ if (session.iceGatheringTimer) {
989
+ clearTimeout(session.iceGatheringTimer);
990
+ session.iceGatheringTimer = null;
991
+ }
992
+
993
+ // Close data channels
994
+ for (const channel of session.dataChannels.values()) {
995
+ channel.close();
996
+ }
997
+ session.dataChannels.clear();
998
+
999
+ // Clear all event handlers before closing to prevent memory leaks
1000
+ const pc = session.pc;
1001
+ pc.ontrack = null;
1002
+ pc.ondatachannel = null;
1003
+ pc.onicecandidate = null;
1004
+ pc.onnegotiationneeded = null;
1005
+ pc.oniceconnectionstatechange = null;
1006
+
1007
+ pc.close();
1008
+ this.peers.delete(peerId);
1009
+ }
1010
+
1011
+ private cleanupPeerSessions(): void {
1012
+ for (const peerId of this.peers.keys()) {
1013
+ this.closePeerSession(peerId);
1014
+ }
1015
+ this.peers.clear();
1016
+ }
1017
+
1018
+ private scheduleIceGatheringTimeout(session: PeerSession): void {
1019
+ const timeoutMs = this.options.iceGatheringTimeout ?? 10000;
1020
+ if (timeoutMs <= 0) return;
1021
+ if (session.iceGatheringTimer) {
1022
+ clearTimeout(session.iceGatheringTimer);
1023
+ }
1024
+ session.iceGatheringTimer = setTimeout(() => {
1025
+ if (!session.hasIceCandidate) {
1026
+ console.warn(`ICE gathering timeout for peer ${session.peerId}`);
1027
+ }
1028
+ }, timeoutMs);
1029
+ }
1030
+
1031
+ // =========================================================================
1032
+ // Data Channel
1033
+ // =========================================================================
1034
+
1035
+ private setupDataChannel(session: PeerSession, channel: RTCDataChannel): void {
1036
+ const label = channel.label;
1037
+ const peerId = session.peerId;
1038
+ session.dataChannels.set(label, channel);
1039
+
1040
+ channel.onopen = () => {
1041
+ this.callbacks.onDataChannelOpen?.(peerId, label);
1042
+ if (this.isDataOnly && this._state !== 'connected') {
1043
+ this.updateConnectionState();
1044
+ }
1045
+ };
1046
+
1047
+ channel.onclose = () => {
1048
+ session.dataChannels.delete(label);
1049
+ this.callbacks.onDataChannelClose?.(peerId, label);
1050
+ };
1051
+
1052
+ channel.onmessage = (event) => {
1053
+ const data = event.data;
1054
+ if (typeof data === 'string') {
1055
+ try {
1056
+ const parsed = JSON.parse(data);
1057
+ this.callbacks.onDataChannelMessage?.(peerId, label, parsed);
1058
+ } catch {
1059
+ this.callbacks.onDataChannelMessage?.(peerId, label, data);
1060
+ }
1061
+ } else {
1062
+ this.callbacks.onDataChannelMessage?.(peerId, label, data);
1063
+ }
1064
+ };
1065
+
1066
+ channel.onerror = (event) => {
1067
+ const error =
1068
+ event instanceof ErrorEvent
1069
+ ? new Error(event.message)
1070
+ : new Error('Data channel error');
1071
+ this.callbacks.onDataChannelError?.(peerId, label, error);
1072
+ };
1073
+ }
1074
+
1075
+ /**
1076
+ * Create a new data channel to all connected peers.
1077
+ */
1078
+ createDataChannel(config: DataChannelConfig): Map<string, RTCDataChannel> {
1079
+ const channels = new Map<string, RTCDataChannel>();
1080
+ for (const [peerId, session] of this.peers) {
1081
+ const channel = session.pc.createDataChannel(config.label, {
1082
+ ordered: config.ordered ?? true,
1083
+ maxPacketLifeTime: config.maxPacketLifeTime,
1084
+ maxRetransmits: config.maxRetransmits,
1085
+ protocol: config.protocol,
1086
+ });
1087
+ this.setupDataChannel(session, channel);
1088
+ channels.set(peerId, channel);
1089
+ }
1090
+ return channels;
1091
+ }
1092
+
1093
+ /**
1094
+ * Get a data channel by label from a specific peer.
1095
+ */
1096
+ getDataChannel(peerId: string, label: string): RTCDataChannel | undefined {
1097
+ return this.peers.get(peerId)?.dataChannels.get(label);
1098
+ }
1099
+
1100
+ /**
1101
+ * Get all open data channel labels.
1102
+ */
1103
+ getDataChannelLabels(): string[] {
1104
+ const labels = new Set<string>();
1105
+ for (const session of this.peers.values()) {
1106
+ for (const label of session.dataChannels.keys()) {
1107
+ labels.add(label);
1108
+ }
1109
+ }
1110
+ return Array.from(labels);
1111
+ }
1112
+
1113
+ /**
1114
+ * Get the state of a data channel for a specific peer.
1115
+ */
1116
+ getDataChannelState(peerId: string, label: string): DataChannelState | null {
1117
+ const channel = this.peers.get(peerId)?.dataChannels.get(label);
1118
+ return channel ? (channel.readyState as DataChannelState) : null;
1119
+ }
1120
+
1121
+ /**
1122
+ * Send a string message to all peers on a data channel.
1123
+ */
1124
+ sendString(label: string, data: string): boolean {
1125
+ let sent = false;
1126
+ for (const session of this.peers.values()) {
1127
+ const channel = session.dataChannels.get(label);
1128
+ if (channel?.readyState === 'open') {
1129
+ channel.send(data);
1130
+ sent = true;
1131
+ }
1132
+ }
1133
+ return sent;
1134
+ }
1135
+
1136
+ /**
1137
+ * Send a string message to a specific peer.
1138
+ */
1139
+ sendStringTo(peerId: string, label: string, data: string): boolean {
1140
+ const channel = this.peers.get(peerId)?.dataChannels.get(label);
1141
+ if (!channel || channel.readyState !== 'open') return false;
1142
+ channel.send(data);
1143
+ return true;
1144
+ }
1145
+
1146
+ /**
1147
+ * Send binary data to all peers on a data channel.
1148
+ */
1149
+ sendBinary(label: string, data: ArrayBuffer | Uint8Array): boolean {
1150
+ let sent = false;
1151
+ const buffer =
1152
+ data instanceof ArrayBuffer
1153
+ ? data
1154
+ : (() => {
1155
+ const buf = new ArrayBuffer(data.byteLength);
1156
+ new Uint8Array(buf).set(data);
1157
+ return buf;
1158
+ })();
1159
+
1160
+ for (const session of this.peers.values()) {
1161
+ const channel = session.dataChannels.get(label);
1162
+ if (channel?.readyState === 'open') {
1163
+ channel.send(buffer);
1164
+ sent = true;
1165
+ }
1166
+ }
1167
+ return sent;
1168
+ }
1169
+
1170
+ /**
1171
+ * Send binary data to a specific peer.
1172
+ */
1173
+ sendBinaryTo(peerId: string, label: string, data: ArrayBuffer | Uint8Array): boolean {
1174
+ const channel = this.peers.get(peerId)?.dataChannels.get(label);
1175
+ if (!channel || channel.readyState !== 'open') return false;
1176
+
1177
+ if (data instanceof ArrayBuffer) {
1178
+ channel.send(data);
1179
+ } else {
1180
+ const buffer = new ArrayBuffer(data.byteLength);
1181
+ new Uint8Array(buffer).set(data);
1182
+ channel.send(buffer);
1183
+ }
1184
+ return true;
1185
+ }
1186
+
1187
+ /**
1188
+ * Send JSON data to all peers on a data channel.
1189
+ */
1190
+ sendJSON(label: string, data: unknown): boolean {
1191
+ return this.sendString(label, JSON.stringify(data));
1192
+ }
1193
+
1194
+ /**
1195
+ * Send JSON data to a specific peer.
1196
+ */
1197
+ sendJSONTo(peerId: string, label: string, data: unknown): boolean {
1198
+ return this.sendStringTo(peerId, label, JSON.stringify(data));
1199
+ }
1200
+
1201
+ /**
1202
+ * Close a specific data channel on all peers.
1203
+ */
1204
+ closeDataChannel(label: string): boolean {
1205
+ let closed = false;
1206
+ for (const session of this.peers.values()) {
1207
+ const channel = session.dataChannels.get(label);
1208
+ if (channel) {
1209
+ channel.close();
1210
+ session.dataChannels.delete(label);
1211
+ closed = true;
1212
+ }
1213
+ }
1214
+ return closed;
1215
+ }
1216
+
1217
+ // =========================================================================
1218
+ // Media Controls
1219
+ // =========================================================================
1220
+
1221
+ /**
1222
+ * Mute or unmute audio
1223
+ */
1224
+ muteAudio(muted: boolean): void {
1225
+ if (this.localStream) {
1226
+ for (const track of this.localStream.getAudioTracks()) {
1227
+ track.enabled = !muted;
1228
+ }
1229
+ }
1230
+ this.isAudioMuted = muted;
1231
+ }
1232
+
1233
+ /**
1234
+ * Mute or unmute video
1235
+ */
1236
+ muteVideo(muted: boolean): void {
1237
+ if (this.localStream) {
1238
+ for (const track of this.localStream.getVideoTracks()) {
1239
+ track.enabled = !muted;
1240
+ }
1241
+ }
1242
+ this.isVideoMuted = muted;
1243
+ }
1244
+
1245
+ // =========================================================================
1246
+ // Screen Sharing
1247
+ // =========================================================================
1248
+
1249
+ /**
1250
+ * Start screen sharing, replacing the current video track.
1251
+ * @param options - Display media constraints
1252
+ */
1253
+ async startScreenShare(
1254
+ options: DisplayMediaStreamOptions = { video: true, audio: false }
1255
+ ): Promise<void> {
1256
+ if (this.isScreenSharing || this.isDataOnly) return;
1257
+
1258
+ const screenStream = await navigator.mediaDevices.getDisplayMedia(options);
1259
+ const screenTrack = screenStream.getVideoTracks()[0];
1260
+
1261
+ if (!screenTrack) {
1262
+ throw new Error('Failed to get screen video track');
1263
+ }
1264
+
1265
+ if (this.localStream) {
1266
+ const currentVideoTrack = this.localStream.getVideoTracks()[0];
1267
+ if (currentVideoTrack) {
1268
+ this.previousVideoTrack = currentVideoTrack;
1269
+ this.localStream.removeTrack(currentVideoTrack);
1270
+ }
1271
+ this.localStream.addTrack(screenTrack);
1272
+ }
1273
+
1274
+ for (const session of this.peers.values()) {
1275
+ const sender = session.pc.getSenders().find((s) => s.track?.kind === 'video');
1276
+ if (sender) {
1277
+ await sender.replaceTrack(screenTrack);
1278
+ } else {
1279
+ session.pc.addTrack(screenTrack, this.localStream!);
1280
+ }
1281
+ }
1282
+
1283
+ screenTrack.onended = () => {
1284
+ this.stopScreenShare();
1285
+ };
1286
+
1287
+ this.isScreenSharing = true;
1288
+ this.callbacks.onScreenShareStart?.();
1289
+ }
1290
+
1291
+ /**
1292
+ * Stop screen sharing and restore the previous video track.
1293
+ */
1294
+ async stopScreenShare(): Promise<void> {
1295
+ if (!this.isScreenSharing) return;
1296
+
1297
+ const screenTrack = this.localStream?.getVideoTracks()[0];
1298
+ if (screenTrack) {
1299
+ screenTrack.stop();
1300
+ this.localStream?.removeTrack(screenTrack);
1301
+ }
1302
+
1303
+ if (this.previousVideoTrack && this.localStream) {
1304
+ this.localStream.addTrack(this.previousVideoTrack);
1305
+
1306
+ for (const session of this.peers.values()) {
1307
+ const sender = session.pc.getSenders().find((s) => s.track?.kind === 'video');
1308
+ if (sender) {
1309
+ await sender.replaceTrack(this.previousVideoTrack);
1310
+ }
1311
+ }
1312
+ }
1313
+
1314
+ this.previousVideoTrack = null;
1315
+ this.isScreenSharing = false;
1316
+ this.callbacks.onScreenShareStop?.();
1317
+ }
1318
+
1319
+ // =========================================================================
1320
+ // Connection Stats
1321
+ // =========================================================================
1322
+
1323
+ /**
1324
+ * Get raw stats for a peer connection.
1325
+ */
1326
+ async getRawStats(peerId: string): Promise<RTCStatsReport | null> {
1327
+ const session = this.peers.get(peerId);
1328
+ if (!session) return null;
1329
+ return session.pc.getStats();
1330
+ }
1331
+
1332
+ /**
1333
+ * Get raw stats for all peer connections.
1334
+ */
1335
+ async getAllRawStats(): Promise<Map<string, RTCStatsReport>> {
1336
+ const stats = new Map<string, RTCStatsReport>();
1337
+ for (const [peerId, session] of this.peers) {
1338
+ stats.set(peerId, await session.pc.getStats());
1339
+ }
1340
+ return stats;
1341
+ }
1342
+
1343
+ /**
1344
+ * Get a normalized quality summary for a peer connection.
1345
+ */
1346
+ async getQualitySummary(peerId: string): Promise<ConnectionQualitySummary | null> {
1347
+ const session = this.peers.get(peerId);
1348
+ if (!session) return null;
1349
+
1350
+ const stats = await session.pc.getStats();
1351
+ return this.parseStatsToSummary(stats, session);
1352
+ }
1353
+
1354
+ /**
1355
+ * Get quality summaries for all peer connections.
1356
+ */
1357
+ async getAllQualitySummaries(): Promise<Map<string, ConnectionQualitySummary>> {
1358
+ const summaries = new Map<string, ConnectionQualitySummary>();
1359
+ for (const [peerId, session] of this.peers) {
1360
+ const stats = await session.pc.getStats();
1361
+ summaries.set(peerId, this.parseStatsToSummary(stats, session));
1362
+ }
1363
+ return summaries;
1364
+ }
1365
+
1366
+ private parseStatsToSummary(
1367
+ stats: RTCStatsReport,
1368
+ session: PeerSession
1369
+ ): ConnectionQualitySummary {
1370
+ const summary: ConnectionQualitySummary = { timestamp: Date.now() };
1371
+ const now = Date.now();
1372
+ const prevStats = session.lastStats;
1373
+ const prevTime = session.lastStatsTime ?? now;
1374
+ const timeDelta = (now - prevTime) / 1000;
1375
+
1376
+ const bitrate: ConnectionQualitySummary['bitrate'] = {};
1377
+
1378
+ stats.forEach((report) => {
1379
+ if (report.type === 'candidate-pair' && report.state === 'succeeded') {
1380
+ summary.rtt = report.currentRoundTripTime
1381
+ ? report.currentRoundTripTime * 1000
1382
+ : undefined;
1383
+
1384
+ const localCandidateId = report.localCandidateId;
1385
+ const remoteCandidateId = report.remoteCandidateId;
1386
+ const localCandidate = this.getStatReport(stats, localCandidateId);
1387
+ const remoteCandidate = this.getStatReport(stats, remoteCandidateId);
1388
+
1389
+ summary.candidatePair = {
1390
+ localType: localCandidate?.candidateType,
1391
+ remoteType: remoteCandidate?.candidateType,
1392
+ protocol: localCandidate?.protocol,
1393
+ usingRelay:
1394
+ localCandidate?.candidateType === 'relay' ||
1395
+ remoteCandidate?.candidateType === 'relay',
1396
+ };
1397
+ }
1398
+
1399
+ if (report.type === 'inbound-rtp' && report.kind === 'audio') {
1400
+ summary.jitter = report.jitter ? report.jitter * 1000 : undefined;
1401
+ if (report.packetsLost !== undefined && report.packetsReceived !== undefined) {
1402
+ const total = report.packetsLost + report.packetsReceived;
1403
+ if (total > 0) {
1404
+ summary.packetLossPercent = (report.packetsLost / total) * 100;
1405
+ }
1406
+ }
1407
+
1408
+ if (prevStats && timeDelta > 0) {
1409
+ const prev = this.findMatchingReport(prevStats, report.id);
1410
+ if (prev?.bytesReceived !== undefined && report.bytesReceived !== undefined) {
1411
+ bitrate.audio = bitrate.audio ?? {};
1412
+ bitrate.audio.inbound =
1413
+ ((report.bytesReceived - prev.bytesReceived) * 8) / timeDelta;
1414
+ }
1415
+ }
1416
+ }
1417
+
1418
+ if (report.type === 'inbound-rtp' && report.kind === 'video') {
1419
+ summary.video = {
1420
+ framesPerSecond: report.framesPerSecond,
1421
+ framesDropped: report.framesDropped,
1422
+ frameWidth: report.frameWidth,
1423
+ frameHeight: report.frameHeight,
1424
+ };
1425
+
1426
+ if (prevStats && timeDelta > 0) {
1427
+ const prev = this.findMatchingReport(prevStats, report.id);
1428
+ if (prev?.bytesReceived !== undefined && report.bytesReceived !== undefined) {
1429
+ bitrate.video = bitrate.video ?? {};
1430
+ bitrate.video.inbound =
1431
+ ((report.bytesReceived - prev.bytesReceived) * 8) / timeDelta;
1432
+ }
1433
+ }
1434
+ }
1435
+
1436
+ if (report.type === 'outbound-rtp' && prevStats && timeDelta > 0) {
1437
+ const prev = this.findMatchingReport(prevStats, report.id);
1438
+ if (prev?.bytesSent !== undefined && report.bytesSent !== undefined) {
1439
+ const bps = ((report.bytesSent - prev.bytesSent) * 8) / timeDelta;
1440
+ if (report.kind === 'audio') {
1441
+ bitrate.audio = bitrate.audio ?? {};
1442
+ bitrate.audio.outbound = bps;
1443
+ } else if (report.kind === 'video') {
1444
+ bitrate.video = bitrate.video ?? {};
1445
+ bitrate.video.outbound = bps;
1446
+ }
1447
+ }
1448
+ }
1449
+ });
1450
+
1451
+ if (Object.keys(bitrate).length > 0) {
1452
+ summary.bitrate = bitrate;
1453
+ }
1454
+
1455
+ session.lastStats = stats;
1456
+ session.lastStatsTime = now;
1457
+
1458
+ return summary;
1459
+ }
1460
+
1461
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1462
+ private findMatchingReport(stats: RTCStatsReport, id: string): any {
1463
+ return this.getStatReport(stats, id);
1464
+ }
1465
+
1466
+ // RTCStatsReport extends Map but bun-types may not expose .get() properly
1467
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1468
+ private getStatReport(stats: RTCStatsReport, id: string): any {
1469
+ // Use Map.prototype.get via cast
1470
+ const mapLike = stats as unknown as Map<string, unknown>;
1471
+ return mapLike.get(id);
1472
+ }
1473
+
1474
+ // =========================================================================
1475
+ // Recording
1476
+ // =========================================================================
1477
+
1478
+ /**
1479
+ * Start recording a stream.
1480
+ * @param streamId - 'local' for local stream, or a peer ID for remote stream
1481
+ * @param options - Recording options
1482
+ */
1483
+ startRecording(streamId: string, options?: RecordingOptions): RecordingHandle | null {
1484
+ const stream = streamId === 'local' ? this.localStream : this.getRemoteStream(streamId);
1485
+ if (!stream) return null;
1486
+
1487
+ const mimeType = this.selectMimeType(stream, options?.mimeType);
1488
+ if (!mimeType) return null;
1489
+
1490
+ const recorder = new MediaRecorder(stream, {
1491
+ mimeType,
1492
+ audioBitsPerSecond: options?.audioBitsPerSecond,
1493
+ videoBitsPerSecond: options?.videoBitsPerSecond,
1494
+ });
1495
+
1496
+ const chunks: Blob[] = [];
1497
+ recorder.ondataavailable = (event) => {
1498
+ if (event.data.size > 0) {
1499
+ chunks.push(event.data);
1500
+ }
1501
+ };
1502
+
1503
+ this.recordings.set(streamId, { recorder, chunks });
1504
+ recorder.start(1000);
1505
+
1506
+ return {
1507
+ stop: () =>
1508
+ new Promise<Blob>((resolve) => {
1509
+ recorder.onstop = () => {
1510
+ this.recordings.delete(streamId);
1511
+ resolve(new Blob(chunks, { type: mimeType }));
1512
+ };
1513
+ recorder.stop();
1514
+ }),
1515
+ pause: () => recorder.pause(),
1516
+ resume: () => recorder.resume(),
1517
+ get state(): RecordingState {
1518
+ return recorder.state as RecordingState;
1519
+ },
1520
+ };
1521
+ }
1522
+
1523
+ /**
1524
+ * Check if a stream is being recorded.
1525
+ */
1526
+ isRecording(streamId: string): boolean {
1527
+ const recording = this.recordings.get(streamId);
1528
+ return recording?.recorder.state === 'recording';
1529
+ }
1530
+
1531
+ /**
1532
+ * Stop all recordings and return the blobs.
1533
+ */
1534
+ async stopAllRecordings(): Promise<Map<string, Blob>> {
1535
+ const blobs = new Map<string, Blob>();
1536
+ const promises: Promise<void>[] = [];
1537
+
1538
+ for (const [streamId, { recorder, chunks }] of this.recordings) {
1539
+ const mimeType = recorder.mimeType;
1540
+ promises.push(
1541
+ new Promise<void>((resolve) => {
1542
+ const timeout = setTimeout(() => {
1543
+ console.warn(`Recording stop timeout for stream ${streamId}`);
1544
+ resolve(); // Don't block other recordings
1545
+ }, 5000);
1546
+
1547
+ recorder.onstop = () => {
1548
+ clearTimeout(timeout);
1549
+ blobs.set(streamId, new Blob(chunks, { type: mimeType }));
1550
+ resolve();
1551
+ };
1552
+ recorder.stop();
1553
+ })
1554
+ );
1555
+ }
1556
+
1557
+ await Promise.all(promises);
1558
+ this.recordings.clear();
1559
+ return blobs;
1560
+ }
1561
+
1562
+ private selectMimeType(stream: MediaStream, preferred?: string): string | null {
1563
+ if (preferred && MediaRecorder.isTypeSupported(preferred)) {
1564
+ return preferred;
1565
+ }
1566
+
1567
+ const hasVideo = stream.getVideoTracks().length > 0;
1568
+ const hasAudio = stream.getAudioTracks().length > 0;
1569
+
1570
+ const videoTypes = [
1571
+ 'video/webm;codecs=vp9,opus',
1572
+ 'video/webm;codecs=vp8,opus',
1573
+ 'video/webm',
1574
+ 'video/mp4',
1575
+ ];
1576
+ const audioTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg'];
1577
+
1578
+ const candidates = hasVideo ? videoTypes : hasAudio ? audioTypes : [];
1579
+ for (const type of candidates) {
1580
+ if (MediaRecorder.isTypeSupported(type)) {
1581
+ return type;
1582
+ }
1583
+ }
1584
+ return null;
1585
+ }
1586
+
1587
+ // =========================================================================
1588
+ // Cleanup
1589
+ // =========================================================================
1590
+
1591
+ /**
1592
+ * End the call and disconnect from all peers
1593
+ */
1594
+ hangup(): void {
1595
+ this.intentionalClose = true;
1596
+ this.reconnectManager.cancel();
1597
+ this.clearConnectionTimeout();
1598
+ this.cleanupPeerSessions();
1599
+
1600
+ if (this.isScreenSharing) {
1601
+ const screenTrack = this.localStream?.getVideoTracks()[0];
1602
+ screenTrack?.stop();
1603
+ }
1604
+
1605
+ if (this.trackSource) {
1606
+ this.trackSource.stop();
1607
+ this.trackSource = null;
1608
+ }
1609
+ this.localStream = null;
1610
+ this.previousVideoTrack = null;
1611
+
1612
+ if (this.ws) {
1613
+ this.ws.close();
1614
+ this.ws = null;
1615
+ }
1616
+
1617
+ this.peerId = null;
1618
+ this.isScreenSharing = false;
1619
+ this.setState('idle', 'hangup');
1620
+ this.intentionalClose = false;
1621
+ }
1622
+
1623
+ /**
1624
+ * Clean up all resources
1625
+ */
1626
+ dispose(): void {
1627
+ this.stopAllRecordings();
1628
+ this.hangup();
1629
+ this.reconnectManager.dispose();
1630
+ }
1631
+ }