diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts new file mode 100644 index 0000000..f729e64 --- /dev/null +++ b/plugin/core/transport.ts @@ -0,0 +1,225 @@ +: +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; +} + +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); +} diff --git a/plugin/index.ts b/plugin/index.ts index 502d7ff..266a71d 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -14,6 +14,16 @@ export { type YonexusClientStateFile, type YonexusClientStateStore } from "./core/state.js"; +export { + createClientTransport, + YonexusClientTransport, + type ClientTransport, + type ClientTransportOptions, + type ClientConnectionState, + type ClientMessageHandler, + type ClientStateChangeHandler, + type ClientErrorHandler +} from "./core/transport.js"; export interface YonexusClientPluginManifest { readonly name: "Yonexus.Client";