230 lines
5.7 KiB
TypeScript
230 lines
5.7 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;
|
|
private shouldReconnect = false;
|
|
|
|
// 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.shouldReconnect = true;
|
|
this.clearReconnectTimer();
|
|
this.setState("connecting");
|
|
const { mainHost } = this.options.config;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
this.ws = new WebSocket(mainHost);
|
|
|
|
const onOpen = () => {
|
|
this.ws?.off("error", onInitialError);
|
|
this.setState("connected");
|
|
this.reconnectAttempts = 0; // Reset on successful connection
|
|
resolve();
|
|
};
|
|
|
|
const onInitialError = (error: Error) => {
|
|
this.setState("error");
|
|
if (this.options.onError) {
|
|
this.options.onError(error);
|
|
}
|
|
reject(error);
|
|
};
|
|
|
|
this.ws.once("open", onOpen);
|
|
this.ws.once("error", onInitialError);
|
|
|
|
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.shouldReconnect = false;
|
|
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 {
|
|
this.ws = null;
|
|
this.stopHeartbeat();
|
|
this.setState("disconnected");
|
|
|
|
// Don't reconnect if it was a normal close or caller explicitly stopped reconnects.
|
|
if (code === 1000 || !this.shouldReconnect) {
|
|
return;
|
|
}
|
|
|
|
if (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);
|
|
}
|