300 lines
9.1 KiB
TypeScript
300 lines
9.1 KiB
TypeScript
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<void> | 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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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;",
|
|
"",
|
|
]);
|