ReferenceReal-time & Streaming
@nextrush/websocket

WebSocket

WebSocket plugin for NextRush with rooms, broadcasting, and route-based handlers.

HTTP handles request-response. WebSocket handles persistent, bidirectional connections — chat, live dashboards, collaborative editing, and real-time notifications.

Source & internals

This plugin integrates the ws library with NextRush through two integration shapes: a NextRush Extension (createWebSocketExtension(), recommended — decorates app.wss and wires disposal into app.close()) and a manual factory (createWebSocket(), for full manual lifecycle control or attaching outside a NextRush Application). Both register route handlers and attach to your HTTP server; room management and broadcasting are built into either form.

Default Behavior

With default options, the plugin:

  • Accepts WebSocket connections on registered route paths
  • Pings all connections every 30 seconds (heartbeat); terminates a connection the first tick it fails to respond to the previous ping
  • Limits messages to 1MB
  • Allows unlimited connections
  • Limits each connection to 100 rooms
  • Allows all origins

Installation

$ pnpm add @nextrush/websocket ws
$ pnpm add -D @types/ws

The ws package is a required peer dependency — install it alongside the plugin. This plugin targets Node.js 22+ only — it imports node:http/node:net types directly and has no Bun/Deno/edge code path; use each runtime's native WebSocket API there instead.

Authenticate Before Upgrade

WebSocket connections bypass standard HTTP middleware after the upgrade. Use verifyClient to authenticate during the handshake — not after the connection is open.

Minimal Usage

Recommended: the Extension form. createWebSocketExtension() decorates app.wss and wires wss.close() into app.close()'s teardown automatically — a missed manual disposal can never leak the heartbeat timer or leave sockets open after shutdown.

import { createApp, listen } from 'nextrush';
import { createWebSocketExtension } from '@nextrush/websocket';

const app = createApp().extend(createWebSocketExtension());
await app.ready();

app.wss.on('/chat', (conn) => {
  conn.join('general');
  conn.on('message', (msg) => conn.broadcast('general', msg));
  conn.on('close', () => console.log(`${conn.id} disconnected`));
});

app.use(app.wss.upgrade());

const { server } = await listen(app, 8080);
await app.wss.attach(server);

// app.close() now also calls app.wss.close() — heartbeat cleared, connections
// closed, underlying `ws` server closed. No separate wss.close() call needed.

Manual form — for attaching to a server that isn't a NextRush Application, or when you want full manual lifecycle control:

import { createApp } from '@nextrush/core';
import { listen } from '@nextrush/adapter-node';
import { createWebSocket } from '@nextrush/websocket';

const app = createApp();
const wss = createWebSocket();

wss.on('/chat', (conn) => {
  conn.on('message', (msg) => {
    conn.send(`Echo: ${msg}`);
  });

  conn.on('close', (code, reason) => {
    console.log('Disconnected:', code, reason);
  });
});

app.use(wss.upgrade());

const { server } = await listen(app, 8080);
wss.attach(server);

Two separate calls do two separate jobs, in either form: app.use(wss.upgrade()) (or app.use(app.wss.upgrade())) registers a passthrough Middleware so app.use() accepts it; wss.attach(server) is what actually wires the real 'upgrade' event listener onto the raw node:http Server. Neither call does the other's job. listen() returns a ServerInstance — pass its .server property (the underlying node:http Server) to attach(), not the ServerInstance wrapper itself. Only disposal differs between the two forms — the Extension form wires wss.close() into app.close(); the manual form leaves it to you.

Configuration Options

Pass options to createWebSocket() to customize behavior.

const wss = createWebSocket({
  maxPayload: 512 * 1024,
  heartbeatInterval: 15000,
  maxConnections: 500,
  maxRoomsPerConnection: 50,
  allowedOrigins: ['https://example.com', 'https://*.example.com'],
  perMessageDeflate: true,
  verifyClient: async (req) => {
    const token = req.headers['authorization'];
    return Boolean(token);
  },
  onConnection: (conn) => console.log('Connected:', conn.id),
  onClose: (conn, code, reason) => console.log('Closed:', conn.id, code),
  onError: (conn, error) => console.error('Error:', conn.id, error.message),
});

WebSocketOptions

PropertyTypeDescription
path?string | string[]= ['/']Allowed paths for WebSocket connections
maxPayload?number= 1048576 (1MB)Maximum message size in bytes
heartbeatInterval?number= 30000Ping interval in ms (0 disables heartbeat entirely). A connection is terminated the first tick where it has not responded to the previous ping — a dead connection is detected within one heartbeatInterval window.
clientTimeout?number= 60000Declared but not read by any code path — has no observable effect. The actual termination window is governed entirely by heartbeatInterval. Kept in the type for forward compatibility; do not rely on it.
maxConnections?number= 0Maximum concurrent connections (0 for unlimited)
maxRoomsPerConnection?number= 100Maximum rooms a connection can join (0 for unlimited)
allowedOrigins?string[]= []Allowed origins for CORS. Supports wildcards. Empty array allows all.
perMessageDeflate?boolean= falseEnable per-message deflate compression
verifyClient?(req: IncomingMessage) => boolean | Promise<boolean>Custom client verification. Return true to allow, false to reject.
onConnection?(conn: WSConnection) => voidCalled when a connection is established
onClose?(conn: WSConnection, code: number, reason: string) => voidCalled when a connection is closed
onError?(conn: WSConnection, error: Error) => voidCalled when an error occurs

Route-Based Handlers

Register handlers for different WebSocket paths with wss.on(path, handler):

