Files
diplom-frontend/app/lib/websocket/gateway/client.ts
2025-05-20 04:16:03 +03:00

309 lines
9.5 KiB
TypeScript

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<EventData, { type: K }>['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<ControlEvents> = {};
private serverEventHandlers: Partial<GatewayEvents> = {};
private options: Required<GatewayClientOptions>;
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<K extends keyof GatewayEvents>(event: K | string, handler: GatewayEvents[K]): void {
this.serverEventHandlers[event as K] = handler;
}
public offEvent<K extends keyof GatewayEvents>(event: K): void {
delete this.serverEventHandlers[event];
}
public onControl<K extends keyof ControlEvents>(event: K, handler: ControlEvents[K]): void {
this.eventHandlers[event] = handler;
}
public offControl<K extends keyof ControlEvents>(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<K extends keyof ControlEvents>(event: K, ...args: Parameters<ControlEvents[K]>): void {
const handler = this.eventHandlers[event];
if (handler) {
(handler as Function)(...args);
}
}
private emitEvent<K extends keyof GatewayEvents>(event: K, ...args: Parameters<GatewayEvents[K]>): 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;', '']);