Skip to content

WebSockets Guide

Build real-time features with the official WebSocket plugin and decorators. This guide matches current veloce-ts releases (see Changelog).

  • @WebSocket(path) — marks a class as a WebSocket handler for that path.
  • @OnConnect / @OnMessage / @OnDisconnect — lifecycle hooks.
  • WebSocketPlugin — registers routes and performs runtime-specific upgrades (Bun / Deno / Node).
  • WebSocketConnection — send, broadcast, join, leave, close, plus native WebSocket access.

One @OnMessage per class: metadata only stores a single message handler (the last @OnMessage wins if you declare more than one). Model different client payloads with one Zod schema (for example a discriminated union on a type field).

import 'reflect-metadata';
import {
VeloceTS,
WebSocketPlugin,
WebSocket,
OnConnect,
OnMessage,
OnDisconnect,
WebSocketConnection,
} from 'veloce-ts';
import { z } from 'zod';
const ChatMessage = z.object({
type: z.literal('chat'),
text: z.string().min(1),
});
@WebSocket('/ws/chat')
class ChatSocket {
@OnConnect()
onOpen(client: WebSocketConnection) {
client.join('lobby');
client.send({ type: 'welcome', message: 'Connected' });
}
@OnMessage(ChatMessage)
onData(client: WebSocketConnection, msg: z.infer<typeof ChatMessage>) {
client.broadcast({ type: 'chat', text: msg.text }, 'lobby');
}
@OnDisconnect()
onClose(client: WebSocketConnection) {
console.log('disconnected', client.id);
}
}
const app = new VeloceTS({ title: 'Chat', version: '1.0.0' });
app.usePlugin(new WebSocketPlugin());
app.include(ChatSocket);
await app.compile();
app.listen(3000);
  • Import WebSocketPlugin from veloce-ts (also available under veloce-ts/plugins).
  • The plugin constructor takes no options; the path is always the string passed to @WebSocket('/path').
  • Call await app.compile() after controllers/handlers are include()d so metadata is compiled.
RuntimeUpgrade pathExtra install
BunBun.serve()’s native upgrade()—
DenoDeno.upgradeWebSocket()—
Node 20+@hono/node-ws (wraps ws)npm install @hono/node-ws

On Node the plugin needs the real http.Server, which only exists once app.listen() has run — so it injects the WebSocket handler in the plugin’s onStart() hook. Two consequences:

  • Serve with app.listen(). Handing app.fetch to a server you created yourself leaves the plugin without a server to attach to, and upgrades will not complete.
  • Node needs @hono/node-server too, which listen() already requires.

Forget @hono/node-ws and startup fails with the install command rather than a broken upgrade at runtime:

WebSocket support on Node requires the @hono/node-ws package.
Install it with: npm install @hono/node-ws

Heartbeat behaviour differs slightly by runtime — only Bun exposes a protocol-level ws.ping(); see the note under Connection hardening.

All four options are optional and set on WebSocketPlugin:

app.usePlugin(new WebSocketPlugin({
heartbeatIntervalMs: 30_000, // default 30 s; 0 disables
heartbeatTimeoutMs: 60_000, // default 2 × heartbeatIntervalMs
idleTimeoutMs: 0, // default 0 (disabled)
maxMessageSizeBytes: 1_048_576, // default 1 MiB
}));
OptionBehaviour
heartbeatIntervalMsHow often the server pings. Closes the connection with code 1001 if no inbound traffic arrives within heartbeatTimeoutMs.
heartbeatTimeoutMsLiveness deadline. Defaults to twice the interval.
idleTimeoutMsHard idle cutoff (code 1001) regardless of heartbeat replies. Set it larger than heartbeatIntervalMs if both are on, since pong replies count as activity.
maxMessageSizeBytesOversized inbound messages get an error frame and close code 1009 (Message Too Big).

Closed connections are removed from the connection registry and from every room they had joined.

Heartbeat protocol. On each tick the server sends a protocol-level ping where the runtime exposes one (Bun’s ws.ping()) plus a portable application-level {"type":"ping"} JSON frame. Liveness is measured by any inbound traffic, because protocol-level pong events are not observable from the manager — Bun delivers them to a pong handler on Bun.serve() that the adapter does not wire, and Deno’s upgraded sockets expose no ping() at all. Clients should reply with {"type":"pong"} or simply send any message within the timeout.

While the heartbeat is enabled, inbound {"type":"ping"} and {"type":"pong"} frames are reserved control messages and are not forwarded to your @OnMessage handlers.

APIPurpose
idStable connection id
send(data)Send JSON-serializable data to this client
broadcast(data, room?)Send to every connection in a room, or all connections if room is omitted
join(room) / leave(room)Room membership
close(code?, reason?)Close the socket
nativeUnderlying WebSocket instance

Optional Zod schema on @OnMessage(Schema) validates each incoming JSON payload. On failure the manager sends an error object to the client without tearing down the socket.

Since 3.0.0 only parse and schema failures — which describe the client’s own input — are reported back. If your handler itself throws (a failing query, a bug), the client receives a generic Internal server error and the real error is logged server-side, so driver messages and stack detail are no longer leaked over the socket.

The examples/chat-api project (Bun) shows REST + JWT + SQLite alongside WebSocket flow, including notes on 426/501 when upgrade is not available (e.g. plain fetch tests).