UNPKG

ws-wrapper

Version:

Lightweight WebSocket wrapper lib with socket.io-like event handling, requests, and channels

527 lines (477 loc) 19.2 kB
/** * A minimal WebSocket-like interface accepted by WebSocketWrapper. * Compatible with the browser's native WebSocket and the `ws` npm package. */ export interface WebSocketLike { readonly readyState: number send(data: unknown): void close(code?: number, reason?: string): void onopen: ((event: unknown) => void) | null onmessage: ((event: { data: unknown }) => void) | null onerror: ((event: unknown) => void) | null onclose: ((event: unknown) => void) | null } /** * Middleware function signature for use with {@link WebSocketChannel.use}. * Call `next()` to pass the event through, or `next(err)` to drop it. */ export type MiddlewareFn = ( eventName: string, args: unknown[], next: (err?: Error) => void ) => void /** * A single event handler function. Using `any` for arguments allows concrete * typed handlers (e.g. `(x: string) => void`) to be assignable to this type * under TypeScript's function parameter variance rules. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type EventFn = (...args: any[]) => unknown /** * A map of event names to their handler function types. Pass this as a type * argument to {@link WebSocketChannel} or {@link WebSocketWrapper} to enable * typed `on()`, `emit()`, and `request()` calls. * * - **`TRemoteEvents`** (first argument) — describes events handled by the * remote peer. Constrains `emit()` arguments and infers the return type of * `request()`. * - **`TLocalEvents`** (second argument, optional) — describes events handled * by this side. Constrains the listener types passed to `on()` and `once()`. * Defaults to `TRemoteEvents`, which is correct for symmetric APIs where both * sides handle the same events. * * **Symmetric API** (both sides handle the same events — most common): * ```ts * interface ChatEvents { * msg: (from: string, text: string) => void * } * const socket = new WebSocketWrapper<ChatEvents>(rawSocket) * socket.on("msg", (from, text) => console.log(from, text)) // args are typed * socket.emit("msg", "alice", "hello") // args are typed * ``` * * **Asymmetric API** (client calls server, server calls client differently): * ```ts * interface ServerAPI { add: (a: number, b: number) => number } * interface ClientAPI { notify: (msg: string) => void } * * // Client: remote=ServerAPI, local=ClientAPI * const client = new WebSocketWrapper<ServerAPI, ClientAPI>(rawSocket) * const sum = await client.request("add", 1, 2) // typed as number * client.on("notify", (msg) => alert(msg)) // msg is string * * // Server: remote=ClientAPI, local=ServerAPI * const server = new WebSocketWrapper<ClientAPI, ServerAPI>(rawSocket) * server.on("add", (a, b) => a + b) // a, b are number * server.emit("notify", "hello") // arg is string * ``` * * To express a handler that returns an anonymous channel (for streaming), use * `Promise<WebSocketChannel>` as the return type in the event map: * ```ts * interface ServerAPI { * streamNumbers: () => Promise<WebSocketChannel> * } * const chan = await socket.request("streamNumbers") // typed as WebSocketChannel * for await (const n of chan) { ... } * ``` * * Requires TypeScript 4.5 or later (uses the built-in `Awaited` utility type). */ export type EventMap = Record<string, EventFn> /** * A namespaced channel backed by a {@link WebSocketWrapper}. * Exposes an EventEmitter-like API scoped to a channel name. * * @typeParam TRemoteEvents - Events handled by the remote peer (constrains * `emit()` and `request()`). Defaults to the loose {@link EventMap} type, * which allows any string key and degrades to untyped behaviour. * @typeParam TLocalEvents - Events handled by this side (constrains `on()` * and `once()` listeners). Defaults to `TRemoteEvents`. */ export declare class WebSocketChannel< TRemoteEvents extends EventMap = EventMap, TLocalEvents extends EventMap = TRemoteEvents, > { /** The channel namespace, or `null` for the root wrapper. */ readonly name: string | null /** * `true` for anonymous (request-scoped) channels: those created via * `this.channel()` inside a request handler **and** those received as the * resolved value of {@link request} on the requestor side. * `false` for named channels and the root wrapper. */ readonly isAnonymous: boolean /** * An `AbortSignal` that is aborted when this channel is closed (via * {@link close} or {@link abort}). Useful for registering cleanup * handlers that run when the channel is torn down. `null` if the runtime * does not support `AbortController`. * * The signal's `reason` property reflects the value passed to * `close(reason)` or `abort(err)`, including reasons reconstructed from * inbound anonymous-channel cancellation messages. */ readonly closeSignal: AbortSignal | null /** * Register a persistent event listener. * For all ws-wrapper events, the listener is called with the deserialized * arguments from the remote peer. * * Inside a **request** handler `this` is a per-request context object that * inherits all channel methods and additionally exposes: * - `this.signal` – the request's `AbortSignal` (if cancellation is supported) * - `this.channel()` – creates and returns an anonymous (request-scoped) * {@link WebSocketChannel}. Returns the same instance if called more than * once per request. Returning the channel from the handler causes the * requestor's `request()` Promise to resolve to the channel instead of a * plain value. */ on<K extends keyof TLocalEvents & string>( eventName: K, listener: TLocalEvents[K] ): this // eslint-disable-next-line @typescript-eslint/no-explicit-any on(eventName: string, listener: (...args: any[]) => any): this /** Alias for {@link on}. */ addListener<K extends keyof TLocalEvents & string>( eventName: K, listener: TLocalEvents[K] ): this // eslint-disable-next-line @typescript-eslint/no-explicit-any addListener(eventName: string, listener: (...args: any[]) => any): this /** Register a one-time event listener that is removed after it fires once. */ once<K extends keyof TLocalEvents & string>( eventName: K, listener: TLocalEvents[K] ): this // eslint-disable-next-line @typescript-eslint/no-explicit-any once(eventName: string, listener: (...args: any[]) => any): this /** Remove a previously registered listener. */ removeListener<K extends keyof TLocalEvents & string>( eventName: K, listener: TLocalEvents[K] ): this // eslint-disable-next-line @typescript-eslint/no-explicit-any removeListener(eventName: string, listener: (...args: any[]) => any): this /** Alias for {@link removeListener}. */ off<K extends keyof TLocalEvents & string>( eventName: K, listener: TLocalEvents[K] ): this // eslint-disable-next-line @typescript-eslint/no-explicit-any off(eventName: string, listener: (...args: any[]) => any): this /** Remove all listeners for `eventName`, or for all events if omitted. */ removeAllListeners(eventName?: string): this /** Returns the names of all events that have registered listeners. */ eventNames(): (keyof TLocalEvents & string)[] /** Returns the listeners registered for `eventName`. */ listeners<K extends keyof TLocalEvents & string>( eventName: K ): TLocalEvents[K][] // eslint-disable-next-line @typescript-eslint/no-explicit-any listeners(eventName: string): ((...args: any[]) => any)[] /** * Emit an event to the remote peer over the WebSocket. * For reserved events on the root wrapper, emits locally instead. * Throws if the channel has been closed. */ emit<K extends keyof TRemoteEvents & string>( eventName: K, ...args: Parameters<TRemoteEvents[K]> ): void emit(eventName: string, ...args: unknown[]): void /** * Set a one-shot timeout (ms) for the next {@link request} call only. * Overrides the wrapper-level `requestTimeout` option for that request. * Applies to both named and anonymous channels. * * @returns This channel for chaining. */ timeout(ms: number): this /** * Set the AbortSignal for the next {@link request} call only. When the * signal aborts, the request is cancelled and a cancellation message is * sent to the remote end. Applies to both named and anonymous channels. * * @returns This channel for chaining. */ signal(abortSignal: AbortSignal): this /** * Send a request to the remote peer and return a Promise that resolves * with the response value. Rejects with {@link RequestTimeoutError} on * timeout, or {@link RequestAbortedError} if cancelled. * Throws if the channel has been closed. * * When the remote handler returns an anonymous channel (via * `this.channel()`), declare its return type as `Promise<WebSocketChannel>` * in your event map and the resolved type will be `WebSocketChannel`. */ request<K extends keyof TRemoteEvents & string>( eventName: K, ...args: Parameters<TRemoteEvents[K]> ): Promise<Awaited<ReturnType<TRemoteEvents[K]>>> request( eventName: string, ...args: unknown[] ): Promise<unknown | WebSocketChannel> /** * Add a middleware function for this channel. Middleware runs before event * handlers and can inspect, modify, or block incoming events by calling * `next(err)`. * @returns This channel for chaining. */ use(fn: MiddlewareFn): this /** * Remove this channel from the wrapper and clean up all listeners, * middleware, and abort signal subscriptions. The optional `reason` is * forwarded to the internal `AbortController` so that `closeSignal.reason` * reflects why the channel was closed. */ close(reason?: unknown): void /** * For anonymous channels only: send a cancellation message to the remote * peer and close this channel. The optional `err` is serialized as the * cancellation reason; if omitted a default {@link RequestAbortedError} is * sent. A no-op if the channel is not anonymous or is already closed. */ abort(err?: Error): void /** * Returns an async iterator that consumes a one-way stream driven by the * remote peer. The remote emits `"next"` events on the channel with * `{ value, done }` payloads. At most one item is buffered; if a second * item arrives before the consumer calls `next()`, the iterator errors * but the channel remains open. * * Completing the iterator (normally or via `return()`) does **not** close * the channel, allowing the same channel to be iterated again. Call * {@link close} or {@link abort} when the channel is no longer needed. * * @example * const chan = await socket.request("open-stream") * for await (const value of chan) { * console.log(value) * } * chan.close() // channel stays open after iteration; close when done */ [Symbol.asyncIterator](): AsyncIterator<unknown> /** Retrieve user-defined data stored on the underlying socket wrapper. */ get(key: string): unknown /** Store user-defined data on the underlying socket wrapper. */ set(key: string, value: unknown): this } /** Options passed to the {@link WebSocketWrapper} constructor. */ export interface WebSocketWrapperOptions { /** * Enable debug logging (`true` uses `console.log`) or supply a custom * logging function. */ debug?: boolean | ((...args: unknown[]) => void) /** * Override the default error serialization. The function receives an * `Error` and must return a JSON string. */ errorToJSON?: (err: Error) => string /** * Default timeout in milliseconds for all outbound requests made through * this wrapper. `0` or omitted means no timeout. */ requestTimeout?: number /** * Optional wire encoder for ws-wrapper protocol frames. Defaults to * `JSON.stringify`. */ messageEncode?: (message: Record<string, unknown>) => unknown /** * Optional wire decoder for ws-wrapper protocol frames. Defaults to * `JSON.parse`. */ messageDecode?: (data: unknown) => Record<string, unknown> | null | undefined } /** Thrown when an outbound request exceeds its timeout. */ export declare class RequestTimeoutError extends Error { name: "RequestTimeoutError" } /** Thrown when an outbound request is cancelled via {@link AbortSignal} or {@link WebSocketWrapper.abort}. */ export declare class RequestAbortedError extends Error { name: "RequestAbortedError" /** The abort reason from the {@link AbortSignal}, if any. */ reason: unknown } /** * Wraps a WebSocket with socket.io-like event handling, Promise-based * request/response, and named channels. * * @typeParam TRemoteEvents - Events handled by the remote peer. Constrains * `emit()` arguments and infers the return type of `request()`. * @typeParam TLocalEvents - Events handled by this side. Constrains listener * types passed to `on()` and `once()`. Defaults to `TRemoteEvents`. * * @example * ```ts * import WebSocketWrapper from "ws-wrapper" * interface API { greet: (name: string) => string } * const socket = new WebSocket("wss://example.com") * const wrapper = new WebSocketWrapper<API>(socket) * wrapper.on("open", () => * wrapper.request("greet", "world").then(console.log) * ) * ``` */ export declare class WebSocketWrapper< TRemoteEvents extends EventMap = EventMap, TLocalEvents extends EventMap = TRemoteEvents, > extends WebSocketChannel<TRemoteEvents, TLocalEvents> { /** * Maximum number of messages to queue while the socket is not yet * connected. Attempts to send beyond this limit throw an error. */ static MAX_SEND_QUEUE_SIZE: number /** {@link RequestTimeoutError} class, exported for `instanceof` checks. */ static RequestTimeoutError: typeof RequestTimeoutError /** {@link RequestAbortedError} class, exported for `instanceof` checks. */ static RequestAbortedError: typeof RequestAbortedError /** * Create a new wrapper. If `socket` is `null` the wrapper starts * unbound; call {@link bind} later to attach a socket. */ constructor(socket: WebSocketLike | null, options?: WebSocketWrapperOptions) /** * The bound socket, or `null` if no socket is currently bound. * Assigning a new value calls {@link bind}. */ socket: WebSocketLike | null /** `true` while the socket's `readyState` is `CONNECTING`. */ readonly isConnecting: boolean /** `true` when the socket's `readyState` is `OPEN`. */ readonly isConnected: boolean /** Number of outbound requests that are awaiting a response. */ readonly pendingRequestCount: number /** Number of inbound requests currently being processed server-side. */ readonly activeRequestCount: number /** * Bind this wrapper to a new socket, replacing any previously bound socket. * Any queued messages are sent immediately if the new socket is already open. * Throws a `TypeError` if `socket` does not have `send` and `close` methods. */ bind(socket: WebSocketLike): this /** * Send raw data over the WebSocket. If the socket is not yet open the * data is queued and flushed once the connection opens. * @param ignoreMaxQueueSize - Bypass the {@link MAX_SEND_QUEUE_SIZE} limit. */ send(data: unknown, ignoreMaxQueueSize?: boolean): this /** * Close the underlying socket. All arguments are forwarded to * `WebSocket.close()`. */ disconnect(code?: number, reason?: string): this /** * Immediately reject all pending outbound requests and clear the send * queue. Useful when tearing down a connection without waiting for * individual request timeouts. If `err` is provided and is an `Error` * instance, it is used to reject all pending requests; otherwise a * {@link RequestAbortedError} is used. */ abort(err?: Error): this /** * Get (or lazily create) a {@link WebSocketChannel} for the given * namespace. The returned channel shares the same event-map type * parameters as this wrapper. Returns `this` when `namespace` is `null` * or `undefined`. Throws a `TypeError` if `namespace` is an empty string. */ of(namespace: null | undefined): this of( namespace: string ): WebSocketChannel<TRemoteEvents, TLocalEvents> /** * Reserved events fired locally on the root wrapper (not sent over the * wire). These overloads shadow the generic `on()` from * {@link WebSocketChannel} to provide accurate argument types. */ on( eventName: "open" | "connect", listener: (event: unknown) => void ): this on( eventName: "close" | "disconnect", listener: (event: unknown, wasOpen: boolean) => void ): this on(eventName: "error", listener: (event: unknown) => void): this on( eventName: "message", listener: (event: { data: unknown }, data: unknown) => void ): this on<K extends keyof TLocalEvents & string>( eventName: K, listener: TLocalEvents[K] ): this // eslint-disable-next-line @typescript-eslint/no-explicit-any on(eventName: string, listener: (...args: any[]) => any): this /** Reserved-event overloads for {@link once}. */ once( eventName: "open" | "connect", listener: (event: unknown) => void ): this once( eventName: "close" | "disconnect", listener: (event: unknown, wasOpen: boolean) => void ): this once(eventName: "error", listener: (event: unknown) => void): this once( eventName: "message", listener: (event: { data: unknown }, data: unknown) => void ): this once<K extends keyof TLocalEvents & string>( eventName: K, listener: TLocalEvents[K] ): this // eslint-disable-next-line @typescript-eslint/no-explicit-any once(eventName: string, listener: (...args: any[]) => any): this } export default WebSocketWrapper /** * Wraps an event handler function so that if it returns a sync or async * iterable (or a Promise that resolves to one), the values are streamed to the * requestor via an anonymous channel using the async iterator protocol. Each * yielded value is emitted as a `"next"` event with `{value, done: false}`. * When the iterable is exhausted, a final `"next"` with * `{value: undefined, done: true}` is emitted and the channel is closed. * * If the handler does not return a sync or async iterable, the request * Promise is rejected with a `TypeError`. * * Since the stream is one-way (handler → requestor), `yield` expressions in * generator handlers always evaluate to `undefined`. * * Declare the corresponding entry in your event map with a return type of * `Promise<WebSocketChannel>` so that `request()` infers the resolved type * correctly: * ```ts * interface ServerEvents { * streamNumbers: () => Promise<WebSocketChannel> * } * socket.on("streamNumbers", iterableHandler(function* () { * for (let i = 1; i <= 100; i++) yield i * })) * ``` * * @typeParam T - The type of values yielded by the iterable. * * @example * socket.on("data-stream", iterableHandler(function* (filter) { * for (const item of allItems.filter(filter)) { * yield item * } * })) */ export function iterableHandler<T = unknown>( fn: ( // eslint-disable-next-line @typescript-eslint/no-explicit-any ...args: any[] ) => | Iterable<T> | AsyncIterable<T> | Promise<Iterable<T> | AsyncIterable<T>> // eslint-disable-next-line @typescript-eslint/no-explicit-any ): (...args: any[]) => Promise<WebSocketChannel>