7 Commits

Author SHA1 Message Date
dcc343fb02 fix: change MessageTypes export from type to value in index.ts
All checks were successful
CI / build (push) Successful in 18s
CI / publish (push) Successful in 15s
2025-09-06 19:40:40 +01:00
68f31018ef style: standardize quotation marks and formatting in README.md
All checks were successful
CI / build (push) Successful in 17s
CI / publish (push) Successful in 15s
2025-09-06 19:36:14 +01:00
356b70474c feat: enhance BenncClient with websocket-ts integration and improved reconnection logic
Some checks failed
CI / build (push) Failing after 12s
CI / publish (push) Has been skipped
2025-09-06 19:34:23 +01:00
ed1dc6870e feat: implement BenncClient class with connection management and messaging capabilities 2025-09-06 19:24:16 +01:00
22999c34f9 style: remove unnecessary whitespace in packet.ts and basic.test.ts
All checks were successful
CI / build (push) Successful in 17s
CI / publish (push) Successful in 14s
2025-09-06 19:07:27 +01:00
61b7c50868 refactor: update romulus-js import path to @3t/romulus and adjust tests accordingly
Some checks failed
CI / build (push) Failing after 12s
CI / publish (push) Has been skipped
2025-09-06 19:06:08 +01:00
15fa2b1608 refactor: rename package to @3t/bennc and update repository URL
Some checks failed
CI / build (push) Failing after 10s
CI / publish (push) Has been skipped
- Changed package name from "bennc-js" to "@3t/bennc"
- Updated repository URL to point to the new location
- Added publishConfig for npm registry
- Updated devDependencies to newer versions
2025-09-06 19:03:04 +01:00
12 changed files with 3875 additions and 7347 deletions

View File

@@ -1,52 +0,0 @@
kind: pipeline
type: docker
name: build
steps:
- name: install
image: node:lts-alpine
commands:
- apk add git
- npm install
- name: lint
image: node:lts-alpine
commands:
- npm run lint
depends_on:
- install
- name: test
image: node:lts-alpine
commands:
- npm run test
depends_on:
- install
- name: build
image: node:lts-alpine
commands:
- apk add zip
- npm run build
- zip -r bennc-m.zip dist/
depends_on:
- install
- name: publish
image: plugins/gitea-release
depends_on:
- lint
- test
- build
when:
event:
- tag
settings:
base_url: https://git.jacknet.io
api_key:
from_secret: gitea_token
files:
- bennc-m.zip
checksum:
- md5
- sha256

57
.gitea/workflows/ci..yml Normal file
View File

