diff --git a/src/BenncClient.ts b/src/BenncClient.ts new file mode 100644 index 0000000..af2e663 --- /dev/null +++ b/src/BenncClient.ts @@ -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 | null = null; + private eventListeners: Map = 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 { + 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); + } +} diff --git a/src/index.ts b/src/index.ts index ed8cdf3..2aa9144 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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";