WebSockets Guide
WebSockets Guide
Section titled “WebSockets Guide”Build real-time features with the official WebSocket plugin and decorators. This guide matches current veloce-ts releases (see Changelog).
Overview
Section titled “Overview”@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, plusnativeWebSocket 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);Registration notes
Section titled “Registration notes”- Import
WebSocketPluginfromveloce-ts(also available underveloce-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 areinclude()d so metadata is compiled.
Runtime support
Section titled “Runtime support”| Runtime | Upgrade path | Extra install |
|---|---|---|
| Bun | Bun.serve()’s native upgrade() | — |
| Deno | Deno.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(). Handingapp.fetchto a server you created yourself leaves the plugin without a server to attach to, and upgrades will not complete. - Node needs
@hono/node-servertoo, whichlisten()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-wsHeartbeat behaviour differs slightly by runtime — only Bun exposes a protocol-level ws.ping(); see the note under Connection hardening.
Connection hardening
Section titled “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}));| Option | Behaviour |
|---|---|
heartbeatIntervalMs | How often the server pings. Closes the connection with code 1001 if no inbound traffic arrives within heartbeatTimeoutMs. |
heartbeatTimeoutMs | Liveness deadline. Defaults to twice the interval. |
idleTimeoutMs | Hard idle cutoff (code 1001) regardless of heartbeat replies. Set it larger than heartbeatIntervalMs if both are on, since pong replies count as activity. |
maxMessageSizeBytes | Oversized 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.
WebSocketConnection
Section titled “WebSocketConnection”| API | Purpose |
|---|---|
id | Stable 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 |
native | Underlying WebSocket instance |
Message validation
Section titled “Message validation”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.
Examples in the repo
Section titled “Examples in the repo”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).
Related
Section titled “Related”- Limitations & roadmap
- Plugins
- GraphQL — subscriptions are experimental; validate before production.