@@ -0,0 +1,57 @@
name: CI
on:
push:
branches: [master]
tags: ["v*"]
pull_request:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
container: node:lts-alpine
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm run test
- name: Build project
run: npm run build
publish:
needs: build
runs-on: ubuntu-latest
container: node:lts-alpine
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
- name: Set version from git tag
run: |
VERSION=${GITHUB_REF#refs/tags/v}
npm version $VERSION --no-git-tag-version
- name: Setup npm for publishing
run: |
npm config set //git.3t.network/api/packages/3t.network/npm/:_authToken ${{ secrets.PUBLISH_TOKEN }}
- name: Publish to Gitea npm registry
run: npm publish

192
README.md
View File

@@ -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<void>` - 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 <repository-url>
$ 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.

10661
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "bennc-js",
"name": "@3t/bennc",
"version": "1.0.0",
"description": "A TypeScript/Javascript BENNC implementation.",
"main": "dist/index.js",
@@ -13,17 +13,13 @@
},
"repository": {
"type": "git",
"url": "https://git.jacknet.io/TerribleCodeClub/bennc-js"
"url": "https://git.3t.network/3t.network/bennc"
},
"publishConfig": {
"registry": "https://git.3t.network/api/packages/3t.network/npm/"
},
"author": "Butlersaurus",
"license": "ISC",
"devDependencies": {
"@types/jest": "^27.4.0",
"jest": "^27.4.7",
"ts-jest": "^27.1.3",
"ts-standard": "^11.0.0",
"typescript": "^4.5.5"
},
"jest": {
"verbose": true,
"transform": {
@@ -33,6 +29,14 @@
"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",
"jest": "^30.1.3",
"prettier": "^3.6.2",
"ts-jest": "^29.4.1",
"typescript": "^5.9.2"
}
}

211
src/BenncClient.ts Normal file
View File

@@ -0,0 +1,211 @@
import {
WebsocketBuilder,
ConstantBackoff,
ExponentialBackoff,
} from "websocket-ts";
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;
reconnectBackoff?: "constant" | "linear" | "exponential";
reconnectDelay?: number;
maxReconnectAttempts?: number;
}
export class BenncClient extends EventTarget {
private ws: any = null;
private options: BenncClientOptions;
constructor(options: BenncClientOptions) {
super();
this.options = {
autoReconnect: true,
reconnectBackoff: "exponential",
reconnectDelay: 1000,
maxReconnectAttempts: 10,
...options,
};
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
if (
this.ws &&
(this.ws.state === WebSocket.OPEN ||
this.ws.state === WebSocket.CONNECTING)
) {
resolve();
return;
}
const builder = new WebsocketBuilder(this.options.url);
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.ws) {
this.ws.close();
this.ws = null;
}
}
isConnected(): boolean {
return this.ws && this.ws.state === WebSocket.OPEN;
}
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.isConnected()) {
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.dispatchEvent(new CustomEvent("packet", { detail: packet }));
const unpacker = unpackers[packet.messageType as keyof typeof unpackers];
if (unpacker) {
const unpackedData = unpacker(packet.data);
const messageEvent = {
messageType: 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.dispatchEvent(
new CustomEvent("unknown-message", { detail: packet }),
);
}
} catch (error) {
this.dispatchEvent(new CustomEvent("parse-error", { detail: error }));
}
}
}

View File

@@ -1,9 +1,11 @@
export { numberToUint16BE, numberToUint32BE } from "./utilities/number";
export { unpackIncomingPacket } from "./messages/packet";
export { packers, unpackers } from "./mapping";
export type { MessageTypes } from "./common";
export { MessageTypes } from "./common";
export type { IncomingPacket, OutgoingPacket } from "./messages/packet";
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 } from "./BenncClient";

View File

@@ -1,4 +1,4 @@
import { encrypt, decrypt } from "romulus-js";
import { encrypt, decrypt } from "@3t/romulus";
import { DEFAULT_KEY, MessageTypes } from "../common";
import { numberToUint16BE } from "../utilities/number";
import { packOutgoingPacket } from "./packet";

View File

@@ -1,5 +1,5 @@
import Color from "color";
import { encrypt } from "romulus-js";
import { encrypt } from "@3t/romulus";
import { DEFAULT_KEY, MessageTypes } from "../common";
import { numberToUint16BE } from "../utilities/number";
import { SmartBuffer } from "../utilities/smart-buffer";

View File

@@ -1,5 +1,5 @@
import Color from "color";
import { encrypt } from "romulus-js";
import { encrypt } from "@3t/romulus";
import { DEFAULT_KEY, MessageTypes } from "../common";
import { numberToUint16BE } from "../utilities/number";
import { SmartBuffer } from "../utilities/smart-buffer";

View File

@@ -26,12 +26,17 @@ test("Create a basic message (0x0001) packet.", () => {
test("Parse a basic message (0x0001).", () => {
// Given
const data = new Uint8Array([1, 2, 3, 4]);
const encoder = new TextEncoder();
const originalMessage = encoder.encode("Test message");
// First encrypt the message to get valid encrypted data
const encryptedData = packers[MessageTypes.Basic](originalMessage, KEY);
// Extract just the data portion (skip the 4-byte header)
const dataOnly = encryptedData.slice(4);
// When
const unpackedPacket = unpackers[MessageTypes.Basic](data);
const unpackedMessage = unpackers[MessageTypes.Basic](dataOnly, KEY);
// Then
expect(unpackedPacket);
expect(unpackedPacket).toMatchObject(data);
expect(unpackedMessage).toEqual(originalMessage);
});