Files
Yonexus.Client/plugin/core/transport.ts

227 lines
5.6 KiB
TypeScript

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<void>;
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<void> {
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);
}