feat: implement BenncClient class with connection management and messaging capabilities
This commit is contained in:
250
src/BenncClient.ts
Normal file
250
src/BenncClient.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,3 +7,5 @@ export type { SubscribeMessage } from "./messages/subscribe";
|
|||||||
export type { BasicMessage } from "./messages/basic";
|
export type { BasicMessage } from "./messages/basic";
|
||||||
export type { UserDataRequestMessage } from "./messages/userDataRequest";
|
export type { UserDataRequestMessage } from "./messages/userDataRequest";
|
||||||
export type { UserDataResponseMessage } from "./messages/userDataResponse";
|
export type { UserDataResponseMessage } from "./messages/userDataResponse";
|
||||||
|
export { BenncClient } from "./BenncClient";
|
||||||
|
export type { BenncClientOptions, ConnectionState } from "./BenncClient";
|
||||||
|
|||||||
Reference in New Issue
Block a user