feat: implement BenncClient class with connection management and messaging capabilities

This commit is contained in:
2025-09-06 19:24:16 +01:00
parent 22999c34f9
commit ed1dc6870e
2 changed files with 252 additions and 0 deletions

250
src/BenncClient.ts Normal file
View File

@@ -0,0 +1,250 @@
import { MessageTypes } from "./common";
import { packOutgoingPacket, unpackIncomingPacket } from "./messages/packet";
import { packers, unpackers } from "./mapping";
import { numberToUint16BE } from "./utilities/number";
import Color from "color";
export interface BenncClientOptions {
url: string;
protocols?: string[];
autoReconnect?: boolean;
reconnectDelay?: number;
maxReconnectAttempts?: number;
}
export interface ConnectionState {
connected: boolean;
connecting: boolean;
reconnectAttempts: number;
}
type EventListener = (...args: any[]) => void;
export class BenncClient {
private ws: WebSocket | null = null;
private options: BenncClientOptions;
private state: ConnectionState = {
connected: false,
connecting: false,
reconnectAttempts: 0,
};
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private eventListeners: Map<string, EventListener[]> = new Map();
constructor(options: BenncClientOptions) {
this.options = {
autoReconnect: true,
reconnectDelay: 5000,
maxReconnectAttempts: 10,
...options,
};
}
on(event: string, listener: EventListener): void {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, []);
}
this.eventListeners.get(event)!.push(listener);
}
off(event: string, listener: EventListener): void {
const listeners = this.eventListeners.get(event);
if (listeners) {
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
}
}
emit(event: string, ...args: any[]): void {
const listeners = this.eventListeners.get(event);
if (listeners) {
listeners.forEach((listener) => listener(...args));
}
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.state.connected || this.state.connecting) {
resolve();
return;
}
this.state.connecting = true;
this.emit("connecting");
try {
this.ws = new WebSocket(this.options.url, this.options.protocols);
this.ws.binaryType = "arraybuffer";
this.ws.onopen = () => {
this.state.connected = true;
this.state.connecting = false;
this.state.reconnectAttempts = 0;
this.emit("connected");
resolve();
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
this.ws.onclose = (event) => {
this.state.connected = false;
this.state.connecting = false;
this.emit("disconnected", { code: event.code, reason: event.reason });
if (this.options.autoReconnect && this.shouldReconnect()) {
this.scheduleReconnect();
}
};
this.ws.onerror = (error) => {
this.state.connecting = false;
this.emit("error", error);
reject(error);
};
} catch (error) {
this.state.connecting = false;
reject(error);
}
});
}
disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.state.connected = false;
this.state.connecting = false;
}
isConnected(): boolean {
return this.state.connected;
}
getConnectionState(): ConnectionState {
return { ...this.state };
}
private sendRawMessage(messageType: number, data?: Uint8Array): void {
if (!this.state.connected || !this.ws) {
throw new Error("Client is not connected");
}
const packet = packOutgoingPacket({
messageType: numberToUint16BE(messageType),
data,
});
this.ws.send(packet);
}
subscribe(messageType: MessageTypes): void {
const packer = packers[MessageTypes.Subscribe];
const data = packer({ messageType });
this.sendRawMessage(MessageTypes.Subscribe, data);
}
unsubscribe(messageType: MessageTypes): void {
const packer = packers[MessageTypes.Unsubscribe];
const data = packer({ messageType });
this.sendRawMessage(MessageTypes.Unsubscribe, data);
}
sendBasicMessage(message: string, key?: Uint8Array): void {
const encoder = new TextEncoder();
const messageBytes = encoder.encode(message);
const packer = packers[MessageTypes.Basic];
const data = packer(messageBytes, key);
this.sendRawMessage(MessageTypes.Basic, data);
}
sendUserDataRequest(
username: string,
colour: Color,
clientId: string,
key?: Uint8Array,
): void {
const packer = packers[MessageTypes.UserDataRequest];
const data = packer({ username, colour, clientId }, key);
this.sendRawMessage(MessageTypes.UserDataRequest, data);
}
sendUserDataResponse(
username: string,
colour: Color,
clientId: string,
key?: Uint8Array,
): void {
const packer = packers[MessageTypes.UserDataResponse];
const data = packer({ username, colour, clientId }, key);
this.sendRawMessage(MessageTypes.UserDataResponse, data);
}
sendKeepalive(): void {
const packer = packers[MessageTypes.Keepalive];
const data = packer();
this.sendRawMessage(MessageTypes.Keepalive, data);
}
requestHistory(): void {
const packer = packers[MessageTypes.GetHistory];
const data = packer();
this.sendRawMessage(MessageTypes.GetHistory, data);
}
private handleMessage(data: ArrayBuffer): void {
try {
const packet = unpackIncomingPacket(data);
this.emit("packet", packet);
const unpacker = unpackers[packet.messageType as keyof typeof unpackers];
if (unpacker) {
const unpackedData = unpacker(packet.data);
this.emit("message", {
messageType: packet.messageType,
senderId: packet.senderId,
data: unpackedData,
});
this.emit(`message:${packet.messageType}`, {
senderId: packet.senderId,
data: unpackedData,
});
} else {
this.emit("unknown-message", packet);
}
} catch (error) {
this.emit("parse-error", error);
}
}
private shouldReconnect(): boolean {
const maxAttempts = this.options.maxReconnectAttempts || 10;
return this.state.reconnectAttempts < maxAttempts;
}
private scheduleReconnect(): void {
const delay = this.options.reconnectDelay || 5000;
this.reconnectTimer = setTimeout(() => {
this.state.reconnectAttempts++;
this.emit("reconnecting", this.state.reconnectAttempts);
this.connect().catch((error) => {
this.emit("reconnect-failed", error);
if (this.shouldReconnect()) {
this.scheduleReconnect();
} else {
this.emit("reconnect-exhausted");
}
});
}, delay);
}
}

View File

@@ -7,3 +7,5 @@ export type { SubscribeMessage } from "./messages/subscribe";
export type { BasicMessage } from "./messages/basic";
export type { UserDataRequestMessage } from "./messages/userDataRequest";
export type { UserDataResponseMessage } from "./messages/userDataResponse";
export { BenncClient } from "./BenncClient";
export type { BenncClientOptions, ConnectionState } from "./BenncClient";