diff --git a/README.md b/README.md index 7101b0a..02feafc 100644 --- a/README.md +++ b/README.md @@ -2,54 +2,198 @@ [![Build Status](https://drone.jacknet.io/api/badges/TerribleCodeClub/bennc-js/status.svg)](https://drone.jacknet.io/TerribleCodeClub/bennc-js) -An implementation of the [BENNC](https://wiki.jacknet.io/books/simontech/chapter/bennc) client specification. +A TypeScript implementation of the [BENNC](https://wiki.jacknet.io/books/simontech/chapter/bennc) (Butlersaurus Ephemeral No NONCEnse Chat) protocol specification. This library provides both low-level protocol utilities and a high-level client for connecting to BENNC chat servers via WebSocket. + +## Features + +- **Complete BENNC Protocol Support**: All message types (0x0000-0xFFFF) including subscribe, basic chat, user data, keepalive, history, and unsubscribe +- **High-Level Client**: `BenncClient` class with automatic reconnection and event-driven architecture +- **Browser & Node.js Compatible**: Works in modern browsers and Node.js environments +- **TypeScript First**: Full type safety with comprehensive TypeScript definitions +- **Encryption Support**: Built-in Romulus-M AEAD encryption for secure messaging +- **Automatic Reconnection**: Configurable backoff strategies (constant, exponential) +- **Small Bundle Size**: Minimal dependencies and efficient implementation + +## Installation + +```bash +npm install @3t/bennc +``` + +## Quick Start + +### Using the BenncClient (Recommended) + +```typescript +import { BenncClient, MessageTypes } from '@3t/bennc'; + +const client = new BenncClient({ + url: 'wss://your-bennc-server.com', + autoReconnect: true, + reconnectBackoff: 'exponential', + reconnectDelay: 1000, +}); + +// Listen for events +client.addEventListener('connected', () => { + console.log('Connected to BENNC server'); + + // Subscribe to basic messages + client.subscribe(MessageTypes.Basic); +}); + +client.addEventListener('message:1', (event) => { + const { senderId, data } = event.detail; + console.log(`Message from ${senderId}:`, new TextDecoder().decode(data)); +}); + +client.addEventListener('disconnected', (event) => { + console.log('Disconnected:', event.detail); +}); + +// Connect to server +await client.connect(); + +// Send a message +client.sendBasicMessage('Hello, BENNC!'); +``` + +### Using Low-Level Protocol Functions + +```typescript +import { packers, unpackers, MessageTypes } from '@3t/bennc'; + +// Pack a basic message +const messageData = new TextEncoder().encode('Hello World'); +const packet = packers[MessageTypes.Basic](messageData); + +// Unpack incoming message +const incomingMessage = unpackers[MessageTypes.Basic](receivedData); +``` + +## API Reference + +### BenncClient + +#### Constructor Options + +```typescript +interface BenncClientOptions { + url: string; // WebSocket server URL + protocols?: string[]; // WebSocket protocols + autoReconnect?: boolean; // Enable auto-reconnection (default: true) + reconnectBackoff?: 'constant' | 'exponential'; // Backoff strategy (default: 'exponential') + reconnectDelay?: number; // Reconnection delay in ms (default: 1000) + maxReconnectAttempts?: number; // Max reconnection attempts (default: 10) +} +``` + +#### Methods + +- `connect(): Promise` - Connect to the BENNC server +- `disconnect(): void` - Disconnect from the server +- `isConnected(): boolean` - Check connection status +- `subscribe(messageType: MessageTypes): void` - Subscribe to message type +- `unsubscribe(messageType: MessageTypes): void` - Unsubscribe from message type +- `sendBasicMessage(message: string, key?: Uint8Array): void` - Send encrypted chat message +- `sendUserDataRequest(username: string, colour: Color, clientId: string, key?: Uint8Array): void` - Request user data +- `sendUserDataResponse(username: string, colour: Color, clientId: string, key?: Uint8Array): void` - Respond with user data +- `sendKeepalive(): void` - Send keepalive message +- `requestHistory(): void` - Request message history + +#### Events + +The client extends `EventTarget` and emits the following events: + +- `connected` - Successfully connected to server +- `disconnected` - Disconnected from server (detail: `{code, reason}`) +- `reconnecting` - Attempting to reconnect +- `error` - Connection or protocol error (detail: error object) +- `packet` - Raw incoming packet (detail: packet object) +- `message` - Parsed message (detail: `{messageType, senderId, data}`) +- `message:${messageType}` - Specific message type events (detail: `{senderId, data}`) +- `unknown-message` - Unknown message type received +- `parse-error` - Error parsing incoming message + +### Message Types + +```typescript +enum MessageTypes { + Subscribe = 0x0000, + Basic = 0x0001, + UserDataRequest = 0x0002, + UserDataResponse = 0x0003, + Keepalive = 0x0005, + GetHistory = 0xfffe, + Unsubscribe = 0xffff, +} +``` + +### Protocol Constants + +```typescript +const MAX_DATA_LENGTH = 1000; // Maximum data payload size +const DEFAULT_KEY: Uint8Array; // Default encryption key +``` ## Build -To build the BENNC-JS library, first clone this repository. - -Run the following commands from the root of the repository: +To build the BENNC-JS library from source: ```bash +$ git clone +$ cd bennc-js $ npm install $ npm run build ``` The build output will be saved to the `dist` directory. -## Development instructions +## Protocol Details -Requirements: +BENNC uses a binary protocol with the following message structure: -- The latest LTS builds of Node and npm. +**Client → Server**: `[Type(2)|Length(2)|Data(0-1000)]` +**Server → Client**: `[Type(2)|SenderId(4)|Length(2)|Data(0-1000)]` -Follow the instructions below to lint, test and build BENNC-JS. +- All integers are big-endian +- Maximum data payload is 1000 bytes (including encryption overhead) +- Encrypted message types (0x0001, 0x0002, 0x0003) use Romulus-M AEAD with 16-byte nonces +- Reserved sender IDs: 0xFFFFFF00-0xFFFFFFFF -#### Lint +## Development + +Requirements: Node.js LTS and npm + +### Commands ```bash -$ npm install -$ npm run lint -``` +# Install dependencies +npm install -#### Test +# Run tests +npm run test -```bash -$ npm install -$ npm run test -``` +# Lint code +npm run lint -#### Build +# Format code +npm run format -```bash -$ npm install -$ npm run build +# Build library +npm run build ``` ### Visual Studio Code -This repository contains the necessary configuration files to debug, test and build BENNC-JS using only Visual Studio Code. +This repository includes VS Code configuration for debugging and testing. Use `Ctrl+Shift+B` (or `⇧⌘B`) to run the build task. -Run the build task (`Ctrl+Shift+B` or `⇧⌘B`) to automatically compile the Typescript source files in the background. +Unit tests use [Jest](https://jestjs.io/) with VS Code support via the [Jest extension](https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest). -Unit tests use the [Jest](https://jestjs.io/) library. Support for Visual Studio Code is offered through the [Jest marketplace package](https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest) maintained by Orta. +## License + +ISC License - see package.json for details. + +## Contributing + +This is part of the BENNC protocol ecosystem. Please refer to the [BENNC specification](https://wiki.jacknet.io/books/simontech/chapter/bennc) for protocol details. diff --git a/package-lock.json b/package-lock.json index 039d11a..1838db1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "dependencies": { "@3t/romulus": "^1.0.2", "@types/color": "^3.0.3", - "color": "^4.2.0" + "color": "^4.2.0", + "websocket-ts": "^2.2.1" }, "devDependencies": { "@types/jest": "^30.0.0", @@ -4451,6 +4452,12 @@ "makeerror": "1.0.12" } }, + "node_modules/websocket-ts": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/websocket-ts/-/websocket-ts-2.2.1.tgz", + "integrity": "sha512-YKPDfxlK5qOheLZ2bTIiktZO1bpfGdNCPJmTEaPW7G9UXI1GKjDdeacOrsULUS000OPNxDVOyAuKLuIWPqWM0Q==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7714,6 +7721,11 @@ "makeerror": "1.0.12" } }, + "websocket-ts": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/websocket-ts/-/websocket-ts-2.2.1.tgz", + "integrity": "sha512-YKPDfxlK5qOheLZ2bTIiktZO1bpfGdNCPJmTEaPW7G9UXI1GKjDdeacOrsULUS000OPNxDVOyAuKLuIWPqWM0Q==" + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index d1c3e48..6df7c0b 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "dependencies": { "@3t/romulus": "^1.0.2", "@types/color": "^3.0.3", - "color": "^4.2.0" + "color": "^4.2.0", + "websocket-ts": "^2.2.1" }, "devDependencies": { "@types/jest": "^30.0.0", diff --git a/src/BenncClient.ts b/src/BenncClient.ts index af2e663..3c5ceee 100644 --- a/src/BenncClient.ts +++ b/src/BenncClient.ts @@ -1,3 +1,8 @@ +import { + WebsocketBuilder, + ConstantBackoff, + ExponentialBackoff, +} from "websocket-ts"; import { MessageTypes } from "./common"; import { packOutgoingPacket, unpackIncomingPacket } from "./messages/packet"; import { packers, unpackers } from "./mapping"; @@ -8,135 +13,103 @@ export interface BenncClientOptions { url: string; protocols?: string[]; autoReconnect?: boolean; + reconnectBackoff?: "constant" | "linear" | "exponential"; 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; +export class BenncClient extends EventTarget { + private ws: any = 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) { + super(); this.options = { autoReconnect: true, - reconnectDelay: 5000, + reconnectBackoff: "exponential", + reconnectDelay: 1000, 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) { + if ( + this.ws && + (this.ws.state === WebSocket.OPEN || + this.ws.state === WebSocket.CONNECTING) + ) { resolve(); return; } - this.state.connecting = true; - this.emit("connecting"); + const builder = new WebsocketBuilder(this.options.url); - 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); + if (this.options.protocols) { + builder.withProtocols(this.options.protocols); } + + if (this.options.autoReconnect) { + let backoff; + switch (this.options.reconnectBackoff) { + case "constant": + backoff = new ConstantBackoff(this.options.reconnectDelay!); + break; + case "exponential": + default: + backoff = new ExponentialBackoff(this.options.reconnectDelay!); + break; + } + builder.withBackoff(backoff); + } + + this.ws = builder + .onOpen(() => { + this.dispatchEvent(new CustomEvent("connected")); + resolve(); + }) + .onClose((_: any, event: any) => { + this.dispatchEvent( + new CustomEvent("disconnected", { + detail: { code: event.code, reason: event.reason }, + }), + ); + }) + .onError((_: any, error: any) => { + this.dispatchEvent(new CustomEvent("error", { detail: error })); + reject(error); + }) + .onMessage((_: any, event: any) => { + this.handleMessage(event.data); + }) + .onReconnect(() => { + this.dispatchEvent(new CustomEvent("reconnecting")); + }) + .build(); }); } 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; + return this.ws && this.ws.state === WebSocket.OPEN; } - getConnectionState(): ConnectionState { - return { ...this.state }; + getConnectionState(): { connected: boolean; connecting: boolean } { + return { + connected: this.ws?.state === WebSocket.OPEN || false, + connecting: this.ws?.state === WebSocket.CONNECTING || false, + }; } private sendRawMessage(messageType: number, data?: Uint8Array): void { - if (!this.state.connected || !this.ws) { + if (!this.isConnected()) { throw new Error("Client is not connected"); } @@ -205,46 +178,34 @@ export class BenncClient { private handleMessage(data: ArrayBuffer): void { try { const packet = unpackIncomingPacket(data); - this.emit("packet", packet); + this.dispatchEvent(new CustomEvent("packet", { detail: packet })); const unpacker = unpackers[packet.messageType as keyof typeof unpackers]; if (unpacker) { const unpackedData = unpacker(packet.data); - this.emit("message", { + const messageEvent = { messageType: packet.messageType, senderId: packet.senderId, data: unpackedData, - }); - this.emit(`message:${packet.messageType}`, { - senderId: packet.senderId, - data: unpackedData, - }); + }; + this.dispatchEvent( + new CustomEvent("message", { detail: messageEvent }), + ); + this.dispatchEvent( + new CustomEvent(`message:${packet.messageType}`, { + detail: { + senderId: packet.senderId, + data: unpackedData, + }, + }), + ); } else { - this.emit("unknown-message", packet); + this.dispatchEvent( + new CustomEvent("unknown-message", { detail: packet }), + ); } } catch (error) { - this.emit("parse-error", error); + this.dispatchEvent(new CustomEvent("parse-error", { detail: 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 2aa9144..9b37f5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,4 +8,4 @@ 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"; +export type { BenncClientOptions } from "./BenncClient";