wss.on('/chat', (conn, request) => {
  conn.on('message', (msg) => {
    conn.broadcast('chat', msg);
  });
});

wss.on('/notifications', (conn) => {
  conn.json({ type: 'welcome', timestamp: Date.now() });
});

Path matching supports exact matches, parameterized segments (:id), and wildcards (*).

Connection API

Each handler receives a WSConnection with these properties and methods.

WSConnection Properties

PropertyTypeDescription
idstringUnique connection identifier (UUID)
urlstringConnection URL path
isOpenbooleanWhether the connection is currently open
requestIncomingMessageOriginal HTTP upgrade request

Sending Messages

conn.send('Hello'); // Send string
conn.send(Buffer.from([1, 2, 3])); // Send binary buffer
conn.json({ type: 'update', items: [] }); // Send JSON (auto-stringified)

Events

conn.on('message', (data) => {
  /* string or Buffer */
});
conn.on('close', (code, reason) => {
  /* cleanup */
});
conn.on('error', (error) => {
  /* handle error */
});
conn.on('ping', (data) => {
  /* ping received */
});
conn.on('pong', (data) => {
  /* pong received */
});

conn.off(event, handler); // Remove a listener

Rooms

Connections can join rooms for organized broadcasting:

conn.join('general');
conn.join('notifications');
conn.leave('general');
conn.leaveAll();

const rooms = conn.getRooms(); // ['notifications']

Room names must be non-empty strings with a maximum length of 256 characters. Each connection can join up to 100 rooms by default (configurable via maxRoomsPerConnection).

Broadcasting from a Connection

broadcast() sends to all room members excluding the sender:

conn.broadcast('general', 'User joined!');
conn.broadcastJson('general', { type: 'join', user: 'Alice' });

Middleware

Add WebSocket-specific middleware for cross-cutting concerns:

wss.use((conn, request, next) => {
  console.log('New connection:', conn.id, request.url);
  next();
});

wss.use((conn, request, next) => {
  const token = request.headers['x-auth-token'];
  if (!token) {
    conn.close(4001, 'Unauthorized');
    return;
  }
  next();
});

Middleware runs in registration order before the route handler executes.

Server-Level APIs

The WebSocket server instance provides broadcasting across all connections and server management.

// Broadcast to all connections
wss.broadcast('Hello everyone!');
wss.broadcastJson({ type: 'announcement', text: 'Server restarting' });

// Broadcast to a specific room
wss.broadcastToRoom('chat', 'System message');
wss.broadcastJsonToRoom('chat', { type: 'system', text: 'Welcome' });

// Exclude a specific connection
wss.broadcast('Hello others!', currentConn);

Server Statistics

const connections = wss.getConnections();
const count = wss.getConnectionCount();
const rooms = wss.getRooms();
const members = wss.getRoomConnections('chat');

Shutdown

wss.closeAll(1001, 'Server shutting down');
wss.close(); // Stops heartbeat, closes all connections, releases resources

Attaching to the HTTP Server

No auto-attach helper — attach() is always explicit

Neither integration form wires the HTTP 'upgrade' event automatically. A NextRush Middleware or Extension.setup() only ever receives the app/context, never the raw node:http Server the upgrade handshake needs — so wss.attach(server) (or app.wss.attach(server)) must always be called explicitly, once you have the real server instance from listen(). This is true for both createWebSocket() and createWebSocketExtension(); the Extension form only automates disposal via app.close(), not the upgrade-wiring contract.

Extension vs. manual factory

createWebSocket() (manual)createWebSocketExtension() (recommended)
ReturnsA plain WebSocketServerA NextRush Extension<{ wss: WebSocketServer }>
AccessThe local wss variable you createdapp.wss, via app.extend()'s decoration
DisposalYou must call wss.close() yourselfapp.close() calls it for you automatically
Best forAttaching to a server outside a NextRush Application; full manual lifecycle controlAny NextRush app using WebSockets — the default choice

Common Mistakes

Forgetting to call wss.attach(server.server). The WebSocket server does not listen for connections until attached to the raw HTTP server. Without this step, upgrade requests are never handled.

Calling conn.broadcast() without joining a room first. broadcast(room, data) sends to members of the specified room. If no connections have joined that room, the message goes nowhere.

Not installing the ws peer dependency. The plugin dynamically imports ws at runtime. If the package is missing, an error is thrown with installation instructions.

Calling wss.close() manually with the Extension form. createWebSocketExtension() already wires wss.close() into app.close()'s teardown — you do not need to call it yourself. Only the manual createWebSocket() form requires calling wss.close() on your own.

Sending non-string/Buffer data with conn.send(). Use conn.json(data) for objects. conn.send() accepts string or Buffer only.

Troubleshooting

WebSocket connections return 404. Ensure the request path matches a registered route via wss.on(path, handler) or the path option in configuration.

Connections drop after ~30 seconds of inactivity. The heartbeat system terminates a connection the first ping tick where it hasn't responded to the previous ping — within one heartbeatInterval window (default: 30s), not clientTimeout. clientTimeout is declared in WebSocketOptions but no code path reads it; it has no observable effect. To change the termination window, adjust heartbeatInterval instead.

Origin rejected with 403 Forbidden. When allowedOrigins is configured, requests without an Origin header are denied. Verify the client sends the correct Origin header.

MaxRoomsExceededError thrown. A connection attempted to join more rooms than maxRoomsPerConnection allows. Increase the limit or have the connection leave unused rooms.


Was this helpful?

On this page