import { createPrefixedLogger } from "~/lib/utils"; import { ClientMessageType, ConnectionState, ServerMessageType, type ClientMessage, type ServerMessage, } from "./types"; export class WebRTCClient { private socket: WebSocket | null = null; private peerConnection: RTCPeerConnection | null = null; private onStateChange: (state: ConnectionState) => void; private onError: (error: Error) => void; private onRemoteStream: (stream: MediaStream) => void; private state: ConnectionState = ConnectionState.DISCONNECTED; private url: string; private connectionLock = false; private disconnectPromise: Promise | null = null; private disconnectResolve: (() => void) | null = null; constructor( url: string, onStateChange: (state: ConnectionState) => void, onError: (error: Error) => void, onRemoteStream: (stream: MediaStream) => void, ) { this.url = url; this.onStateChange = onStateChange; this.onError = onError; this.onRemoteStream = onRemoteStream; } public connect = async (token: string) => { if (this.connectionLock) { warn("WebRTC: Connection already in progress"); return; } this.connectionLock = true; if ( this.state !== ConnectionState.DISCONNECTED && this.state !== ConnectionState.ERROR ) { this.disconnect(); } if (this.disconnectPromise) { warn("WebRTC: Waiting for previous disconnect to complete"); try { await this.disconnectPromise; } catch (error) { console.error("WebRTC: Previous disconnect failed:", error); } } log("Connecting to %s", this.url); try { this.setState(ConnectionState.CONNECTING); this.socket = new WebSocket(this.url); this.socket.onopen = () => { log("Socket opened"); this.connectionLock = false; this.setState(ConnectionState.AUTHENTICATING); this.sendMessage({ type: ClientMessageType.AUTHENTICATE, data: { token }, }); }; this.socket.onmessage = this.handleServerMessage; this.socket.onerror = (event) => { this.handleError(new Error("WebSocket error occurred")); }; this.socket.onclose = (e) => { log("Socket closed", e); this.cleanupResources(); if (this.state !== ConnectionState.ERROR) { this.setState(ConnectionState.DISCONNECTED); } if (this.disconnectResolve) { this.disconnectResolve(); this.disconnectResolve = null; this.disconnectPromise = null; } }; } catch (error) { this.handleError( error instanceof Error ? error : new Error("Unknown error"), ); } }; public disconnect = (): void => { if (this.state === ConnectionState.DISCONNECTED) { return; } this.setState(ConnectionState.DISCONNECTING); this.connectionLock = false; if (this.socket && this.socket.readyState !== WebSocket.CLOSED) { // If we're already waiting for a disconnect, cancel it if (this.disconnectPromise) { this.disconnectResolve = null; this.disconnectPromise = null; } this.disconnectPromise = new Promise((resolve) => { this.disconnectResolve = resolve; }); const onSocketClose = () => { this.socket?.removeEventListener("close", onSocketClose); this.disconnectResolve?.(); this.disconnectResolve = null; this.disconnectPromise = null; }; this.socket.addEventListener("close", onSocketClose); if (this.socket.readyState !== WebSocket.CLOSING) { this.socket.close(1000, "WebRTC: Cleaning up resources"); } } else { this.cleanupResources(); this.setState(ConnectionState.DISCONNECTED); } }; public createOffer = async (localStream?: MediaStream): Promise => { if (this.state !== ConnectionState.CONNECTED) { this.handleError(new Error("Cannot create offer: not connected")); return; } try { // Create RTCPeerConnection with standard configuration const configuration: RTCConfiguration = { iceServers: [], }; this.peerConnection = new RTCPeerConnection(configuration); // Add local stream tracks if provided if (localStream) { localStream.getTracks().forEach((track) => { this.peerConnection!.addTrack(track, localStream); }); } // Handle ICE candidates this.peerConnection.onicecandidate = (event) => { if (event.candidate === null) { // ICE gathering completed } }; // Handle remote stream this.peerConnection.ontrack = (event) => { const [remoteStream] = event.streams; if (remoteStream) { this.onRemoteStream(remoteStream); } }; // Create offer and set local description const offer = await this.peerConnection.createOffer(); await this.peerConnection.setLocalDescription(offer); // Send offer to server if (this.peerConnection.localDescription) { this.sendMessage({ type: ClientMessageType.SDP_OFFER, data: { sdp: this.peerConnection.localDescription, }, }); } } catch (error) { this.handleError( error instanceof Error ? error : new Error("Error creating WebRTC offer"), ); } }; private handleServerMessage = async ( event: MessageEvent, ): Promise => { try { const message: ServerMessage = JSON.parse(event.data); log("Received message: %o", message); switch (message.type) { case ServerMessageType.AUTHENTICATE_ACCEPTED: this.setState(ConnectionState.CONNECTED); break; case ServerMessageType.AUTHENTICATE_DENIED: this.handleError(new Error("Authentication failed")); break; case ServerMessageType.SDP_ANSWER: await this.handleSdpAnswer(message.data.sdp); break; default: warn("Unhandled message type:", message); } } catch (error) { this.handleError( error instanceof Error ? error : new Error("Failed to process message"), ); } }; private handleSdpAnswer = async ( sdp: RTCSessionDescription, ): Promise => { log("Received SDP answer: %o", sdp); if (!this.peerConnection) { this.handleError(new Error("No peer connection established")); return; } try { await this.peerConnection.setRemoteDescription(sdp); } catch (error) { this.handleError( error instanceof Error ? error : new Error("Error setting remote description"), ); } }; private sendMessage = (message: ClientMessage): void => { log("Sending message: %o", message); if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); } else { this.handleError( new Error("Cannot send message: socket not connected"), ); } }; private setState = (state: ConnectionState): void => { log("State changed to %s", state); this.state = state; this.onStateChange(state); }; private handleError = (error: Error): void => { log("Error: %s", error.message); this.setState(ConnectionState.ERROR); this.onError(error); }; private cleanupResources = (): void => { log("Cleaning up resources"); if (this.peerConnection) { this.peerConnection.close(); this.peerConnection = null; } if (this.socket) { this.socket.close(1000, "WebRTC: Cleaning up resources"); this.socket = null; } }; } const { log, warn, ...other } = createPrefixedLogger("%cWebRTC WS%c:", [ "color: blue; font-weight: bold;", "", ]);