Skip to content

Client Guide ​

Experimental

This package is in an experimental phase. The API may change without following semver until it reaches a stable release.

@wooksjs/ws-client is a structured WebSocket client for browsers and Node.js. It provides RPC calls with automatic correlation, fire-and-forget messaging, push listeners with path matching, subscriptions with auto-resubscribe, and reconnection with backoff.

Zero runtime dependencies. Uses native WebSocket in browsers.

Installation ​

bash
npm install @wooksjs/ws-client

Node.js 22+ and browsers need nothing extra. For Node.js < 22, also install the ws package:

bash
npm install @wooksjs/ws-client ws

Creating a Client ​

Browser ​

ts
import { createWsClient } from '@wooksjs/ws-client'

const client = createWsClient('ws://localhost:3000/ws', {
  reconnect: true,
  rpcTimeout: 5000,
})

Node.js ​

Node.js 22+ has a native global WebSocket — the browser example above works as-is. On Node.js < 22, expose the ws package as a global polyfill before creating the client:

ts
import WebSocket from 'ws'
import { createWsClient } from '@wooksjs/ws-client'

globalThis.WebSocket ??= WebSocket as any

const client = createWsClient('ws://localhost:3000/ws', {
  rpcTimeout: 5000,
})

Options ​

ts
interface WsClientOptions {
  protocols?: string | string[]                // WebSocket subprotocols
  reconnect?: boolean | WsClientReconnectOptions  // Enable reconnection
  rpcTimeout?: number                          // Timeout for call() in ms (default: 10000)
  messageParser?: (raw: string) => any         // Custom deserializer (default: JSON.parse)
  messageSerializer?: (msg: any) => string     // Custom serializer (default: JSON.stringify)
}

Sending Messages ​

Fire-and-forget: send() ​

Send a message without expecting a reply. The server handler's return value is ignored.

ts
client.send('message', '/chat/general', { text: 'Hello!' })

Wire frame: { event: "message", path: "/chat/general", data: { text: "Hello!" } }

When disconnected with reconnect enabled, messages are queued and sent when the connection reopens. Without reconnect, they are silently dropped.

RPC: call() ​

Send a message and wait for the server's reply. Returns a typed Promise.

ts
const result = await client.call<{ joined: boolean }>('join', '/chat/general', { name: 'Alice' })
console.log(result.joined) // → true

Wire frame: { event: "join", path: "/chat/general", data: { name: "Alice" }, id: 1 }

The client auto-generates an incrementing numeric id. The server matches the reply by this ID.

Error handling ​

call() rejects with WsClientError in these cases:

ScenarioError code
Not connected when called503
Connection lost while waiting for reply503
client.close() called while waiting503
Timeout (rpcTimeout exceeded)408
Server sent an error replyServer's error code
ts
import { WsClientError } from '@wooksjs/ws-client'

try {
  await client.call('join', '/chat/general', { name: 'Alice' })
} catch (err) {
  if (err instanceof WsClientError) {
    if (err.code === 409) console.log('Name already taken')
    if (err.code === 408) console.log('Request timed out')
  }
}

Listening for Push Messages ​

on() ​

Register a handler for server-initiated push messages. Returns an unregister function.

ts
const off = client.on('message', '/chat/general', ({ event, path, params, data }) => {
  console.log(`${data.from}: ${data.text}`)
})

// Later: stop listening
off()

Handler signature ​

ts
interface WsClientPushEvent<T> {
  event: string                    // Event type from server
  path: string                     // Concrete path from server
  params: Record<string, string>   // Route params extracted by server (e.g. { room: 'general' })
  data: T                          // Payload
}

Route params are extracted by the server router and included in the push message. The client does not parse them.

Path matching ​

Exact match — O(1) lookup:

ts
client.on('message', '/chat/general', handler)
// Matches: /chat/general
// Ignores: /chat/random, /chat/general/sub

Wildcard suffix — prefix matching:

ts
client.on('message', '/chat/*', handler)
// Matches: /chat/general, /chat/random, /chat/general/sub
// Ignores: /users/42

