WebSockets
WebJs has first-class WebSocket support. Export a WS function from any route.ts file and it becomes a WebSocket endpoint at that URL. On the client, connectWS() provides auto-reconnect, JSON serialisation, and send queuing out of the box.
Server Side: Export WS
Add a named WS export to any route.ts file. The function receives the raw WebSocket connection, the upgrade Request, and the route params:
// app/api/echo/route.ts
import type { WebSocket } from 'ws';
export function WS(ws: WebSocket, req: Request, { params }: { params: Record<string, string> }) {
console.log('New WebSocket connection');
ws.on('message', (data) => {
ws.send('echo: ' + data.toString());
});
ws.on('close', () => {
console.log('Client disconnected');
});
}
The WS Function Signature
export function WS(
ws: WebSocket, // ws library WebSocket instance
req: Request, // the HTTP upgrade Request
ctx: { params: Record<string, string> } // dynamic route params
): void
The three arguments give you everything you need:
ws: the ws library'sWebSocketinstance. Usews.on('message', ...),ws.send(),ws.close(), and all other ws APIs.req: a standardRequestconstructed from the HTTP upgrade request. It carries the original headers, cookies, URL, and query parameters. Use it for authentication: read the session cookie, verify a JWT from theAuthorizationheader, or check query params.{ params }: dynamic route segment values, just like in API route handlers. Forapp/api/rooms/[roomId]/route.ts,params.roomIdis the room ID from the URL.
Under the Hood
WebJs uses the ws library with noServer: true. When the Node.js HTTP server receives an Upgrade request:
- The URL is matched against the API route table.
- If a match is found, the route's module is loaded and checked for a
WSexport. - If
WSexists,wss.handleUpgrade()completes the WebSocket handshake. - The
WSfunction is called with the upgraded socket. - If no match is found or the route does not export
WS, the socket is rejected with an HTTP error (404 or 426) and destroyed.
WebSocket connections use HTTP/1.1 Upgrade. Even when the server is configured for HTTP/2 (with allowHTTP1: true), WebSocket upgrades happen over the HTTP/1.1 fallback path, which is the universally supported approach.
On Bun, the same WS(ws, req, ctx) export works unchanged. Bun's native WebSocket dispatches messages through server-level handlers rather than the ws-library per-socket events, so WebJs wraps Bun's socket in a thin adapter that re-exposes the core contract: ws.on('message', …), ws.on('close', …), ws.send(…), ws.readyState, and ws.close(). The typical message/close/broadcast handler and your broadcast() code are identical across runtimes. The lower-level 'error' / 'ping' / 'pong' events of the node ws library are not forwarded on Bun; a socket failure surfaces as a 'close' event there, so connection-cleanup logic keyed on 'close' still runs. One payload nuance: a text message arrives as a Buffer on Node (the ws default) and as a string on Bun, so call String(data) / data.toString() (works on both) before parsing rather than branching on the type. The examples below already do.
Accessing Cookies, Headers, and Auth
The req parameter carries the full HTTP upgrade request, so you can authenticate the connection before accepting messages:
// app/api/protected-ws/route.ts
import type { WebSocket } from 'ws';
export function WS(ws: WebSocket, req: Request) {
// Read cookies
const cookies = parseCookies(req.headers.get('cookie') || '');
const sessionId = cookies['session'];
// Verify auth
const user = sessions.get(sessionId);
if (!user) {
ws.close(4001, 'Unauthorized');
return;
}
ws.on('message', (data) => {
// user is available in the closure
handleMessage(user, JSON.parse(data.toString()));
});
}
function parseCookies(header: string): Record<string, string> {
const out: Record<string, string> = {};
for (const part of header.split(/;s*/)) {
const eq = part.indexOf('=');
if (eq > 0) out[part.slice(0, eq)] = decodeURIComponent(part.slice(eq + 1));
}
return out;
}
You can also read query parameters from the upgrade URL:
export function WS(ws: WebSocket, req: Request) {
const url = new URL(req.url);
const token = url.searchParams.get('token');
if (!verifyToken(token)) {
ws.close(4001, 'Bad token');
return;
}
// ... handle messages
}
Client Side: connectWS()
The connectWS() function from webjs creates a managed WebSocket connection with automatic reconnection, JSON handling, and send queuing:
import { connectWS } from '@webjsdev/core';
const conn = connectWS('/api/chat', {
onOpen: () => {
console.log('Connected');
},
onMessage: (data) => {
// data is already JSON-parsed if the server sent JSON
console.log('Received:', data);
},
onClose: (ev) => {
console.log('Disconnected:', ev.code, ev.reason);
},
onError: (ev) => {
console.error('WebSocket error', ev);
},
});
// Send a message (objects are JSON-stringified automatically)
conn.send({ type: 'say', text: 'Hello!' });
// Send a raw string
conn.send('ping');
// Later: permanently close (disables reconnect)
conn.close();
URL Resolution
Relative paths like '/api/chat' are automatically promoted to the correct WebSocket URL based on the current page's protocol:
http://page:ws://localhost:8080/api/chathttps://page:wss://example.com/api/chat
Absolute ws:// or wss:// URLs pass through unchanged.
Auto-Reconnect with Exponential Backoff
By default, connectWS() automatically reconnects when the connection drops. The backoff schedule is:
- 1st retry: 1 second
- 2nd retry: 2 seconds
- 3rd retry: 4 seconds
- 4th retry: 8 seconds
- ...and so on, capped at 30 seconds
Every successful connection resets the backoff counter to zero. To disable reconnect:
const conn = connectWS('/api/one-shot', {
reconnect: false,
onMessage: (data) => { /* ... */ },
});
Calling conn.close() permanently stops reconnection.
Automatic JSON Parse/Stringify
- Sending: if you call
conn.send(obj)with an object, it isJSON.stringify'd before sending. Strings,ArrayBuffer, andUint8Arrayare sent as-is. - Receiving: incoming text messages are parsed with
JSON.parse. If parsing fails (the message is not valid JSON), the raw string is passed toonMessage.
Send Queuing While Disconnected
If you call conn.send() before the socket is open or while it is reconnecting, the message is queued in memory. When the connection (re)opens, all queued messages are flushed in order. This means you do not need to check connection state before sending. Messages are never silently dropped.
// Safe to call immediately: message is queued if not yet connected
const conn = connectWS('/api/events');
conn.send({ type: 'subscribe', channel: 'updates' });
Connection State
The returned connection object exposes:
conn.send(data): send a message (queued if not open)conn.close(code?, reason?): permanently close and disable reconnectconn.socket: the underlyingWebSocketinstance (may benullwhile reconnecting)conn.readyState: current state: 0 (CONNECTING), 1 (OPEN), 2 (CLOSING), 3 (CLOSED)
The globalThis Pattern for Shared State
In dev mode, WebJs cache-busts module imports on every request so that edits take effect immediately. This means module-level variables (like a Set of connected clients) are reset every time the module reloads. To preserve shared state across dev reloads, attach it to globalThis:
// modules/chat/clients.ts
import type { WebSocket } from 'ws';
declare global {
var __webjs_chat_clients: Set<WebSocket> | undefined;
}
export const clients: Set<WebSocket> =
globalThis.__webjs_chat_clients ??
(globalThis.__webjs_chat_clients = new Set());
export function broadcast(msg: unknown, except?: WebSocket): void {
const payload = typeof msg === 'string' ? msg : JSON.stringify(msg);
for (const c of clients) {
if (c === except) continue;
if (c.readyState === 1) {
try { c.send(payload); } catch { /* ignore dead client */ }
}
}
}
This pattern works because globalThis persists across module re-imports within the same Node.js process. In production (no cache-busting), the module is loaded once and this pattern is a no-op.
Example: Live Chat (Broadcast)
A complete live chat implementation with server broadcast and client UI:
Server
// modules/chat/clients.ts (shared state as above)
import type { WebSocket } from 'ws';
declare global {
var __webjs_chat_clients: Set<WebSocket> | undefined;
}
export const clients: Set<WebSocket> =
globalThis.__webjs_chat_clients ??
(globalThis.__webjs_chat_clients = new Set());
export function broadcast(msg: unknown, except?: WebSocket): void {
const payload = typeof msg === 'string' ? msg : JSON.stringify(msg);
for (const c of clients) {
if (c === except) continue;
if (c.readyState === 1) {
try { c.send(payload); } catch {}
}
}
}
// app/api/chat/route.ts
import type { WebSocket } from 'ws';
import { clients, broadcast } from '#modules/chat/clients.ts';
export function GET() {
return new Response(
'Open a WebSocket to this URL. Currently connected: ' + clients.size + '\n',
{ headers: { 'content-type': 'text/plain; charset=utf-8' } },
);
}
export function WS(ws: WebSocket) {
clients.add(ws);
broadcast({ kind: 'join', count: clients.size }, ws);
ws.on('message', (data) => {
let msg: { text?: string };
try { msg = JSON.parse(data.toString()); } catch { msg = { text: data.toString() }; }
broadcast({
kind: 'say',
text: String(msg.text || '').slice(0, 500),
at: Date.now(),
});
});
ws.on('close', () => {
clients.delete(ws);
broadcast({ kind: 'leave', count: clients.size });
});
}
Client Component
// components/live-chat.ts
import { WebComponent, html, css, connectWS } from '@webjsdev/core';
export class LiveChat extends WebComponent {
static styles = css`
:host { display: flex; flex-direction: column; height: 400px; border: 1px solid #ccc; border-radius: 8px; overflow: hidden; }
.messages { flex: 1; overflow-y: auto; padding: 12px; }
.message { margin: 4px 0; }
.meta { color: #888; font-size: 12px; }
form { display: flex; padding: 8px; border-top: 1px solid #ccc; }
input { flex: 1; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
button { margin-left: 8px; padding: 8px 16px; }
`;
conn = null;
messages = signal([]);
connected = signal(false);
connectedCallback() {
super.connectedCallback();
this.conn = connectWS('/api/chat', {
onOpen: () => this.connected.set(true),
onClose: () => this.connected.set(false),
onMessage: (msg) => {
this.messages.set([...this.messages.get().slice(-99), msg]);
},
});
}
disconnectedCallback() {
this.conn?.close();
}
handleSend(e) {
e.preventDefault();
const input = this.shadowRoot.querySelector('input');
if (!input.value.trim()) return;
this.conn.send({ text: input.value });
input.value = '';
}
render() {
const messages = this.messages.get();
const connected = this.connected.get();
return html`
<div class="messages">
${messages.map(m => {
if (m.kind === 'say') {
return html`<div class="message">${m.text} <span class="meta">${new Date(m.at).toLocaleTimeString()}</span></div>`;
}
if (m.kind === 'join') return html`<div class="meta">Someone joined (${m.count} online)</div>`;
if (m.kind === 'leave') return html`<div class="meta">Someone left (${m.count} online)</div>`;
return html`<div class="meta">${JSON.stringify(m)}</div>`;
})}
</div>
<form @submit=${(e) => this.handleSend(e)}>
<input placeholder="${connected ? 'Type a message...' : 'Reconnecting...'}" ?disabled=${!connected} />
<button type="submit" ?disabled=${!connected}>Send</button>
</form>
`;
}
}
LiveChat.register('live-chat');
Use it in a page:
// app/chat/page.ts
import { html } from '@webjsdev/core';
import '#components/live-chat.ts';
export const metadata = { title: 'Live Chat' };
export default function ChatPage() {
return html`
<h1>Live Chat</h1>
<live-chat></live-chat>
`;
}
Example: Live Comments (Pub/Sub Bus + WS)
A more structured pattern uses a per-topic pub/sub bus so each post's comment section only receives relevant updates:
Pub/Sub Bus
// modules/pubsub.ts
import type { WebSocket } from 'ws';
declare global {
var __webjs_pubsub: Map<string, Set<WebSocket>> | undefined;
}
const topics: Map<string, Set<WebSocket>> =
globalThis.__webjs_pubsub ??
(globalThis.__webjs_pubsub = new Map());
export function subscribe(topic: string, ws: WebSocket): void {
if (!topics.has(topic)) topics.set(topic, new Set());
topics.get(topic)!.add(ws);
ws.on('close', () => {
topics.get(topic)?.delete(ws);
if (topics.get(topic)?.size === 0) topics.delete(topic);
});
}
export function publish(topic: string, msg: unknown): void {
const subs = topics.get(topic);
if (!subs) return;
const payload = JSON.stringify(msg);
for (const ws of subs) {
if (ws.readyState === 1) {
try { ws.send(payload); } catch {}
}
}
}
WebSocket Route
// app/api/comments/[postId]/ws/route.ts
import type { WebSocket } from 'ws';
import { subscribe, publish } from '#modules/pubsub.ts';
export function WS(ws: WebSocket, req: Request, { params }: { params: { postId: string } }) {
const topic = 'comments:' + params.postId;
subscribe(topic, ws);
ws.on('message', async (data) => {
const msg = JSON.parse(data.toString());
// Save comment to database
const comment = await db.comment.create({
data: { postId: Number(params.postId), text: msg.text, author: msg.author },
});
// Publish to all subscribers watching this post
publish(topic, { kind: 'new-comment', comment });
});
}
Client Component
// components/live-comments.ts
import { WebComponent, html, css, connectWS } from '@webjsdev/core';
export class LiveComments extends WebComponent({ postId: Number }) {
static styles = css`
:host { display: block; }
.comment { padding: 8px 0; border-bottom: 1px solid #eee; }
.author { font-weight: bold; }
`;
conn = null;
comments = signal<any[]>([]);
constructor() {
super();
this.postId = 0;
}
connectedCallback() {
super.connectedCallback();
if (this.postId) {
this.conn = connectWS('/api/comments/' + this.postId + '/ws', {
onMessage: (msg) => {
if (msg.kind === 'new-comment') {
this.comments.set([...this.comments.get(), msg.comment]);
}
},
});
}
}
disconnectedCallback() {
this.conn?.close();
}
handleSubmit(e) {
e.preventDefault();
const input = this.shadowRoot.querySelector('input');
this.conn?.send({ text: input.value, author: 'Anonymous' });
input.value = '';
}
render() {
return html`
<div>
${this.comments.get().map(c => html`
<div class="comment">
<span class="author">${c.author}</span>: ${c.text}
</div>
`)}
</div>
<form @submit=${(e) => this.handleSubmit(e)}>
<input placeholder="Add a comment..." />
<button type="submit">Post</button>
</form>
`;
}
}
LiveComments.register('live-comments');
Usage in a page:
// app/posts/[slug]/page.ts
import { html } from '@webjsdev/core';
import '#components/live-comments.ts';
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
if (!post) throw notFound();
return html`
<article>
<h1>${post.title}</h1>
<div>${post.body}</div>
</article>
<h2>Comments</h2>
<live-comments post-id="${post.id}"></live-comments>
`;
}
Coexisting with HTTP Handlers
A single route.ts can export both HTTP methods and WS. This is useful for providing a REST fallback alongside the WebSocket endpoint:
// app/api/events/route.ts
import type { WebSocket } from 'ws';
// HTTP: return recent events as JSON
export async function GET() {
const events = await db.event.findMany({ take: 50, orderBy: { createdAt: 'desc' } });
return Response.json(events);
}
// WS: stream events in real time
export function WS(ws: WebSocket) {
const listener = (event: unknown) => {
if (ws.readyState === 1) ws.send(JSON.stringify(event));
};
eventBus.on('new-event', listener);
ws.on('close', () => eventBus.off('new-event', listener));
}
Dynamic Route Params with WebSockets
Dynamic segments in WebSocket routes work exactly like in API routes:
// app/api/rooms/[roomId]/route.ts
import type { WebSocket } from 'ws';
export function WS(ws: WebSocket, req: Request, { params }: { params: { roomId: string } }) {
const room = params.roomId; // e.g. "general" for ws://localhost:8080/api/rooms/general
joinRoom(room, ws);
ws.on('message', (data) => broadcastToRoom(room, data.toString(), ws));
ws.on('close', () => leaveRoom(room, ws));
}
Summary
- Export
WSfrom anyroute.tsto create a WebSocket endpoint - The handler receives
(ws, req, { params }): the ws socket, the upgrade Request (for auth/cookies), and dynamic route params - Uses the
wslibrary under the hood with HTTP/1.1 Upgrade - Client:
connectWS(url, handlers)with auto-reconnect (exponential backoff, capped at 30s) - Outgoing objects are JSON-stringified, while incoming text is JSON-parsed when possible
- Messages sent while disconnected are queued and flushed on reconnect
- Use the
globalThispattern for shared state that survives dev-mode module reloads - WebSocket and HTTP handlers coexist in the same
route.tsfile