@culpeo/async-ws
v1.2.1
Published
Promise-first WebSocket client for Node.js and browsers
Maintainers
Readme
@culpeo/async-ws
Promise-first WebSocket client for Node.js and browsers.
@culpeo/async-ws is a cross-platform WebSocket client that turns the event-driven WebSocket API into a small, imperative, promise-based interface.
Features
- Works in both Node.js and browsers from one package
- Promise-based
connect(),send(),receive(), andclose()APIs - Async iteration support with
for await...of - Message buffering for messages that arrive before
receive()is called - Configurable
maxBufferSizewith oldest-message eviction when full - Connect timeout and
AbortSignalsupport - Keep-alive with automatic ping/pong (Node.js)
- Server-side socket adoption with
fromSocket()(Node.js) - Exposed WebSocket properties (
protocol,url,bufferedAmount,extensions) - Clean close information via
lastCloseInfo - TypeScript-first with bundled type definitions
- Binary and text message support
- Browser build uses the native
WebSocket; Node build usesws
Install
npm install @culpeo/async-wsyarn add @culpeo/async-wspnpm add @culpeo/async-wsQuick Start
import { WebSocketClient } from "@culpeo/async-ws";
const client = new WebSocketClient();
await client.connect("wss://echo.websocket.events");
await client.send("hello");
const message = await client.receive();
console.log(message.data); // string | ArrayBuffer
console.log(message.binary); // boolean
await client.close();API Reference
WebSocketClient
Constructor
new WebSocketClient(options?: ClientOptions)Creates a new client instance.
Constructor options
maxBufferSize?: number- Maximum number of incoming messages to keep buffered before they are consumed
- Default:
0(unlimited) - When the limit is reached, the oldest buffered message is dropped
keepAlive?: KeepAliveOptions- Enables automatic ping/pong keep-alive (Node.js only)
- Throws if used in a browser environment
Properties
client.readyState
readonly readyState: WebSocketStateReturns the current client state:
"idle""connecting""open""closing""closed""errored"
client.protocol
readonly protocol: stringReturns the negotiated subprotocol, or "" when not connected.
client.url
readonly url: stringReturns the URL of the WebSocket connection, or "" when not connected.
client.bufferedAmount
readonly bufferedAmount: numberReturns the number of bytes queued for transmission, or 0 when not connected.
client.extensions
readonly extensions: stringReturns the negotiated extensions, or "" when not connected.
client.lastCloseInfo
readonly lastCloseInfo: WebSocketCloseInfo | nullReturns close metadata from the most recent close event, or null if the socket has not closed yet.
Static Methods
fromSocket()
static fromSocket(rawSocket: unknown, options?: ClientOptions): WebSocketClientWraps an already-open WebSocket into a WebSocketClient in the "open" state, ready to send and receive. Intended for server scenarios where a WebSocketServer hands you an established connection.
Node.js only. Throws in browser builds.
import { WebSocketServer } from "ws";
import { WebSocketClient } from "@culpeo/async-ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", async (socket) => {
const client = WebSocketClient.fromSocket(socket);
for await (const msg of client) {
console.log("received:", msg.data);
await client.send("echo: " + msg.data);
}
});The client takes ownership of the socket lifecycle — calling close() will close the underlying socket. Call fromSocket() immediately in the connection handler to avoid missing messages.
Accepts any WebSocket-compatible object (validated structurally, not via instanceof), so it works even when multiple copies of the ws package are installed.
Instance Methods
connect()
connect(url: string | URL, options?: ConnectOptions): Promise<void>Opens a WebSocket connection and resolves when the connection is established.
Rejects when:
- the client is already connecting, open, or closing
- the socket constructor throws
- the connection errors before opening
- the socket closes before opening
ConnectOptions
protocols?: string | string[]— WebSocket subprotocols to requestheaders?: Record<string, string>— custom handshake headers in Node.jstimeout?: number— connection timeout in milliseconds; rejects if the connection is not established within this timesignal?: AbortSignal— an abort signal to cancel the connection attempt
In browsers, passing
headersthrows because the native WebSocket API does not support custom headers.
send()
send(data: string | ArrayBuffer | ArrayBufferView): Promise<void>Sends text or binary data.
Resolves when the underlying socket accepts the payload. Rejects if the client is not open or if the underlying adapter reports an error.
receive()
receive(): Promise<WebSocketMessage>Resolves with the next incoming message.
Behavior:
- If buffered messages exist, returns the oldest buffered message immediately
- If no buffered message exists, waits for the next incoming message
- If the socket closes after buffering messages, buffered messages are still drained first
- Rejects when the client is not open and no buffered messages remain
close()
close(code?: number, reason?: string): Promise<void>Starts the close handshake and resolves when the socket closes.
Behavior:
- Resolves immediately if the client is idle, already closed, or errored
- If a close is already in progress, waits for the close event
- Validates custom close codes before calling the underlying socket
- Accepts
1000or values in the range3000-4999
Async iterator
client[Symbol.asyncIterator](): AsyncGenerator<WebSocketMessage>Allows consumption with for await...of.
Behavior:
- Yields incoming messages as they arrive
- Ends iteration on a clean close
- Throws on unexpected or error-driven termination
- Does not automatically close the socket if you
breakout of the loop
Types
ConnectOptions
interface ConnectOptions {
protocols?: string | string[];
headers?: Record<string, string>;
timeout?: number;
signal?: AbortSignal;
}Connection-time options.
ClientOptions
interface ClientOptions {
maxBufferSize?: number;
keepAlive?: KeepAliveOptions;
}Client-level configuration.
KeepAliveOptions
interface KeepAliveOptions {
interval: number;
timeout?: number;
}interval— milliseconds between pingstimeout— milliseconds to wait for a pong before terminating the connection (default:interval)
WebSocketMessage
interface WebSocketMessage {
data: string | ArrayBuffer;
binary: boolean;
}Represents a received message payload.
WebSocketCloseInfo
interface WebSocketCloseInfo {
code: number;
reason: string;
wasClean: boolean;
}Represents close metadata captured from the underlying socket.
WebSocketState
type WebSocketState =
"idle" | "connecting" | "open" | "closing" | "closed" | "errored";Represents the client lifecycle state.
Browser vs Node
@culpeo/async-ws ships one API for both environments:
- Node.js build uses the
wspackage internally - Browser build uses the native
WebSocketimplementation
This is handled at build time with Rollup. The browser bundle aliases the Node adapter module to a browser-specific adapter, so application code does not need environment checks or separate imports.
In practice, that means you write this once:
import { WebSocketClient } from "@culpeo/async-ws";…and the appropriate adapter is selected by the published package exports and browser build.
Async Iterator
import { WebSocketClient } from "@culpeo/async-ws";
const client = new WebSocketClient();
await client.connect("wss://example.com/ws");
try {
for await (const message of client) {
if (!message.binary) {
console.log("text:", message.data);
}
}
} finally {
await client.close();
}This is useful when you want a stream-like consumer loop without manually calling receive() each time.
Error Handling
All core operations are async and communicate failure by rejecting:
connect()rejects on invalid state, connection failure, early close, timeout, or abortsend()rejects when called before the socket is open or when the adapter fails to sendreceive()rejects when the client is not in a receivable state and no buffered messages remainclose()rejects for invalid close codes
Additional notes:
- Connection errors are treated as terminal for pending receivers
- A socket error is typically followed by a close event; close metadata is exposed through
lastCloseInfo - If buffered messages exist when a close happens, those messages are still delivered before
receive()starts rejecting
A simple pattern:
try {
await client.connect("wss://example.com/ws");
await client.send("ping");
const reply = await client.receive();
console.log(reply);
} catch (error) {
console.error("WebSocket operation failed", error);
console.error("Last close info:", client.lastCloseInfo);
}Message Buffering
Incoming messages are buffered when they arrive before a consumer calls receive().
By default, buffering is unlimited:
const client = new WebSocketClient();To cap memory usage, set maxBufferSize:
const client = new WebSocketClient({ maxBufferSize: 100 });When the buffer is full:
- the oldest message is removed
- the newest message is stored
This makes buffering predictable for bursty message streams while keeping the public API simple.
Connection Timeout and Abort
Use timeout to reject if the connection isn't established within a deadline:
await client.connect("wss://example.com/ws", { timeout: 5000 });Use signal to cancel a connection attempt at any time:
const controller = new AbortController();
setTimeout(() => controller.abort(), 3000);
await client.connect("wss://example.com/ws", { signal: controller.signal });Both can be combined:
await client.connect("wss://example.com/ws", {
timeout: 10000,
signal: controller.signal,
});Server-Side Socket Adoption (Node.js)
Use fromSocket() to wrap connections from a WebSocketServer:
import { WebSocketServer } from "ws";
import { WebSocketClient } from "@culpeo/async-ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", async (socket) => {
const client = WebSocketClient.fromSocket(socket);
const msg = await client.receive();
await client.send("got: " + msg.data);
await client.close();
});The same ClientOptions are supported:
const client = WebSocketClient.fromSocket(socket, {
maxBufferSize: 100,
keepAlive: { interval: 30000 },
});Keep-Alive (Node.js)
Enable automatic ping/pong to detect dead connections:
const client = new WebSocketClient({
keepAlive: { interval: 30000, timeout: 5000 },
});
await client.connect("wss://example.com/ws");- Sends a ping every
intervalmilliseconds - If no pong is received within
timeoutmilliseconds, the connection is terminated timeoutdefaults tointervalif omitted- Not available in browsers — the constructor throws if
keepAliveis configured in a browser environment
Building from Source
git clone <your-fork-or-repo-url>
cd <repo-directory>
npm installRun tests:
npm test
npm run test:browserBuild the package:
npm run buildCurrent build outputs include:
- CommonJS for Node.js
- ESM for Node.js
- Browser ESM
- Browser IIFE bundle
- Bundled TypeScript declarations
License
MIT
