import type { ChannelId, ServerId, UserId } from "~/lib/api/types"; import { createPrefixedLogger } from "~/lib/utils"; import { type ClientMessage, ClientMessageType, ConnectionState, ErrorCode, type EventData, EventType, type ServerMessage, ServerMessageType, } from "./types"; export type GatewayEvents = { [K in EventType]: (data: Extract["data"]) => void; }; export type ControlEvents = { stateChange: (state: ConnectionState) => void; error: (error: Error, code?: ErrorCode) => void; authenticated: (userId: UserId, sessionKey: string) => void; }; export interface GatewayClientOptions { reconnect?: boolean; reconnectDelay?: number; maxReconnectAttempts?: number; } export class GatewayClient { private socket: WebSocket | null = null; private state: ConnectionState = ConnectionState.DISCONNECTED; private url: string; private token: string | null = null; private sessionKey: string | null = null; private userId: string | null = null; private reconnectAttempts = 0; private reconnectTimeout: NodeJS.Timeout | null = null; private eventHandlers: Partial = {}; private serverEventHandlers: Partial = {}; private options: Required; private closeInitiatedByClient = false; private connectionLock = false; constructor(url: string, options: GatewayClientOptions = {}) { this.url = url; this.options = { reconnect: options.reconnect ?? true, reconnectDelay: options.reconnectDelay ?? 5000, maxReconnectAttempts: options.maxReconnectAttempts ?? 10, }; } // Public methods public connect(token: string): void { logger.log("Connecting to %s", this.url); if (this.connectionLock) { logger.warn("Connection already in progress"); return; } if (this.token === token) { logger.warn("Token is the same as the current token"); return; } this.connectionLock = true; if (this.state !== ConnectionState.DISCONNECTED) { this.disconnect(); } this.token = token; this.closeInitiatedByClient = false; this.reconnectAttempts = 0; this.connectToWebSocket(); } public disconnect(): void { logger.log("Disconnecting"); this.closeInitiatedByClient = true; this.cleanupSocket(); this.setState(ConnectionState.DISCONNECTED); this.connectionLock = false; } public updateVoiceState(serverId: ServerId, channelId: ChannelId): void { this.sendMessage({ type: ClientMessageType.VOICE_STATE_UPDATE, data: { serverId, channelId }, }); } public requestVoiceStates(serverId: ServerId): void { this.sendMessage({ type: ClientMessageType.REQUEST_VOICE_STATES, data: { serverId }, }); } public onEvent(event: K | string, handler: GatewayEvents[K]): void { this.serverEventHandlers[event as K] = handler; } public offEvent(event: K): void { delete this.serverEventHandlers[event]; } public onControl(event: K, handler: ControlEvents[K]): void { this.eventHandlers[event] = handler; } public offControl(event: K): void { delete this.eventHandlers[event]; } public get connectionState(): ConnectionState { return this.state; } public get currentUserId(): UserId | null { return this.userId; } public get currentSessionKey(): string | null { return this.sessionKey; } // Private methods private connectToWebSocket(): void { this.setState(ConnectionState.CONNECTING); try { this.socket = new WebSocket(this.url); this.socket.onopen = this.onSocketOpen.bind(this); this.socket.onmessage = this.onSocketMessage.bind(this); this.socket.onerror = this.onSocketError.bind(this); this.socket.onclose = this.onSocketClose.bind(this); } catch (error) { this.emitError(new Error("Failed to create WebSocket connection")); this.setState(ConnectionState.ERROR); } } private onSocketOpen(): void { this.connectionLock = false; logger.log("Socket opened"); this.setState(ConnectionState.AUTHENTICATING); if (this.token) { this.sendMessage({ type: ClientMessageType.AUTHENTICATE, data: { token: this.token }, }); } else { this.emitError(new Error("No authentication token provided")); this.disconnect(); } } private onSocketMessage(event: MessageEvent): void { try { const message = JSON.parse(event.data) as ServerMessage; this.handleServerMessage(message); } catch (error) { this.emitError( new Error("Failed to parse WebSocket message", { cause: error, }), ); } } private onSocketError(event: Event): void { this.connectionLock = false; logger.log("Socket error: %s", event); this.emitError(new Error("WebSocket error occurred")); } private onSocketClose(event: CloseEvent): void { logger.log("Socket closed: %s", event); this.connectionLock = false; if ( this.options.reconnect && !this.closeInitiatedByClient && this.reconnectAttempts < this.options.maxReconnectAttempts ) { logger.log( "Reconnecting in %d seconds (%d/%d)", this.options.reconnectDelay / 1000, this.reconnectAttempts + 1, this.options.maxReconnectAttempts, ); this.reconnectAttempts++; this.reconnectTimeout = setTimeout(() => { if (this.token) { this.connectToWebSocket(); } }, this.options.reconnectDelay); } else { this.setState(ConnectionState.DISCONNECTED); } } private handleServerMessage(message: ServerMessage): void { logger.log("Received message: ", message); switch (message.type) { case ServerMessageType.AUTHENTICATE_ACCEPTED: this.userId = message.data.userId; this.sessionKey = message.data.sessionKey; this.setState(ConnectionState.CONNECTED); this.emitControl("authenticated", message.data.userId, message.data.sessionKey); break; case ServerMessageType.AUTHENTICATE_DENIED: this.emitError(new Error("Authentication denied")); this.disconnect(); break; case ServerMessageType.ERROR: this.emitError(new Error(`Server error: ${message.data.code}`), message.data.code); break; case ServerMessageType.EVENT: this.handleEventMessage(message.data.event); break; default: console.warn("Unhandled server message type:", message); } } private handleEventMessage(event: EventData): void { this.emitEvent(event.type, event.data as any); } private sendMessage(message: ClientMessage): void { logger.log("Sending message: %o", message); if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); } else { this.emitError(new Error("Cannot send message: socket not connected")); } } private setState(state: ConnectionState): void { if (this.state !== state) { logger.log("State changed to %s", state); this.state = state; this.emitControl("stateChange", state); } } private emitError(error: Error, code?: ErrorCode): void { logger.error("Error: %s", error, error.cause); this.setState(ConnectionState.ERROR); this.emitControl("error", error, code); } private emitControl(event: K, ...args: Parameters): void { const handler = this.eventHandlers[event]; if (handler) { (handler as Function)(...args); } } private emitEvent(event: K, ...args: Parameters): void { const handler = this.serverEventHandlers[event]; if (handler) { (handler as Function)(...args); } } private cleanupSocket(): void { logger.log("Cleaning up socket"); if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } if (this.socket) { // Remove all event listeners this.socket.onopen = null; this.socket.onmessage = null; this.socket.onerror = null; this.socket.onclose = null; // Close the connection if it's still open if (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING) { this.socket.close(); } this.socket = null; } this.sessionKey = null; this.userId = null; } } const logger = createPrefixedLogger("%cGateway WS%c:", ["color: red; font-weight: bold;", ""]);