From 7714d71b0af8a8d2c0af52703d560b8ca422a923 Mon Sep 17 00:00:00 2001 From: nvms Date: Wed, 26 Mar 2025 21:09:28 -0400 Subject: [PATCH] feat(keepalive-ws): enhance README and improve client/server implementation - Add tests --- packages/keepalive-ws/README.md | 222 +++++++---- packages/keepalive-ws/bun.lockb | Bin 77925 -> 110110 bytes packages/keepalive-ws/package.json | 5 +- packages/keepalive-ws/src/client/client.ts | 229 ++++++++--- .../keepalive-ws/src/client/connection.ts | 325 ++++++--------- packages/keepalive-ws/src/client/index.ts | 3 +- packages/keepalive-ws/src/client/queue.ts | 50 ++- packages/keepalive-ws/src/common/codeerror.ts | 14 + packages/keepalive-ws/src/common/message.ts | 17 + packages/keepalive-ws/src/common/status.ts | 6 + packages/keepalive-ws/src/index.ts | 5 +- packages/keepalive-ws/src/server/command.ts | 19 - .../keepalive-ws/src/server/connection.ts | 72 +++- packages/keepalive-ws/src/server/index.ts | 370 +++++++++--------- packages/keepalive-ws/tests/advanced.test.ts | 161 ++++++++ packages/keepalive-ws/tests/basic.test.ts | 97 +++++ 16 files changed, 1007 insertions(+), 588 deletions(-) create mode 100644 packages/keepalive-ws/src/common/codeerror.ts create mode 100644 packages/keepalive-ws/src/common/message.ts create mode 100644 packages/keepalive-ws/src/common/status.ts create mode 100644 packages/keepalive-ws/tests/advanced.test.ts create mode 100644 packages/keepalive-ws/tests/basic.test.ts diff --git a/packages/keepalive-ws/README.md b/packages/keepalive-ws/README.md index b8057d9..13554b7 100644 --- a/packages/keepalive-ws/README.md +++ b/packages/keepalive-ws/README.md @@ -1,99 +1,173 @@ -For a TCP-based, node-only solution with a similar API, see [duplex](https://github.com/node-prism/duplex). - # keepalive-ws -A command server and client for simplified WebSocket communication, with builtin ping and latency messaging. +[![NPM version](https://img.shields.io/npm/v/@prsm/keepalive-ws?color=a1b858&label=)](https://www.npmjs.com/package/@prsm/keepalive-ws) -Built for [grove](https://github.com/node-prism/grove), but works anywhere. +A command server and client for simplified WebSocket communication, with built-in ping and latency messaging. Provides reliable, Promise-based communication with automatic reconnection and command queueing. -### Server +For a TCP-based, node-only solution with a similar API, see [duplex](https://github.com/node-prism/duplex). -For node. +## Features + +- **Promise-based API** - All operations return Promises for easy async/await usage +- **Command queueing** - Commands are automatically queued when offline +- **Reliable connections** - Robust error handling and reconnection +- **Bidirectional communication** - Full-duplex WebSocket communication +- **Latency monitoring** - Built-in ping/pong and latency measurement +- **Room-based messaging** - Group connections into rooms for targeted broadcasts +- **Lightweight** - Minimal dependencies + +## Server ```typescript import { KeepAliveServer, WSContext } from "@prsm/keepalive-ws/server"; -const ws = new KeepAliveServer({ - // Where to mount this server and listen to messages. - path: "/", - // How often to send ping messages to connected clients. - pingInterval: 30_000, - // Calculate round-trip time and send latency updates - // to clients every 5s. - latencyInterval: 5_000, +// Create a server instance +const server = new KeepAliveServer({ + port: 8080, + pingInterval: 30000, + latencyInterval: 5000, }); -ws.registerCommand( - "authenticate", - async (c: WSContext<{ token: string >}) => { - const { token } = c.payload; - // use c.payload to authenticate c.connection - return { ok: true, token }; - }, -); +// Register command handlers +server.registerCommand("echo", async (context) => { + return `Echo: ${context.payload}`; +}); -ws.registerCommand( - "throws", - async (c: WSContext) => { - throw new Error("oops"); - }, -); +// Error handling +server.registerCommand("throws", async () => { + throw new Error("Something went wrong"); +}); + +// Room-based messaging +server.registerCommand("join-room", async (context) => { + const { roomName } = context.payload; + server.addToRoom(roomName, context.connection); + server.broadcastRoom(roomName, "user-joined", { + id: context.connection.id + }); + return { success: true }; +}); + +// Broadcasting to all clients +server.registerCommand("broadcast", async (context) => { + server.broadcast("announcement", context.payload); + return { sent: true }; +}); ``` -Extended API: - -- Rooms - - It can be useful to collect connections into rooms. - - - `addToRoom(roomName: string, connection: Connection): void` - - `removeFromRoom(roomName: string, connection: Connection): void` - - `getRoom(roomName: string): Connection[]` - - `clearRoom(roomName: string): void` -- Command middleware -- Broadcasting to: - - all - - `broadcast(command: string, payload: any, connections?: Connection[]): void` - - all connections that share the same IP - - `broadcastRemoteAddress(c: Connection, command: string, payload: any): void` - - rooms - - `broadcastRoom(roomName: string, command: string, payload: any): void` - -### Client - -For the browser. +## Client ```typescript import { KeepAliveClient } from "@prsm/keepalive-ws/client"; -const opts = { - // After 30s (+ maxLatency) of no ping, assume we've disconnected and attempt a - // reconnection if shouldReconnect is true. - // This number should be coordinated with the pingInterval from KeepAliveServer. - pingTimeout: 30_000, - // Try to reconnect whenever we are disconnected. +// Create a client instance +const client = new KeepAliveClient("ws://localhost:8080", { + pingTimeout: 30000, + maxLatency: 2000, shouldReconnect: true, - // This number, added to pingTimeout, is the maximum amount of time - // that can pass before the connection is considered closed. - // In this case, 32s. - maxLatency: 2_000, - // How often to try and connect during reconnection phase. - reconnectInterval: 2_000, - // How many times to try and reconnect before giving up. + reconnectInterval: 2000, maxReconnectAttempts: Infinity, -}; - -const ws = new KeepAliveClient("ws://localhost:8080", opts); - -const { ok, token } = await ws.command("authenticate", { - username: "user", - password: "pass", }); -const result = await ws.command("throws", {}); -// result is: { error: "oops" } +// Connect to the server (returns a Promise) +await client.connect(); -ws.on("latency", (e: CustomEvent<{ latency: number }>) => { - // e.detail.latency is round-trip time in ms +// Using Promise-based API +try { + const response = await client.command("echo", "Hello world", 5000); + console.log("Response:", response); +} catch (error) { + console.error("Error:", error); +} + +// Join a room +await client.command("join-room", { roomName: "lobby" }); + +// Listen for events +client.on("user-joined", (event) => { + console.log("User joined:", event.detail.id); }); + +// Monitor latency +client.on("latency", (event) => { + console.log("Current latency:", event.detail.latency, "ms"); +}); + +// Graceful shutdown +await client.close(); +``` + +## Extended Server API + +### Room Management +```typescript +// Add a connection to a room +server.addToRoom("roomName", connection); + +// Remove a connection from a room +server.removeFromRoom("roomName", connection); + +// Get all connections in a room +const roomConnections = server.getRoom("roomName"); + +// Clear all connections from a room +server.clearRoom("roomName"); +``` + +### Broadcasting +```typescript +// Broadcast to all connections +server.broadcast("eventName", payload); + +// Broadcast to specific connections +server.broadcast("eventName", payload, connections); + +// Broadcast to all connections except one +server.broadcastExclude(connection, "eventName", payload); + +// Broadcast to all connections in a room +server.broadcastRoom("roomName", "eventName", payload); + +// Broadcast to all connections in a room except one +server.broadcastRoomExclude("roomName", "eventName", payload, connection); + +// Broadcast to all connections with the same IP +server.broadcastRemoteAddress(connection, "eventName", payload); +``` + +### Middleware +```typescript +// Global middleware for all commands +server.globalMiddlewares.push(async (context) => { + // Validate authentication, etc. + if (!isAuthenticated(context)) { + throw new Error("Unauthorized"); + } +}); + +// Command-specific middleware +server.registerCommand( + "protected-command", + async (context) => { + return "Protected data"; + }, + [ + async (context) => { + // Command-specific validation + if (!hasPermission(context)) { + throw new Error("Forbidden"); + } + } + ] +); +``` + +## Graceful Shutdown + +```typescript +// Close client connection +await client.close(); + +// Close server +server.close(); ``` diff --git a/packages/keepalive-ws/bun.lockb b/packages/keepalive-ws/bun.lockb index 56b08aac8f3bb97459d42a7ae9ae9031d42765f0..0e583031013d658ad3e25f764522618f8dc59e59 100755 GIT binary patch delta 32189 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 diff --git a/packages/keepalive-ws/package.json b/packages/keepalive-ws/package.json index 90e6dca..ac8f3f0 100644 --- a/packages/keepalive-ws/package.json +++ b/packages/keepalive-ws/package.json @@ -33,6 +33,8 @@ "build:server": "tsup src/server/index.ts --format cjs,esm --dts --clean --minify --out-dir dist/server", "build:client": "tsup src/client/index.ts --format cjs,esm --dts --clean --minify --out-dir dist/client", "build": "npm run build:prep && npm run build:server && npm run build:client", + "test": "vitest run", + "test:watch": "vitest", "release": "bumpp package.json && npm publish --access public" }, "keywords": [], @@ -44,6 +46,7 @@ "@types/ws": "^8.5.3", "bumpp": "^9.1.1", "tsup": "^8.2.4", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vitest": "^3.0.9" } } diff --git a/packages/keepalive-ws/src/client/client.ts b/packages/keepalive-ws/src/client/client.ts index 6779547..bb2d387 100644 --- a/packages/keepalive-ws/src/client/client.ts +++ b/packages/keepalive-ws/src/client/client.ts @@ -1,6 +1,12 @@ +import { EventEmitter } from "node:events"; +import { WebSocket } from "ws"; +import { CodeError } from "../common/codeerror"; +import { Status } from "../common/status"; import { Connection } from "./connection"; -type KeepAliveClientOptions = Partial<{ +export { Status } from "../common/status"; + +export type KeepAliveClientOptions = Partial<{ /** * The number of milliseconds to wait before considering the connection closed due to inactivity. * When this happens, the connection will be closed and a reconnect will be attempted if @see KeepAliveClientOptions.shouldReconnect is true. @@ -36,58 +42,129 @@ type KeepAliveClientOptions = Partial<{ maxReconnectAttempts: number; }>; -const defaultOptions = (opts: KeepAliveClientOptions = {}) => { - opts.pingTimeout = opts.pingTimeout ?? 30_000; - opts.maxLatency = opts.maxLatency ?? 2_000; - opts.shouldReconnect = opts.shouldReconnect ?? true; - opts.reconnectInterval = opts.reconnectInterval ?? 2_000; - opts.maxReconnectAttempts = opts.maxReconnectAttempts ?? Infinity; - return opts; -}; - -export class KeepAliveClient extends EventTarget { +export class KeepAliveClient extends EventEmitter { connection: Connection; url: string; - socket: WebSocket; + socket: WebSocket | null = null; pingTimeout: ReturnType; - options: KeepAliveClientOptions; + options: Required; isReconnecting = false; + private _status: Status = Status.OFFLINE; constructor(url: string, opts: KeepAliveClientOptions = {}) { super(); this.url = url; - this.socket = new WebSocket(url); - this.connection = new Connection(this.socket); - this.options = defaultOptions(opts); - this.applyListeners(); + this.connection = new Connection(null); + this.options = { + pingTimeout: opts.pingTimeout ?? 30_000, + maxLatency: opts.maxLatency ?? 2_000, + shouldReconnect: opts.shouldReconnect ?? true, + reconnectInterval: opts.reconnectInterval ?? 2_000, + maxReconnectAttempts: opts.maxReconnectAttempts ?? Infinity, + }; + + this.setupConnectionEvents(); } - get on() { - return this.connection.addEventListener.bind(this.connection); + get status(): Status { + return this._status; } - applyListeners() { - this.connection.addEventListener("connection", () => { - this.heartbeat(); + private setupConnectionEvents(): void { + // Forward relevant events from connection to client + this.connection.on("message", (data) => { + this.emit("message", data); }); - this.connection.addEventListener("close", () => { + this.connection.on("close", () => { + this._status = Status.OFFLINE; + this.emit("close"); this.reconnect(); }); - this.connection.addEventListener("ping", () => { - this.heartbeat(); + this.connection.on("error", (error) => { + this.emit("error", error); }); - this.connection.addEventListener( - "message", - (ev: CustomEventInit) => { - this.dispatchEvent(new CustomEvent("message", ev)); - }, - ); + this.connection.on("ping", () => { + this.heartbeat(); + this.emit("ping"); + }); + + this.connection.on("latency", (data) => { + this.emit("latency", data); + }); } - heartbeat() { + /** + * Connect to the WebSocket server. + * @returns A promise that resolves when the connection is established. + */ + connect(): Promise { + if (this._status === Status.ONLINE) { + return Promise.resolve(); + } + + if ( + this._status === Status.CONNECTING || + this._status === Status.RECONNECTING + ) { + return new Promise((resolve, reject) => { + const onConnect = () => { + this.removeListener("connect", onConnect); + this.removeListener("error", onError); + resolve(); + }; + + const onError = (error: Error) => { + this.removeListener("connect", onConnect); + this.removeListener("error", onError); + reject(error); + }; + + this.once("connect", onConnect); + this.once("error", onError); + }); + } + + this._status = Status.CONNECTING; + + return new Promise((resolve, reject) => { + try { + // Create a new WebSocket connection + this.socket = new WebSocket(this.url); + + // Set up a direct onopen handler to ensure we catch the connection event + this.socket.onopen = () => { + this._status = Status.ONLINE; + this.connection.socket = this.socket; + this.connection.status = Status.ONLINE; + this.connection.applyListeners(); + this.heartbeat(); + + this.emit("connect"); + resolve(); + }; + + // Set up a direct onerror handler for immediate connection errors + this.socket.onerror = (error) => { + this._status = Status.OFFLINE; + reject( + new CodeError( + "WebSocket connection error", + "ECONNECTION", + "ConnectionError", + ), + ); + }; + } catch (error) { + this._status = Status.OFFLINE; + reject(error); + } + }); + } + + heartbeat(): void { clearTimeout(this.pingTimeout); this.pingTimeout = setTimeout(() => { @@ -100,23 +177,45 @@ export class KeepAliveClient extends EventTarget { /** * Disconnect the client from the server. * The client will not attempt to reconnect. - * To reconnect, create a new KeepAliveClient. + * @returns A promise that resolves when the connection is closed. */ - disconnect() { + close(): Promise { this.options.shouldReconnect = false; - if (this.socket) { - this.socket.close(); + if (this._status === Status.OFFLINE) { + return Promise.resolve(); } - clearTimeout(this.pingTimeout); + return new Promise((resolve) => { + const onClose = () => { + this.removeListener("close", onClose); + this._status = Status.OFFLINE; + resolve(); + }; + + this.once("close", onClose); + + clearTimeout(this.pingTimeout); + + if (this.socket) { + this.socket.close(); + } + }); } - private async reconnect() { + /** + * @deprecated Use close() instead + */ + disconnect(): Promise { + return this.close(); + } + + private reconnect(): void { if (!this.options.shouldReconnect || this.isReconnecting) { return; } + this._status = Status.RECONNECTING; this.isReconnecting = true; let attempt = 1; @@ -124,11 +223,14 @@ export class KeepAliveClient extends EventTarget { if (this.socket) { try { this.socket.close(); - } catch (e) {} + } catch (e) { + // Ignore errors during close + } } const connect = () => { this.socket = new WebSocket(this.url); + this.socket.onerror = () => { attempt++; @@ -136,37 +238,56 @@ export class KeepAliveClient extends EventTarget { setTimeout(connect, this.options.reconnectInterval); } else { this.isReconnecting = false; - - this.connection.dispatchEvent(new Event("reconnectfailed")); - this.connection.dispatchEvent(new Event("reconnectionfailed")); + this._status = Status.OFFLINE; + this.emit("reconnectfailed"); } }; this.socket.onopen = () => { this.isReconnecting = false; + this._status = Status.ONLINE; this.connection.socket = this.socket; - + this.connection.status = Status.ONLINE; this.connection.applyListeners(true); + this.heartbeat(); - this.connection.dispatchEvent(new Event("connection")); - this.connection.dispatchEvent(new Event("connected")); - this.connection.dispatchEvent(new Event("connect")); - - this.connection.dispatchEvent(new Event("reconnection")); - this.connection.dispatchEvent(new Event("reconnected")); - this.connection.dispatchEvent(new Event("reconnect")); + this.emit("connect"); + this.emit("reconnect"); }; }; connect(); } - async command( + /** + * Send a command to the server and wait for a response. + * @param command The command name to send + * @param payload The payload to send with the command + * @param expiresIn Timeout in milliseconds + * @param callback Optional callback function + * @returns A promise that resolves with the command result + */ + command( command: string, payload?: any, - expiresIn?: number, - callback?: Function, - ) { + expiresIn: number = 30000, + callback?: (result: any, error?: Error) => void, + ): Promise { + // Ensure we're connected before sending commands + if (this._status !== Status.ONLINE) { + return this.connect() + .then(() => + this.connection.command(command, payload, expiresIn, callback), + ) + .catch((error) => { + if (callback) { + callback(null, error); + return Promise.reject(error); + } + return Promise.reject(error); + }); + } + return this.connection.command(command, payload, expiresIn, callback); } } diff --git a/packages/keepalive-ws/src/client/connection.ts b/packages/keepalive-ws/src/client/connection.ts index 3a4fac5..081a7ee 100644 --- a/packages/keepalive-ws/src/client/connection.ts +++ b/packages/keepalive-ws/src/client/connection.ts @@ -1,261 +1,137 @@ +import { EventEmitter } from "node:events"; +import { WebSocket } from "ws"; +import { CodeError } from "../common/codeerror"; +import { Command, parseCommand, stringifyCommand } from "../common/message"; +import { Status } from "../common/status"; import { IdManager } from "./ids"; -import { Queue, QueueItem } from "./queue"; +import { Queue } from "./queue"; -type Command = { - id?: number; - command: string; - payload?: any; -}; - -type LatencyPayload = { +export type LatencyPayload = { /** Round trip time in milliseconds. */ latency: number; }; -export declare interface Connection extends EventTarget { - addEventListener( - type: "message", - listener: (ev: CustomEvent) => any, - options?: boolean | AddEventListenerOptions, - ): void; - - /** Emits when a connection is made. */ - addEventListener( - type: "connection", - listener: () => any, - options?: boolean | AddEventListenerOptions, - ): void; - /** Emits when a connection is made. */ - addEventListener( - type: "connected", - listener: () => any, - options?: boolean | AddEventListenerOptions, - ): void; - /** Emits when a connection is made. */ - addEventListener( - type: "connect", - listener: () => any, - options?: boolean | AddEventListenerOptions, - ): void; - - /** Emits when a connection is closed. */ - addEventListener( - type: "close", - listener: () => any, - options?: boolean | AddEventListenerOptions, - ): void; - /** Emits when a connection is closed. */ - addEventListener( - type: "closed", - listener: () => any, - options?: boolean | AddEventListenerOptions, - ): void; - /** Emits when a connection is closed. */ - addEventListener( - type: "disconnect", - listener: () => any, - options?: boolean | AddEventListenerOptions, - ): void; - /** Emits when a connection is closed. */ - addEventListener( - type: "disconnected", - listener: () => any, - options?: boolean | AddEventListenerOptions, - ): void; - - /** Emits when a reconnect event is successful. */ - addEventListener( - type: "reconnect", - listener: () => any, - options?: boolean | AddEventListenerOptions, - ): void; - - /** Emits when a reconnect fails after @see KeepAliveClientOptions.maxReconnectAttempts attempts. */ - addEventListener( - type: "reconnectfailed", - listener: () => any, - options?: boolean | AddEventListenerOptions, - ): void; - - /** Emits when a ping message is received from @see KeepAliveServer from `@prsm/keepalive-ws/server`. */ - addEventListener( - type: "ping", - listener: (ev: CustomEventInit<{}>) => any, - options?: boolean | AddEventListenerOptions, - ): void; - - /** Emits when a latency event is received from @see KeepAliveServer from `@prsm/keepalive-ws/server`. */ - addEventListener( - type: "latency", - listener: (ev: CustomEventInit) => any, - options?: boolean | AddEventListenerOptions, - ): void; - - addEventListener( - type: string, - listener: (ev: CustomEvent) => any, - options?: boolean | AddEventListenerOptions, - ): void; -} - -export class Connection extends EventTarget { - socket: WebSocket; +export class Connection extends EventEmitter { + socket: WebSocket | null = null; ids = new IdManager(); queue = new Queue(); - callbacks: { [id: number]: (error: Error | null, result?: any) => void } = {}; + callbacks: { [id: number]: (result: any, error?: Error) => void } = {}; + status: Status = Status.OFFLINE; - constructor(socket: WebSocket) { + constructor(socket: WebSocket | null) { super(); this.socket = socket; - this.applyListeners(); - } - - /** - * Adds an event listener to the target. - * @param event The name of the event to listen for. - * @param listener The function to call when the event is fired. - * @param options An options object that specifies characteristics about the event listener. - */ - on( - event: string, - listener: (ev: CustomEvent) => any, - options?: boolean | AddEventListenerOptions, - ) { - this.addEventListener(event, listener, options); - } - - /** - * Removes the event listener previously registered with addEventListener. - * @param event A string that specifies the name of the event for which to remove an event listener. - * @param listener The event listener to be removed. - * @param options An options object that specifies characteristics about the event listener. - */ - off( - event: string, - listener: (ev: CustomEvent) => any, - options?: boolean | AddEventListenerOptions, - ) { - this.removeEventListener(event, listener, options); - } - - sendToken(cmd: Command, expiresIn: number) { - try { - this.socket.send(JSON.stringify(cmd)); - } catch (e) { - this.queue.add(cmd, expiresIn); + if (socket) { + this.applyListeners(); } } - applyListeners(reconnection = false) { + get isDead(): boolean { + return !this.socket || this.socket.readyState !== WebSocket.OPEN; + } + + send(command: Command): boolean { + try { + if (!this.isDead) { + this.socket.send(stringifyCommand(command)); + return true; + } + return false; + } catch (e) { + return false; + } + } + + sendWithQueue(command: Command, expiresIn: number): boolean { + const success = this.send(command); + + if (!success) { + this.queue.add(command, expiresIn); + } + + return success; + } + + applyListeners(reconnection = false): void { + if (!this.socket) return; + const drainQueue = () => { while (!this.queue.isEmpty) { - const item = this.queue.pop() as QueueItem; - this.sendToken(item.value, item.expiresIn); + const item = this.queue.pop(); + if (item) { + this.send(item.value); + } } }; - if (reconnection) drainQueue(); - - // @ts-ignore - this.socket.onopen = (socket: WebSocket, ev: Event): any => { + if (reconnection) { drainQueue(); - this.dispatchEvent(new Event("connection")); - this.dispatchEvent(new Event("connected")); - this.dispatchEvent(new Event("connect")); + } + + this.socket.onclose = () => { + this.status = Status.OFFLINE; + this.emit("close"); + this.emit("disconnect"); }; - this.socket.onclose = (event: CloseEvent) => { - this.dispatchEvent(new Event("close")); - this.dispatchEvent(new Event("closed")); - this.dispatchEvent(new Event("disconnected")); - this.dispatchEvent(new Event("disconnect")); + this.socket.onerror = (error) => { + this.emit("error", error); }; - this.socket.onmessage = async (event: MessageEvent) => { + this.socket.onmessage = (event: any) => { try { - const data = JSON.parse(event.data); + const data = parseCommand(event.data as string); - this.dispatchEvent(new CustomEvent("message", { detail: data })); + // Emit the raw message event + this.emit("message", data); + // Handle special system commands if (data.command === "latency:request") { - this.dispatchEvent( - new CustomEvent("latency:request", { - detail: { latency: data.payload.latency ?? undefined }, - }), - ); - this.command( - "latency:response", - { latency: data.payload.latency ?? undefined }, - null, - ); + this.emit("latency:request", data.payload); + this.command("latency:response", data.payload, null); } else if (data.command === "latency") { - this.dispatchEvent( - new CustomEvent("latency", { - detail: { latency: data.payload ?? undefined }, - }), - ); + this.emit("latency", data.payload); } else if (data.command === "ping") { - this.dispatchEvent(new CustomEvent("ping", {})); + this.emit("ping"); this.command("pong", {}, null); } else { - this.dispatchEvent( - new CustomEvent(data.command, { detail: data.payload }), - ); + // Emit command-specific event + this.emit(data.command, data.payload); } - if (this.callbacks[data.id]) { - this.callbacks[data.id](null, data.payload); + // Resolve any pending command promises + if (data.id !== undefined && this.callbacks[data.id]) { + // Always resolve with the payload, even if it contains an error + // This allows the test to check for error properties in the result + this.callbacks[data.id](data.payload); } - } catch (e) { - this.dispatchEvent(new Event("error")); + } catch (error) { + this.emit("error", error); } }; } - async command( + command( command: string, payload: any, - expiresIn: number = 30_000, - callback: Function | null = null, - ) { + expiresIn: number | null = 30_000, + callback?: (result: any, error?: Error) => void, + ): Promise | null { const id = this.ids.reserve(); - const cmd = { id, command, payload: payload ?? {} }; + const cmd: Command = { id, command, payload: payload ?? {} }; - this.sendToken(cmd, expiresIn); + this.sendWithQueue(cmd, expiresIn || 30000); if (expiresIn === null) { this.ids.release(id); - delete this.callbacks[id]; return null; } - const response = this.createResponsePromise(id); - const timeout = this.createTimeoutPromise(id, expiresIn); - - if (typeof callback === "function") { - const ret = await Promise.race([response, timeout]); - callback(ret); - return ret; - } else { - return Promise.race([response, timeout]); - } - } - - createTimeoutPromise(id: number, expiresIn: number) { - return new Promise((_, reject) => { - setTimeout(() => { + const responsePromise = new Promise((resolve, reject) => { + this.callbacks[id] = (result: any, error?: Error) => { this.ids.release(id); delete this.callbacks[id]; - reject(new Error(`Command ${id} timed out after ${expiresIn}ms.`)); - }, expiresIn); - }); - } - createResponsePromise(id: number) { - return new Promise((resolve, reject) => { - this.callbacks[id] = (error: Error | null, result?: any) => { - this.ids.release(id); - delete this.callbacks[id]; if (error) { reject(error); } else { @@ -263,5 +139,42 @@ export class Connection extends EventTarget { } }; }); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + if (this.callbacks[id]) { + this.ids.release(id); + delete this.callbacks[id]; + reject( + new CodeError( + `Command timed out after ${expiresIn}ms.`, + "ETIMEOUT", + "TimeoutError", + ), + ); + } + }, expiresIn); + }); + + if (typeof callback === "function") { + Promise.race([responsePromise, timeoutPromise]) + .then((result) => callback(result)) + .catch((error) => callback(null, error)); + + return responsePromise; + } + + return Promise.race([responsePromise, timeoutPromise]); + } + + close(): boolean { + if (this.isDead) return false; + + try { + this.socket.close(); + return true; + } catch (e) { + return false; + } } } diff --git a/packages/keepalive-ws/src/client/index.ts b/packages/keepalive-ws/src/client/index.ts index e5a5473..4b17d56 100644 --- a/packages/keepalive-ws/src/client/index.ts +++ b/packages/keepalive-ws/src/client/index.ts @@ -1,2 +1,3 @@ -export { KeepAliveClient } from "./client"; +export { KeepAliveClient, Status } from "./client"; export { Connection } from "./connection"; +export { CodeError } from "../common/codeerror"; diff --git a/packages/keepalive-ws/src/client/queue.ts b/packages/keepalive-ws/src/client/queue.ts index 948754e..4e7f8fa 100644 --- a/packages/keepalive-ws/src/client/queue.ts +++ b/packages/keepalive-ws/src/client/queue.ts @@ -1,50 +1,48 @@ +import { Command } from "../common/message"; + export class QueueItem { - value: any; - expireTime: number; + value: Command; + private expiration: number; - constructor(value: any, expiresIn: number) { + constructor(value: Command, expiresIn: number) { this.value = value; - this.expireTime = Date.now() + expiresIn; + this.expiration = Date.now() + expiresIn; } - get expiresIn() { - return this.expireTime - Date.now(); + get expiresIn(): number { + return this.expiration - Date.now(); } - get isExpired() { - return Date.now() > this.expireTime; + get isExpired(): boolean { + return Date.now() > this.expiration; } } export class Queue { - items: any[] = []; + private items: QueueItem[] = []; - add(item: any, expiresIn: number) { + add(item: Command, expiresIn: number): void { this.items.push(new QueueItem(item, expiresIn)); } - get isEmpty() { - let i = this.items.length; - - while (i--) { - if (this.items[i].isExpired) { - this.items.splice(i, 1); - } else { - return false; - } - } - - return true; + get isEmpty(): boolean { + // Remove expired items first + this.items = this.items.filter((item) => !item.isExpired); + return this.items.length === 0; } pop(): QueueItem | null { - while (this.items.length) { - const item = this.items.shift() as QueueItem; - if (!item.isExpired) { + // Find the first non-expired item + while (this.items.length > 0) { + const item = this.items.shift(); + if (item && !item.isExpired) { return item; } } - return null; } + + clear(): void { + this.items = []; + } } diff --git a/packages/keepalive-ws/src/common/codeerror.ts b/packages/keepalive-ws/src/common/codeerror.ts new file mode 100644 index 0000000..bbaee5e --- /dev/null +++ b/packages/keepalive-ws/src/common/codeerror.ts @@ -0,0 +1,14 @@ +export class CodeError extends Error { + code: string; + name: string; + + constructor(message: string, code?: string, name?: string) { + super(message); + if (typeof code === "string") { + this.code = code; + } + if (typeof name === "string") { + this.name = name; + } + } +} diff --git a/packages/keepalive-ws/src/common/message.ts b/packages/keepalive-ws/src/common/message.ts new file mode 100644 index 0000000..fd2321c --- /dev/null +++ b/packages/keepalive-ws/src/common/message.ts @@ -0,0 +1,17 @@ +export interface Command { + id?: number; + command: string; + payload: any; +} + +export function parseCommand(data: string): Command { + try { + return JSON.parse(data) as Command; + } catch (e) { + return { command: "", payload: {} }; + } +} + +export function stringifyCommand(command: Command): string { + return JSON.stringify(command); +} diff --git a/packages/keepalive-ws/src/common/status.ts b/packages/keepalive-ws/src/common/status.ts new file mode 100644 index 0000000..4a51ab3 --- /dev/null +++ b/packages/keepalive-ws/src/common/status.ts @@ -0,0 +1,6 @@ +export enum Status { + ONLINE = 3, + CONNECTING = 2, + RECONNECTING = 1, + OFFLINE = 0, +} diff --git a/packages/keepalive-ws/src/index.ts b/packages/keepalive-ws/src/index.ts index 80a56be..7f89e44 100644 --- a/packages/keepalive-ws/src/index.ts +++ b/packages/keepalive-ws/src/index.ts @@ -1,2 +1,3 @@ -export { KeepAliveClient } from "./client"; -export { KeepAliveServer } from "./server"; +export { KeepAliveClient, Status } from "./client"; +export { KeepAliveServer, WSContext } from "./server"; +export { CodeError } from "./common/codeerror"; diff --git a/packages/keepalive-ws/src/server/command.ts b/packages/keepalive-ws/src/server/command.ts index 6381a42..e69de29 100644 --- a/packages/keepalive-ws/src/server/command.ts +++ b/packages/keepalive-ws/src/server/command.ts @@ -1,19 +0,0 @@ -export interface Command { - id?: number; - command: string; - payload: any; -} - -export const bufferToCommand = (buffer: Buffer): Command => { - const decoded = new TextDecoder("utf-8").decode(buffer); - if (!decoded) { - return { id: 0, command: "", payload: {} }; - } - - try { - const parsed = JSON.parse(decoded) as Command; - return { id: parsed.id, command: parsed.command, payload: parsed.payload }; - } catch (e) { - return { id: 0, command: "", payload: {} }; - } -}; diff --git a/packages/keepalive-ws/src/server/connection.ts b/packages/keepalive-ws/src/server/connection.ts index db0a4ef..8938d2a 100644 --- a/packages/keepalive-ws/src/server/connection.ts +++ b/packages/keepalive-ws/src/server/connection.ts @@ -1,10 +1,11 @@ -import EventEmitter from "node:events"; +import { EventEmitter } from "node:events"; import { IncomingMessage } from "node:http"; import { WebSocket } from "ws"; -import { KeepAliveServerOptions } from "."; -import { bufferToCommand, Command } from "./command"; +import { Command, parseCommand, stringifyCommand } from "../common/message"; +import { Status } from "../common/status"; import { Latency } from "./latency"; import { Ping } from "./ping"; +import { KeepAliveServerOptions } from "./"; export class Connection extends EventEmitter { id: string; @@ -14,6 +15,7 @@ export class Connection extends EventEmitter { ping: Ping; remoteAddress: string; connectionOptions: KeepAliveServerOptions; + status: Status = Status.ONLINE; constructor( socket: WebSocket, @@ -30,7 +32,11 @@ export class Connection extends EventEmitter { this.startIntervals(); } - startIntervals() { + get isDead(): boolean { + return !this.socket || this.socket.readyState !== WebSocket.OPEN; + } + + startIntervals(): void { this.latency = new Latency(); this.ping = new Ping(); @@ -50,6 +56,7 @@ export class Connection extends EventEmitter { this.ping.interval = setInterval(() => { if (!this.alive) { this.emit("close"); + return; } this.alive = false; @@ -57,32 +64,61 @@ export class Connection extends EventEmitter { }, this.connectionOptions.pingInterval); } - stopIntervals() { + stopIntervals(): void { clearInterval(this.latency.interval); clearInterval(this.ping.interval); } - applyListeners() { + applyListeners(): void { this.socket.on("close", () => { + this.status = Status.OFFLINE; this.emit("close"); }); - this.socket.on("message", (buffer: Buffer) => { - const command = bufferToCommand(buffer); + this.socket.on("error", (error) => { + this.emit("error", error); + }); - if (command.command === "latency:response") { - this.latency.onResponse(); - return; - } else if (command.command === "pong") { - this.alive = true; - return; + this.socket.on("message", (data: Buffer) => { + try { + const command = parseCommand(data.toString()); + + if (command.command === "latency:response") { + this.latency.onResponse(); + return; + } else if (command.command === "pong") { + this.alive = true; + return; + } + + this.emit("message", data); + } catch (error) { + this.emit("error", error); } - - this.emit("message", buffer); }); } - send(cmd: Command) { - this.socket.send(JSON.stringify(cmd)); + send(cmd: Command): boolean { + if (this.isDead) return false; + + try { + this.socket.send(stringifyCommand(cmd)); + return true; + } catch (error) { + this.emit("error", error); + return false; + } + } + + close(): boolean { + if (this.isDead) return false; + + try { + this.socket.close(); + return true; + } catch (error) { + this.emit("error", error); + return false; + } } } diff --git a/packages/keepalive-ws/src/server/index.ts b/packages/keepalive-ws/src/server/index.ts index eae72c0..04992fc 100644 --- a/packages/keepalive-ws/src/server/index.ts +++ b/packages/keepalive-ws/src/server/index.ts @@ -1,117 +1,26 @@ import { IncomingMessage } from "node:http"; import { ServerOptions, WebSocket, WebSocketServer } from "ws"; -import { bufferToCommand } from "./command"; +import { CodeError } from "../common/codeerror"; +import { Command, parseCommand } from "../common/message"; +import { Status } from "../common/status"; import { Connection } from "./connection"; -export declare interface KeepAliveServer extends WebSocketServer { - on( - event: "connection", - handler: (socket: WebSocket, req: IncomingMessage) => void, - ): this; - on(event: "connected", handler: (c: Connection) => void): this; - on(event: "close", handler: (c: Connection) => void): this; - on(event: "error", cb: (this: WebSocketServer, error: Error) => void): this; - on( - event: "headers", - cb: ( - this: WebSocketServer, - headers: string[], - request: IncomingMessage, - ) => void, - ): this; - on( - event: string | symbol, - listener: (this: WebSocketServer, ...args: any[]) => void, - ): this; +export { Status } from "../common/status"; +export { Connection } from "./connection"; - emit(event: "connection", socket: WebSocket, req: IncomingMessage): boolean; - emit(event: "connected", connection: Connection): boolean; - emit(event: "close", connection: Connection): boolean; - emit(event: "error", connection: Connection): boolean; - - once( - event: "connection", - cb: ( - this: WebSocketServer, - socket: WebSocket, - request: IncomingMessage, - ) => void, - ): this; - once(event: "error", cb: (this: WebSocketServer, error: Error) => void): this; - once( - event: "headers", - cb: ( - this: WebSocketServer, - headers: string[], - request: IncomingMessage, - ) => void, - ): this; - once(event: "close" | "listening", cb: (this: WebSocketServer) => void): this; - once( - event: string | symbol, - listener: (this: WebSocketServer, ...args: any[]) => void, - ): this; - - off( - event: "connection", - cb: ( - this: WebSocketServer, - socket: WebSocket, - request: IncomingMessage, - ) => void, - ): this; - off(event: "error", cb: (this: WebSocketServer, error: Error) => void): this; - off( - event: "headers", - cb: ( - this: WebSocketServer, - headers: string[], - request: IncomingMessage, - ) => void, - ): this; - off(event: "close" | "listening", cb: (this: WebSocketServer) => void): this; - off( - event: string | symbol, - listener: (this: WebSocketServer, ...args: any[]) => void, - ): this; - - addListener( - event: "connection", - cb: (client: WebSocket, request: IncomingMessage) => void, - ): this; - addListener(event: "error", cb: (err: Error) => void): this; - addListener( - event: "headers", - cb: (headers: string[], request: IncomingMessage) => void, - ): this; - addListener(event: "close" | "listening", cb: () => void): this; - addListener(event: string | symbol, listener: (...args: any[]) => void): this; - - removeListener(event: "connection", cb: (client: WebSocket) => void): this; - removeListener(event: "error", cb: (err: Error) => void): this; - removeListener( - event: "headers", - cb: (headers: string[], request: IncomingMessage) => void, - ): this; - removeListener(event: "close" | "listening", cb: () => void): this; - removeListener( - event: string | symbol, - listener: (...args: any[]) => void, - ): this; -} -export class WSContext { - wss: KeepAliveServer; +export class WSContext { + server: KeepAliveServer; connection: Connection; payload: T; - constructor(wss: KeepAliveServer, connection: Connection, payload: any) { - this.wss = wss; + constructor(server: KeepAliveServer, connection: Connection, payload: T) { + this.server = server; this.connection = connection; this.payload = payload; } } -export type SocketMiddleware = (c: WSContext) => any | Promise; +export type SocketMiddleware = (context: WSContext) => any | Promise; export type KeepAliveServerOptions = ServerOptions & { /** @@ -136,34 +45,65 @@ export class KeepAliveServer extends WebSocketServer { globalMiddlewares: SocketMiddleware[] = []; middlewares: { [key: string]: SocketMiddleware[] } = {}; rooms: { [roomName: string]: Set } = {}; - declare serverOptions: KeepAliveServerOptions; + serverOptions: ServerOptions & { + pingInterval: number; + latencyInterval: number; + }; + status: Status = Status.OFFLINE; + private _listening: boolean = false; + + /** + * Whether the server is currently listening for connections + */ + get listening(): boolean { + return this._listening; + } constructor(opts: KeepAliveServerOptions) { - super({ ...opts }); + super(opts); this.serverOptions = { ...opts, pingInterval: opts.pingInterval ?? 30_000, latencyInterval: opts.latencyInterval ?? 5_000, }; + + this.on("listening", () => { + this._listening = true; + this.status = Status.ONLINE; + }); + + this.on("close", () => { + this._listening = false; + this.status = Status.OFFLINE; + }); + this.applyListeners(); } - private cleanupConnection(c: Connection) { - c.stopIntervals(); - delete this.connections[c.id]; - if (this.remoteAddressToConnections[c.remoteAddress]) { - this.remoteAddressToConnections[c.remoteAddress] = - this.remoteAddressToConnections[c.remoteAddress].filter( - (cn) => cn.id !== c.id, + private cleanupConnection(connection: Connection): void { + connection.stopIntervals(); + delete this.connections[connection.id]; + + if (this.remoteAddressToConnections[connection.remoteAddress]) { + this.remoteAddressToConnections[connection.remoteAddress] = + this.remoteAddressToConnections[connection.remoteAddress].filter( + (conn) => conn.id !== connection.id, ); + + if ( + this.remoteAddressToConnections[connection.remoteAddress].length === 0 + ) { + delete this.remoteAddressToConnections[connection.remoteAddress]; + } } - if (!this.remoteAddressToConnections[c.remoteAddress].length) { - delete this.remoteAddressToConnections[c.remoteAddress]; - } + // Remove from all rooms + Object.keys(this.rooms).forEach((roomName) => { + this.rooms[roomName].delete(connection.id); + }); } - private applyListeners() { + private applyListeners(): void { this.on("connection", (socket: WebSocket, req: IncomingMessage) => { const connection = new Connection(socket, req, this.serverOptions); this.connections[connection.id] = connection; @@ -178,44 +118,47 @@ export class KeepAliveServer extends WebSocketServer { this.emit("connected", connection); - connection.once("close", () => { + connection.on("close", () => { this.cleanupConnection(connection); this.emit("close", connection); + }); - if (socket.readyState === WebSocket.OPEN) { - socket.close(); - } - - Object.keys(this.rooms).forEach((roomName) => { - this.rooms[roomName].delete(connection.id); - }); + connection.on("error", (error) => { + this.emit("clientError", error); }); connection.on("message", (buffer: Buffer) => { try { - const { id, command, payload } = bufferToCommand(buffer); - this.runCommand(id ?? 0, command, payload, connection); - } catch (e) { - this.emit("error", e); + const data = buffer.toString(); + const command = parseCommand(data); + + if (command.id !== undefined) { + this.runCommand( + command.id, + command.command, + command.payload, + connection, + ); + } + } catch (error) { + this.emit("error", error); } }); }); } - broadcast(command: string, payload: any, connections?: Connection[]) { - const cmd = JSON.stringify({ command, payload }); + broadcast(command: string, payload: any, connections?: Connection[]): void { + const cmd: Command = { command, payload }; if (connections) { - connections.forEach((c) => { - c.socket.send(cmd); + connections.forEach((connection) => { + connection.send(cmd); + }); + } else { + Object.values(this.connections).forEach((connection) => { + connection.send(cmd); }); - - return; } - - Object.values(this.connections).forEach((c) => { - c.socket.send(cmd); - }); } /** @@ -226,14 +169,21 @@ export class KeepAliveServer extends WebSocketServer { * - Push notifications. * - Auth changes, e.g., logging out in one tab should log you out in all tabs. */ - broadcastRemoteAddress(c: Connection, command: string, payload: any) { - const cmd = JSON.stringify({ command, payload }); - this.remoteAddressToConnections[c.remoteAddress].forEach((cn) => { - cn.socket.send(cmd); + broadcastRemoteAddress( + connection: Connection, + command: string, + payload: any, + ): void { + const cmd: Command = { command, payload }; + const connections = + this.remoteAddressToConnections[connection.remoteAddress] || []; + + connections.forEach((conn) => { + conn.send(cmd); }); } - broadcastRemoteAddressById(id: string, command: string, payload: any) { + broadcastRemoteAddressById(id: string, command: string, payload: any): void { const connection = this.connections[id]; if (connection) { this.broadcastRemoteAddress(connection, command, payload); @@ -244,8 +194,8 @@ export class KeepAliveServer extends WebSocketServer { * Given a roomName, a command and a payload, broadcasts to all Connections * that are in the room. */ - broadcastRoom(roomName: string, command: string, payload: any) { - const cmd = JSON.stringify({ command, payload }); + broadcastRoom(roomName: string, command: string, payload: any): void { + const cmd: Command = { command, payload }; const room = this.rooms[roomName]; if (!room) return; @@ -253,7 +203,7 @@ export class KeepAliveServer extends WebSocketServer { room.forEach((connectionId) => { const connection = this.connections[connectionId]; if (connection) { - connection.socket.send(cmd); + connection.send(cmd); } }); } @@ -267,8 +217,8 @@ export class KeepAliveServer extends WebSocketServer { command: string, payload: any, connection: Connection | Connection[], - ) { - const cmd = JSON.stringify({ command, payload }); + ): void { + const cmd: Command = { command, payload }; const room = this.rooms[roomName]; if (!room) return; @@ -281,7 +231,7 @@ export class KeepAliveServer extends WebSocketServer { if (!excludeIds.includes(connectionId)) { const conn = this.connections[connectionId]; if (conn) { - conn.socket.send(cmd); + conn.send(cmd); } } }); @@ -291,111 +241,157 @@ export class KeepAliveServer extends WebSocketServer { * Given a connection, broadcasts a message to all connections except * the provided connection. */ - broadcastExclude(connection: Connection, command: string, payload: any) { - const cmd = JSON.stringify({ command, payload }); - Object.values(this.connections).forEach((c) => { - if (c.id !== connection.id) { - c.socket.send(cmd); + broadcastExclude( + connection: Connection, + command: string, + payload: any, + ): void { + const cmd: Command = { command, payload }; + + Object.values(this.connections).forEach((conn) => { + if (conn.id !== connection.id) { + conn.send(cmd); } }); } /** - * @example - * ```typescript - * server.registerCommand("join:room", async (payload: { roomName: string }, connection: Connection) => { - * server.addToRoom(payload.roomName, connection); - * server.broadcastRoom(payload.roomName, "joined", { roomName: payload.roomName }); - * }); - * ``` + * Add a connection to a room */ - addToRoom(roomName: string, connection: Connection) { + addToRoom(roomName: string, connection: Connection): void { this.rooms[roomName] = this.rooms[roomName] ?? new Set(); this.rooms[roomName].add(connection.id); } - removeFromRoom(roomName: string, connection: Connection) { + /** + * Remove a connection from a room + */ + removeFromRoom(roomName: string, connection: Connection): void { if (!this.rooms[roomName]) return; this.rooms[roomName].delete(connection.id); } - removeFromAllRooms(connection: Connection | string) { - const connectionId = typeof connection === "string" ? connection : connection.id; + /** + * Remove a connection from all rooms + */ + removeFromAllRooms(connection: Connection | string): void { + const connectionId = + typeof connection === "string" ? connection : connection.id; + Object.keys(this.rooms).forEach((roomName) => { this.rooms[roomName].delete(connectionId); }); } /** - * Returns a "room", which is simply a Set of Connection ids. - * @param roomName + * Returns all connections in a room */ getRoom(roomName: string): Connection[] { const ids = this.rooms[roomName] || new Set(); - return Array.from(ids).map((id) => this.connections[id]); + return Array.from(ids) + .map((id) => this.connections[id]) + .filter(Boolean); } - clearRoom(roomName: string) { + /** + * Clear all connections from a room + */ + clearRoom(roomName: string): void { this.rooms[roomName] = new Set(); } - registerCommand( + /** + * Register a command handler + */ + async registerCommand( command: string, callback: (context: WSContext) => Promise | T, middlewares: SocketMiddleware[] = [], - ) { + ): Promise { this.commands[command] = callback; - this.prependMiddlewareToCommand(command, middlewares); + + if (middlewares.length > 0) { + this.prependMiddlewareToCommand(command, middlewares); + } + + return Promise.resolve(); } - prependMiddlewareToCommand(command: string, middlewares: SocketMiddleware[]) { + /** + * Add middleware to be executed before a command + */ + prependMiddlewareToCommand( + command: string, + middlewares: SocketMiddleware[], + ): void { if (middlewares.length) { this.middlewares[command] = this.middlewares[command] || []; this.middlewares[command] = middlewares.concat(this.middlewares[command]); } } - appendMiddlewareToCommand(command: string, middlewares: SocketMiddleware[]) { + /** + * Add middleware to be executed after other middleware but before the command + */ + appendMiddlewareToCommand( + command: string, + middlewares: SocketMiddleware[], + ): void { if (middlewares.length) { this.middlewares[command] = this.middlewares[command] || []; this.middlewares[command] = this.middlewares[command].concat(middlewares); } } + /** + * Execute a command with the given id, name, payload and connection + */ private async runCommand( id: number, command: string, payload: any, connection: Connection, - ) { - const c = new WSContext(this, connection, payload); + ): Promise { + const context = new WSContext(this, connection, payload); try { if (!this.commands[command]) { - // An onslaught of commands that don't exist is a sign of a bad - // or otherwise misconfigured client. - throw new Error(`Command [${command}] not found.`); + throw new CodeError( + `Command [${command}] not found.`, + "ENOTFOUND", + "CommandError", + ); } + // Run global middlewares if (this.globalMiddlewares.length) { - for (const mw of this.globalMiddlewares) { - await mw(c); + for (const middleware of this.globalMiddlewares) { + await middleware(context); } } + // Run command-specific middlewares if (this.middlewares[command]) { - for (const mw of this.middlewares[command]) { - await mw(c); + for (const middleware of this.middlewares[command]) { + await middleware(context); } } - const result = await this.commands[command](c); + // Execute the command + const result = await this.commands[command](context); connection.send({ id, command, payload: result }); - } catch (e) { - const payload = { error: e.message ?? e ?? "Unknown error" }; - connection.send({ id, command, payload }); + } catch (error) { + // Handle and serialize errors + const errorPayload = + error instanceof Error + ? { + error: error.message, + code: (error as CodeError).code || "ESERVER", + name: error.name || "Error", + } + : { error: String(error) }; + + connection.send({ id, command, payload: errorPayload }); } } } - -export { Connection }; diff --git a/packages/keepalive-ws/tests/advanced.test.ts b/packages/keepalive-ws/tests/advanced.test.ts new file mode 100644 index 0000000..6d563fa --- /dev/null +++ b/packages/keepalive-ws/tests/advanced.test.ts @@ -0,0 +1,161 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { KeepAliveClient, Status } from "../src/client/client"; +import { KeepAliveServer } from "../src/server/index"; + +// Helper to create a WebSocket server for testing +const createTestServer = (port: number) => { + return new KeepAliveServer({ + port, + pingInterval: 1000, // Faster for testing + latencyInterval: 500, // Faster for testing + }); +}; + +describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => { + const port = 8125; + let server: KeepAliveServer; + let client: KeepAliveClient; + + beforeEach(async () => { + server = createTestServer(port); + + // Wait for the server to start + await new Promise((resolve) => { + server.on("listening", () => { + resolve(); + }); + + // In case the server is already listening + if (server.listening) { + resolve(); + } + }); + + client = new KeepAliveClient(`ws://localhost:${port}`); + }); + + afterEach(async () => { + // Close connections in order + if (client.status === Status.ONLINE) { + await client.close(); + } + + // Close the server + return new Promise((resolve) => { + if (server) { + server.close(() => { + resolve(); + }); + } else { + resolve(); + } + }); + }); + + test("command times out when server doesn't respond", async () => { + await server.registerCommand("never-responds", async () => { + return new Promise(() => {}); + }); + + await client.connect(); + + // Expect it to fail after a short timeout + await expect( + client.command("never-responds", "Should timeout", 500), + ).rejects.toThrow(/timed out/); + }, 2000); + + test("server errors are properly serialized to client", async () => { + await server.registerCommand("throws-error", async () => { + throw new Error("Custom server error"); + }); + + await client.connect(); + + // Expect to receive this error + const result = await client.command("throws-error", "Will error", 1000); + expect(result).toHaveProperty("error", "Custom server error"); + }, 2000); + + test("multiple concurrent commands are handled correctly", async () => { + // Register commands with different delays + await server.registerCommand("fast", async (context) => { + await new Promise((r) => setTimeout(r, 50)); + return `Fast: ${context.payload}`; + }); + + await server.registerCommand("slow", async (context) => { + await new Promise((r) => setTimeout(r, 150)); + return `Slow: ${context.payload}`; + }); + + await server.registerCommand("echo", async (context) => { + return `Echo: ${context.payload}`; + }); + + await client.connect(); + + // Send multiple commands concurrently + const results = await Promise.all([ + client.command("fast", "First", 1000), + client.command("slow", "Second", 1000), + client.command("echo", "Third", 1000), + ]); + + // Verify all commands completed successfully + expect(results).toEqual(["Fast: First", "Slow: Second", "Echo: Third"]); + }, 3000); + + test("handles large payloads correctly", async () => { + await server.registerCommand("echo", async (context) => { + return context.payload; + }); + + await client.connect(); + + const largeData = { + array: Array(1000) + .fill(0) + .map((_, i) => `item-${i}`), + nested: { + deep: { + object: { + with: "lots of data", + }, + }, + }, + }; + + const result = await client.command("echo", largeData, 5000); + + // Verify the response contains the expected data + expect(result).toEqual(largeData); + }, 10000); + + test("server handles multiple client connections", async () => { + await server.registerCommand("echo", async (context) => { + return `Echo: ${context.payload}`; + }); + + // Create multiple clients + const clients = Array(5) + .fill(0) + .map(() => new KeepAliveClient(`ws://localhost:${port}`)); + + // Connect all clients + await Promise.all(clients.map((client) => client.connect())); + + // Send a command from each client + const results = await Promise.all( + clients.map((client, i) => client.command("echo", `Client ${i}`, 1000)), + ); + + // Verify all commands succeeded + results.forEach((result, i) => { + expect(result).toBe(`Echo: Client ${i}`); + }); + + // Clean up + await Promise.all(clients.map((client) => client.close())); + }, 5000); +}); diff --git a/packages/keepalive-ws/tests/basic.test.ts b/packages/keepalive-ws/tests/basic.test.ts new file mode 100644 index 0000000..b5810f5 --- /dev/null +++ b/packages/keepalive-ws/tests/basic.test.ts @@ -0,0 +1,97 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { KeepAliveClient, Status } from "../src/client/client"; +import { KeepAliveServer } from "../src/server/index"; +import { WebSocket, WebSocketServer } from "ws"; + +// Helper to create a WebSocket server for testing +const createTestServer = (port: number) => { + return new KeepAliveServer({ + port, + pingInterval: 1000, // Faster for testing + latencyInterval: 500, // Faster for testing + }); +}; + +describe("Basic KeepAliveClient and KeepAliveServer Tests", () => { + const port = 8124; + let server: KeepAliveServer; + let client: KeepAliveClient; + + beforeEach(async () => { + server = createTestServer(port); + + // Wait for the server to start + await new Promise((resolve) => { + server.on("listening", () => { + resolve(); + }); + + // In case the server is already listening + if (server.listening) { + resolve(); + } + }); + + client = new KeepAliveClient(`ws://localhost:${port}`); + }); + + afterEach(async () => { + // Close connections in order + if (client.status === Status.ONLINE) { + await client.close(); + } + + // Close the server + return new Promise((resolve) => { + if (server) { + server.close(() => { + resolve(); + }); + } else { + resolve(); + } + }); + }); + + test("client-server connection should be online", async () => { + await server.registerCommand("echo", async (context) => { + return context.payload; + }); + + await client.connect(); + expect(client.status).toBe(Status.ONLINE); + }, 10000); + + test("simple echo command", async () => { + await server.registerCommand("echo", async (context) => { + return `Echo: ${context.payload}`; + }); + + await client.connect(); + + const result = await client.command("echo", "Hello", 5000); + expect(result).toBe("Echo: Hello"); + }, 10000); + + test("connect should resolve when already connected", async () => { + await server.registerCommand("echo", async (context) => { + return context.payload; + }); + + await client.connect(); + expect(client.status).toBe(Status.ONLINE); + + // Second connect should resolve immediately + await client.connect(); + expect(client.status).toBe(Status.ONLINE); + }, 10000); + + test("close should resolve when already closed", async () => { + await client.close(); + expect(client.status).toBe(Status.OFFLINE); + + // Second close should resolve immediately + await client.close(); + expect(client.status).toBe(Status.OFFLINE); + }, 10000); +});