Both exact and wildcard listeners fire if they match the same message. Multiple handlers for the same pattern are all called.

Subscriptions ​

subscribe() ​

Subscribe to a path with server acknowledgment and automatic resubscribe on reconnect.

ts
const unsub = await client.subscribe('/notifications')

// Later: unsubscribe
unsub()

Under the hood:

  1. Sends { event: "subscribe", path: "/notifications", id: N } (RPC)
  2. Waits for server acknowledgment
  3. Tracks the subscription for auto-resubscribe after reconnect
  4. Returns an unsubscribe function that sends { event: "unsubscribe", path: "/notifications" } (fire-and-forget) and removes the tracking

You still need on() to handle the push messages that arrive on the subscribed path.

Lifecycle Events ​

All lifecycle handlers return an unregister function. Multiple handlers can be registered for each event.

onOpen ​

Fires when the connection opens, including after reconnect.

ts
const off = client.onOpen(() => {
  console.log('Connected!')
})

After reconnect: queued messages are flushed first, then subscriptions are resubscribed, then onOpen fires.

onClose ​

Fires on every close, including before reconnect attempts.

ts
client.onClose((code, reason) => {
  console.log(`Disconnected: ${code} ${reason}`)
})

onError ​

Fires on WebSocket error events.

ts
client.onError((error) => {
  console.error('WebSocket error:', error)
})

onReconnect ​

Fires before each reconnection attempt, after the backoff delay.

ts
client.onReconnect((attempt) => {
  console.log(`Reconnecting... attempt ${attempt}`)
})

Closing ​

ts
client.close()

Closes the WebSocket with code 1000. Permanently disables reconnection. Rejects all pending RPCs with WsClientError(503, 'Connection closed'). Clears the message queue.

Reconnection ​

Enable reconnection to automatically recover from dropped connections:

ts
const client = createWsClient(url, {
  reconnect: true, // uses defaults
})

// Or with custom options:
const client = createWsClient(url, {
  reconnect: {
    enabled: true,
    maxRetries: 10,          // default: Infinity
    baseDelay: 1000,         // ms, default: 1000
    maxDelay: 30000,         // ms, default: 30000
    backoff: 'exponential',  // 'exponential' | 'linear', default: 'exponential'
  },
})

Backoff ​

  • Exponential (default): min(baseDelay × 2^attempt, maxDelay) → 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
  • Linear: min(baseDelay × (attempt + 1), maxDelay) → 1s, 2s, 3s, 4s, ...

What happens on unexpected close ​

  1. All pending RPCs are rejected (code 503, "Connection lost")
  2. onClose handlers fire
  3. After backoff delay: onReconnect handlers fire, new WebSocket is created
  4. On successful open: queued messages are flushed, subscriptions are resubscribed, onOpen fires

Re-joining rooms after reconnect ​

Reconnection restores the WebSocket connection and resubscribes subscribe() calls, but it does not automatically re-join rooms. Handle this in onOpen:

ts
client.onOpen(() => {
  // Re-join the room after reconnect
  client.call('join', `/chat/${currentRoom}`, { name: myName })
})

Complete Example ​

ts
import { createWsClient, WsClientError } from '@wooksjs/ws-client'

const client = createWsClient('ws://localhost:3000/ws', {
  reconnect: true,
  rpcTimeout: 5000,
})

// Wait for connection
await new Promise<void>(resolve => client.onOpen(resolve))

// Join a room (RPC)
try {
  await client.call('join', '/chat/general', { name: 'Alice' })
} catch (err) {
  if (err instanceof WsClientError && err.code === 409) {
    console.log('Name taken, try another')
  }
  throw err
}

// Listen for messages
client.on('message', '/chat/general', ({ data }) => {
  console.log(`${data.from}: ${data.text}`)
})

client.on('system', '/chat/general', ({ data }) => {
  console.log(`[system] ${data.text}`)
})

// Send a message (fire-and-forget)
client.send('message', '/chat/general', { text: 'Hello everyone!' })

// Leave and close
await client.call('leave', '/chat/general')
client.close()

Released under the MIT License.