Add client WebSocket transport
This commit is contained in:
225
plugin/core/transport.ts
Normal file
225
plugin/core/transport.ts
Normal file
@@ -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<void>;
|
||||
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<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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user