import { WebSocket } from "ws"; import type { YonexusClientConfig } from "./config.js"; export type ClientConnectionState = | "idle" | "connecting" | "connected" | "authenticating" | "authenticated" | "disconnecting" | "disconnected" | "error"; export interface ClientTransport { readonly state: ClientConnectionState; readonly isConnected: boolean; readonly isAuthenticated: boolean; connect(): Promise; disconnect(): void; send(message: string): boolean; markAuthenticated(): void; markAuthenticating(): void; } export type ClientMessageHandler = (message: string) => void; export type ClientStateChangeHandler = (state: ClientConnectionState) => void; export type ClientErrorHandler = (error: Error) => void; export interface ClientTransportOptions { config: YonexusClientConfig; onMessage: ClientMessageHandler; onStateChange?: ClientStateChangeHandler; onError?: ClientErrorHandler; } export class YonexusClientTransport implements ClientTransport { private ws: WebSocket | null = null; private options: ClientTransportOptions; private _state: ClientConnectionState = "idle"; private reconnectAttempts = 0; private reconnectTimer: NodeJS.Timeout | null = null; private heartbeatTimer: NodeJS.Timeout | null = null; // Reconnect configuration private readonly maxReconnectAttempts = 10; private readonly baseReconnectDelayMs = 1000; private readonly maxReconnectDelayMs = 30000; constructor(options: ClientTransportOptions) { this.options = options; } get state(): ClientConnectionState { return this._state; } get isConnected(): boolean { return this.ws !== null && this.ws.readyState === WebSocket.OPEN; } get isAuthenticated(): boolean { return this._state === "authenticated"; } async connect(): Promise { if (this.isConnected) { return; } this.setState("connecting"); const { mainHost } = this.options.config; return new Promise((resolve, reject) => { try { this.ws = new WebSocket(mainHost); const onOpen = () => { this.setState("connected"); this.reconnectAttempts = 0; // Reset on successful connection resolve(); }; const onError = (error: Error) => { this.setState("error"); if (this.options.onError) { this.options.onError(error); } reject(error); }; this.ws.once("open", onOpen); this.ws.once("error", onError); this.ws.on("message", (data) => { const message = data.toString("utf8"); this.options.onMessage(message); }); this.ws.on("close", (code: number, reason: Buffer) => { this.handleDisconnect(code, reason.toString()); }); this.ws.on("error", (error: Error) => { if (this.options.onError) { this.options.onError(error); } }); } catch (error) { this.setState("error"); reject(error); } }); } disconnect(): void { this.clearReconnectTimer(); this.stopHeartbeat(); if (this.ws) { this.setState("disconnecting"); this.ws.close(1000, "Client disconnecting"); this.ws = null; } this.setState("disconnected"); } send(message: string): boolean { if (!this.isConnected) { return false; } try { this.ws!.send(message); return true; } catch { return false; } } markAuthenticated(): void { if (this._state === "connected" || this._state === "authenticating") { this.setState("authenticated"); this.startHeartbeat(); } } markAuthenticating(): void { if (this._state === "connected") { this.setState("authenticating"); } } private handleDisconnect(code: number, reason: string): void { const wasAuthenticated = this._state === "authenticated"; this.ws = null; this.stopHeartbeat(); this.setState("disconnected"); // Don't reconnect if it was a normal close if (code === 1000) { return; } // Attempt reconnect if we were previously authenticated if (wasAuthenticated && this.reconnectAttempts < this.maxReconnectAttempts) { this.scheduleReconnect(); } } private scheduleReconnect(): void { this.clearReconnectTimer(); const delay = Math.min( this.baseReconnectDelayMs * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelayMs ); this.reconnectAttempts++; this.reconnectTimer = setTimeout(() => { this.connect().catch(() => { // Error handled in connect() }); }, delay); } private clearReconnectTimer(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } private startHeartbeat(): void { this.stopHeartbeat(); // Heartbeat every 5 minutes (300 seconds) this.heartbeatTimer = setInterval(() => { if (this.isConnected) { // Send heartbeat - actual message construction done by protocol layer this.options.onMessage("heartbeat_tick"); } }, 5 * 60 * 1000); } private stopHeartbeat(): void { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } private setState(state: ClientConnectionState): void { const oldState = this._state; this._state = state; if (oldState !== state && this.options.onStateChange) { this.options.onStateChange(state); } } } export function createClientTransport(options: ClientTransportOptions): ClientTransport { return new YonexusClientTransport(options); }