···11+/**
22+ * Configuration for reconnection behavior.
33+ */
44+export interface ReconnectOptions {
55+ /** Base delay in ms before the first reconnection attempt. Default: `5000`. */
66+ initialDelay?: number;
77+ /** Maximum delay in ms between reconnection attempts (caps exponential backoff). Default: `30000`. */
88+ maxDelay?: number;
99+ /** Multiplier applied to the delay after each failed attempt. Default: `1.5`. */
1010+ backoffFactor?: number;
1111+ /** Maximum consecutive reconnection attempts per service URL before switching. Default: `3`. */
1212+ maxAttempts?: number;
1313+ /** Maximum number of full cycles through all service URLs before giving up. Default: `2`. */
1414+ maxServiceCycles?: number;
1515+}
1616+1717+/**
1818+ * Configuration for the WebSocket connection layer.
1919+ */
2020+export interface ConnectionOptions {
2121+ /** One or more WebSocket service URLs. When multiple are provided, the connection fails over between them. */
2222+ service: string | string[];
2323+ /** Query parameters appended to the service URL. Values are serialized via `URLSearchParams`. */
2424+ queryParams?: Record<string, string | number | boolean>;
2525+ /** Reconnection behavior configuration. */
2626+ reconnect?: ReconnectOptions;
2727+ /** Interval in ms for sending WebSocket ping frames to keep the connection alive. Default: `10000`. */
2828+ pingInterval?: number;
2929+}
3030+3131+/**
3232+ * Possible states of a WebSocket connection.
3333+ */
3434+export type ConnectionState = "connecting" | "connected" | "closing" | "closed";
3535+3636+/**
3737+ * Read-only snapshot of the current connection state.
3838+ * Passed to handlers so they can inspect the connection without modifying it.
3939+ */
4040+export interface ConnectionInfo {
4141+ /** Current connection state. */
4242+ state: ConnectionState;
4343+ /** The service URL currently in use (including query params). */
4444+ currentService: string;
4545+ /** Index of the current service in the service array. */
4646+ serviceIndex: number;
4747+ /** All configured service URLs. */
4848+ allServices: string[];
4949+ /** Number of consecutive reconnection attempts for the current service. */
5050+ reconnectAttempts: number;
5151+ /** Number of completed full cycles through all services. */
5252+ serviceCycles: number;
5353+ /** Total number of messages received since the connection was created. */
5454+ messageCount: number;
5555+ /** Unix timestamp (ms) of the last received message, or `0` if none received. */
5656+ lastMessageTime: number;
5757+}
+19
src/index.ts
···11+export { WebSocketClient } from "./WebSocketClient";
22+export { WebSocketClientOptions } from "./types";
33+44+export { Logger, LogLevel } from "./logger/Logger";
55+export type { LoggerInterface, LoggerOptions } from "./logger/Logger";
66+77+export type {
88+ ConnectionOptions,
99+ ConnectionState,
1010+ ConnectionInfo,
1111+ ReconnectOptions,
1212+} from "./connection/types";
1313+1414+export type {
1515+ HandlerContext,
1616+ MessageHandler,
1717+ HandlerRegistration,
1818+ HandlerError,
1919+} from "./router/types";
+102
src/logger/Logger.ts
···11+/**
22+ * Log severity levels, ordered from most verbose to least verbose.
33+ * Used to filter which messages are emitted by the logger.
44+ */
55+export enum LogLevel {
66+ DEBUG = 0,
77+ INFO = 1,
88+ WARN = 2,
99+ ERROR = 3,
1010+}
1111+1212+/**
1313+ * Interface for custom logger implementations.
1414+ * Provide an object matching this interface to replace the built-in console logger.
1515+ */
1616+export interface LoggerInterface {
1717+ debug(message: string, context?: unknown): void;
1818+ info(message: string, context?: unknown): void;
1919+ warn(message: string, context?: unknown): void;
2020+ error(message: string, context?: unknown): void;
2121+}
2222+2323+/**
2424+ * Configuration options for the logger.
2525+ */
2626+export interface LoggerOptions {
2727+ /** Whether logging is enabled. Default: `true`. */
2828+ enabled?: boolean;
2929+ /** Minimum log level to emit. Messages below this level are discarded. Default: `LogLevel.INFO`. */
3030+ level?: LogLevel;
3131+ /** Custom logger implementation. When provided, all log calls are delegated to it. */
3232+ custom?: LoggerInterface;
3333+}
3434+3535+/**
3636+ * Internal logger used throughout the library.
3737+ * Each `WebSocketClient` instance creates its own `Logger` so configuration is isolated.
3838+ *
3939+ * @example
4040+ * ```typescript
4141+ * const logger = new Logger({ enabled: true, level: LogLevel.DEBUG });
4242+ * logger.info("Connected", { service: "wss://example.com" });
4343+ * ```
4444+ */
4545+export class Logger implements LoggerInterface {
4646+ private enabled: boolean;
4747+ private level: LogLevel;
4848+ private custom?: LoggerInterface;
4949+5050+ constructor(options: LoggerOptions = {}) {
5151+ this.enabled = options.enabled ?? true;
5252+ this.level = options.level ?? LogLevel.INFO;
5353+ this.custom = options.custom;
5454+ }
5555+5656+ debug(message: string, context?: unknown): void {
5757+ this.log(LogLevel.DEBUG, message, context);
5858+ }
5959+6060+ info(message: string, context?: unknown): void {
6161+ this.log(LogLevel.INFO, message, context);
6262+ }
6363+6464+ warn(message: string, context?: unknown): void {
6565+ this.log(LogLevel.WARN, message, context);
6666+ }
6767+6868+ error(message: string, context?: unknown): void {
6969+ this.log(LogLevel.ERROR, message, context);
7070+ }
7171+7272+ private log(level: LogLevel, message: string, context?: unknown): void {
7373+ if (!this.enabled || level < this.level) {
7474+ return;
7575+ }
7676+7777+ if (this.custom) {
7878+ const method = LogLevel[level].toLowerCase() as keyof LoggerInterface;
7979+ this.custom[method](message, context);
8080+ return;
8181+ }
8282+8383+ const timestamp = new Date().toISOString();
8484+ const tag = LogLevel[level];
8585+ const prefix = `[WAH ${tag}] ${timestamp}`;
8686+8787+ const logFn =
8888+ level === LogLevel.ERROR
8989+ ? console.error
9090+ : level === LogLevel.WARN
9191+ ? console.warn
9292+ : level === LogLevel.DEBUG
9393+ ? console.debug
9494+ : console.log;
9595+9696+ if (context !== undefined) {
9797+ logFn(prefix, message, context);
9898+ } else {
9999+ logFn(prefix, message);
100100+ }
101101+ }
102102+}
+106
src/router/WebSocketRouter.ts
···11+import { EventEmitter } from "events";
22+import { z } from "zod";
33+import { ConnectionInfo } from "../connection/types";
44+import { HandlerRegistration, HandlerError, MessageHandler } from "./types";
55+import { Logger } from "../logger/Logger";
66+77+/**
88+ * Validates incoming WebSocket messages against registered Zod schemas
99+ * and dispatches to matching handlers.
1010+ *
1111+ * All handlers whose schema matches the incoming message are invoked
1212+ * concurrently via `Promise.allSettled`. Handler errors are caught and
1313+ * emitted as `"error"` events rather than propagating.
1414+ *
1515+ * Events emitted:
1616+ * - `"error"` — a handler threw or JSON parsing failed (emits {@link HandlerError})
1717+ */
1818+export class WebSocketRouter extends EventEmitter {
1919+ private handlers: HandlerRegistration[] = [];
2020+ private logger: Logger;
2121+2222+ constructor(logger: Logger) {
2323+ super();
2424+ this.logger = logger;
2525+ }
2626+2727+ /**
2828+ * Registers a handler that will be invoked for every message matching the given schema.
2929+ *
3030+ * @typeParam T - The type inferred from the Zod schema.
3131+ * @param schema - Zod schema to validate messages against.
3232+ * @param handler - Function to call with the validated data.
3333+ */
3434+ register<T>(schema: z.ZodSchema<T>, handler: MessageHandler<T>): void {
3535+ this.handlers.push({
3636+ schema,
3737+ handler: handler as MessageHandler<unknown>,
3838+ });
3939+ this.logger.debug("Handler registered", { totalHandlers: this.handlers.length });
4040+ }
4141+4242+ /**
4343+ * Processes a raw WebSocket message: parses JSON, validates against all
4444+ * registered schemas, and invokes matching handlers.
4545+ *
4646+ * @param rawData - The raw message string from the WebSocket.
4747+ * @param sendFn - Function to send data back through the WebSocket.
4848+ * @param connectionInfo - Current connection state snapshot.
4949+ */
5050+ async route(
5151+ rawData: string,
5252+ sendFn: (data: unknown) => boolean,
5353+ connectionInfo: ConnectionInfo
5454+ ): Promise<void> {
5555+ let parsed: unknown;
5656+ try {
5757+ parsed = JSON.parse(rawData);
5858+ } catch {
5959+ this.logger.debug("Failed to parse message as JSON", { rawData: rawData.slice(0, 200) });
6060+ this.emit("error", {
6161+ error: new Error("Failed to parse WebSocket message as JSON"),
6262+ rawData,
6363+ timestamp: Date.now(),
6464+ } satisfies HandlerError);
6565+ return;
6666+ }
6767+6868+ const matched: Promise<void>[] = [];
6969+7070+ for (const registration of this.handlers) {
7171+ const result = registration.schema.safeParse(parsed);
7272+ if (result.success) {
7373+ matched.push(
7474+ this.invokeHandler(registration, result.data, rawData, sendFn, connectionInfo)
7575+ );
7676+ }
7777+ }
7878+7979+ if (matched.length === 0) {
8080+ this.logger.debug("No handlers matched message");
8181+ return;
8282+ }
8383+8484+ this.logger.debug("Dispatching to matched handlers", { count: matched.length });
8585+ await Promise.allSettled(matched);
8686+ }
8787+8888+ private async invokeHandler(
8989+ registration: HandlerRegistration,
9090+ data: unknown,
9191+ rawData: string,
9292+ sendFn: (data: unknown) => boolean,
9393+ connectionInfo: ConnectionInfo
9494+ ): Promise<void> {
9595+ try {
9696+ await registration.handler({ data, rawData, send: sendFn, connection: connectionInfo });
9797+ } catch (error) {
9898+ this.logger.error("Handler threw an error", error);
9999+ this.emit("error", {
100100+ error,
101101+ rawData,
102102+ timestamp: Date.now(),
103103+ } satisfies HandlerError);
104104+ }
105105+ }
106106+}
+48
src/router/types.ts
···11+import { z } from "zod";
22+import { ConnectionInfo } from "../connection/types";
33+44+/**
55+ * Context object passed to every matched handler.
66+ * Provides the validated message data, a send function for replies,
77+ * and read-only connection info.
88+ *
99+ * @typeParam T - The inferred type of the matched Zod schema.
1010+ */
1111+export interface HandlerContext<T> {
1212+ /** The validated and typed message data. */
1313+ data: T;
1414+ /** The original raw message string before parsing. */
1515+ rawData: string;
1616+ /** Sends data back through the WebSocket. Returns `true` if sent, `false` if not connected. */
1717+ send: (data: unknown) => boolean;
1818+ /** Read-only snapshot of the current connection state. */
1919+ connection: ConnectionInfo;
2020+}
2121+2222+/**
2323+ * A function that handles a message matching a specific schema.
2424+ * Can be synchronous or asynchronous.
2525+ *
2626+ * @typeParam T - The inferred type of the matched Zod schema.
2727+ */
2828+export type MessageHandler<T> = (ctx: HandlerContext<T>) => void | Promise<void>;
2929+3030+/**
3131+ * Internal registration entry pairing a Zod schema with its handler.
3232+ */
3333+export interface HandlerRegistration {
3434+ schema: z.ZodSchema;
3535+ handler: MessageHandler<unknown>;
3636+}
3737+3838+/**
3939+ * Error information emitted when a handler throws during execution.
4040+ */
4141+export interface HandlerError {
4242+ /** The error thrown by the handler. */
4343+ error: unknown;
4444+ /** The raw message string that triggered the handler. */
4545+ rawData: string;
4646+ /** Unix timestamp (ms) when the error occurred. */
4747+ timestamp: number;
4848+}
+11
src/types.ts
···11+import { ConnectionOptions } from "./connection/types";
22+import { LoggerOptions } from "./logger/Logger";
33+44+/**
55+ * Configuration options for {@link WebSocketClient}.
66+ * Combines connection settings with logger configuration.
77+ */
88+export interface WebSocketClientOptions extends ConnectionOptions {
99+ /** Logger configuration. When omitted, logging is enabled at INFO level. */
1010+ logger?: LoggerOptions;
1111+}