WebSockets
A WebSocket is an Elysia .ws() route in your actions app. The Rust server does the protocol work (the handshake, the frames, ping and pong, the limits, the topics, the drain on shutdown); Bun runs only your handlers.
There is no second API to learn: .ws() is Elysia’s, the handlers get Elysia’s ws, and the route sits under the same [actions] prefix as your other actions. WebSockets run on the Bun target (brust start and brust dev). See What is not supported for the rest.
A first socket
// actions.ts
import { brust } from '@brust/core/elysia'
import { Elysia } from 'elysia'
export default new Elysia({ adapter: brust() })
.ws('/echo', {
open(ws) {
ws.send('hello')
},
message(ws, message) {
ws.send(message)
},
close(ws, code, reason) {
console.log('closed', code, reason)
},
})// in the browser, with [actions] prefix = "/api" and no base path
const socket = new WebSocket(`${location.origin.replace('http', 'ws')}/api/echo`)
socket.addEventListener('message', (e) => console.log(e.data))
socket.addEventListener('open', () => socket.send('ping'))- The URL is the actions prefix plus the route path. When you set
[server] base_path, an explicitprefixmust lie under it: withbase_path = "/app"andprefix = "/app/api"the socket is/app/api/echo. - Only a
GETwithUpgrade: websocketunder the actions prefix is a socket upgrade. Anywhere else the header is ignored and the request is the plain request it also is. - Text and binary messages both arrive in
messageand go out throughws.send; a binary message is aBuffer.
The upgrade request
Before a worker is called, Rust checks the handshake: GET, an Upgrade: websocket and a Connection: upgrade token, Sec-WebSocket-Version: 13 and a 16-byte base64 Sec-WebSocket-Key. A bad one is a 400; a wrong version is a 426 with Sec-WebSocket-Version: 13. Then the upgrade is an ordinary request to your app: derive, validation, beforeHandle, guards and cookies all run on it, and ws.data is the Elysia context they built.
new Elysia({ adapter: brust() })
.derive(({ cookie }) => ({ user: cookie.sid?.value ?? null }))
.guard({ beforeHandle: ({ user, status }) => (user ? undefined : status(401, 'sign in first')) })
.ws('/private', {
open(ws) {
ws.send(`hello ${ws.data.user}`)
},
})- A hook that returns a response refuses the upgrade with that response’s status. The browser sees a failed connection; a client that is not a browser sees the status (401 above).
- Cross-site. A socket upgrade is judged like a mutation under
[actions] cross_site: with"reject"(the default) anOriginfrom another site is a 403, and a request with noOrigin(a server-side client) is allowed. The[cors] originslist is honoured. Browsers always send anOriginwith a WebSocket, so a page of another site cannot open your socket with the visitor’s cookies. - Upgrades are refused while the server drains (503), past
max_connections(503), and withenabled = false(501).
The socket object
ws is Elysia’s ElysiaWS over a ServerWebSocket that brust provides: data, send (sendText, sendBinary), close, terminate, ping, pong, subscribe, unsubscribe, isSubscribed, subscriptions, publish (publishText, publishBinary), cork, remoteAddress, readyState and binaryType. The handlers are open, message, drain, close, ping and pong.
sendreturns a number, as in Bun: more than 0 is the bytes accepted; -1 means the message was accepted but the socket’s outbound queue is pastbackpressure_bytes(slow down); 0 means it was dropped, because the queue is over the limit (or the socket is closed). When the queue empties after a -1 or a 0,drainruns. WithcloseOnBackpressureLimit: truethe socket is closed with 1013 instead of dropping.- Events of one socket reach your handlers in arrival order, with at most 8 in flight: a slow
messagehandler stops the server reading that socket, and TCP back-pressure reaches the client. - Every handler runs in the store context captured at the upgrade, so a
defineStorestore or a request-scoped value reads as it did in the upgrade request.
Topics
const app = new Elysia({ adapter: brust() })
.ws('/room', {
open(ws) {
ws.subscribe('room')
},
message(ws, message) {
ws.publish('room', String(message)) // to every other subscriber, on any worker
},
})
// from an HTTP action: ctx.server
.post('/announce', ({ server, query }) => server?.publish('room', query.text))
// from module scope, for example a message-bus subscriber: app.server (set once the app is mounted)
bus.subscribe('room', (text) => app.server?.publish('room', text))
export default app- A topic is a name.
ws.subscribe(topic)joins it;ws.publish(topic, data)sends to every subscriber on any worker, except the publisher unless the route setspublishToSelf: true. server.publish(topic, data)publishes with no sender to leave out: every subscriber on every worker gets it. It works in an HTTP action (serverin the Elysia context) and from module scope (app.serveron the app you export). It returns the bytes queued, or 0 when nobody is subscribed.app.serveris set when brust mounts the app, so code that runs while the module is being imported finds it unset: useapp.server?.publishand publish later (a timer, a subscriber callback).- Your module is evaluated in every worker. A subscriber at module scope therefore runs once per worker, and a message it publishes reaches the sockets once per worker. Give the subscriber a queue group (NATS) or consumer group (Redis streams) so one worker receives each message.
- A message is queued once and shared by every subscriber’s queue. A socket leaves its topics when it closes.
Limits
# brust.toml (every key optional; these are the defaults)
[actions]
prefix = "/api"
[actions.ws]
enabled = true
max_payload_bytes = 1048576
idle_timeout_s = 120
ping_interval_s = 30
max_connections = 10000
backpressure_bytes = 1048576| Key | Default | Range | What it does |
|---|---|---|---|
enabled | true | boolean | false answers every upgrade 501. |
max_payload_bytes | 1048576 | 1 – 67108864 | The largest message (after reassembling fragments). A bigger one closes the socket with 1009. |
idle_timeout_s | 120 | 0 – 86400 | A socket that sends the server no frame for this long (a pong counts) is closed with 1001 "idle timeout". 0 turns it off. |
ping_interval_s | 30 | 0 – 86400 | The server pings this often, which keeps the idle timer fed by live clients. 0 turns it off. |
max_connections | 10000 | 1 – 1000000 | Open sockets per server process. The next upgrade is a 503. |
backpressure_bytes | 1048576 | 1 – 67108864 | The outbound queue of one socket, in bytes. See send above. |
These are the maxima. The Elysia options of your .ws() routes (maxPayloadLength, idleTimeout in seconds, backpressureLimit, closeOnBackpressureLimit, publishToSelf, sendPings) are sent with each upgrade and may lower a limit, never raise it. As in Bun there is one WebSocket configuration per app, so the options are app-wide. perMessageDeflate is ignored. A value out of range is a build error that names the key.
One socket, one worker
Your handlers are closures: they live in the memory of the Bun worker that ran the upgrade. So a socket stays on that worker for its whole life, and its events always run there. Workers are chosen by load: an upgrade goes to the worker with the fewest open sockets among those that can take it, so with --workers 2 two sockets land on two workers.
- Module-level state is per worker. Two sockets on different workers do not see the same
Map. To reach sockets of another worker, use a topic; to share data, use a database or a cache. - A worker that is busy with a long HTTP request does not slow a socket down: socket events do not use the render slots that pages and actions share.
More than one instance
Topics live inside one server process, and brust does not forward them to another process or machine. If you run several instances, carry messages between them yourself with a message bus (NATS, Redis pub/sub or the like), and have each instance publish what it receives into a topic with app.server.publish (see Topics). A client that reconnects lands on whichever instance the load balancer picks.
Close codes, drain and restart
| Code | When |
|---|---|
| 1000 | A normal close, from either side. |
| 1001 | The server is shutting down (SIGTERM or SIGINT), or the socket was idle past idle_timeout_s. |
| 1002 / 1007 | A protocol error / text that is not UTF-8. |
| 1009 | A message over max_payload_bytes. |
| 1012 | brust dev restarted the app (see below). |
| 1013 | The outbound queue passed its limit and the route has closeOnBackpressureLimit: true. |
| 1006 | Seen by the client when the connection dropped with no close frame. |
Drain. On SIGTERM or SIGINT the server stops accepting connections, sends every open socket a close 1001 "going away", and waits for the peer’s close frame or the rest of BRUST_DRAIN_TIMEOUT_MS (10 seconds by default), then drops what is left and exits. A socket counts as an open connection for the drain, so a deploy does not cut sockets without telling them. Write the client to reconnect on 1001 and 1006, with a delay and some jitter.
brust dev
The dev server is a proxy in front of your app, and it relays a socket upgrade under the actions prefix to the app, so the same URL works in development: the base path, the cookies and the Origin check are the ones above. A refusal by your app (a guard’s 401) reaches the browser as the HTTP status. When you save a file and the app restarts, open sockets are closed with 1012 "service restart"; the page reloads and connects again. The proxy answers 421 for a Host it does not know, as it does for the page.
What is not supported
- Cloudflare Workers. A Worker has no sockets of this kind.
brust build --target cloudflareprintswarning cf-websocketfor a source that calls.ws(, and the app fails to start withWebSocket (.ws()) is not supported on --target cloudflare; the build’s own startup check stops with that message.--target statichas no actions. - Per-message deflate (compression is not negotiated) and subprotocols (
Sec-WebSocket-Protocolis not answered, and the dev proxy does not forward it). - HTTP/2. Browsers open WebSockets over HTTP/1.1. The extended CONNECT of RFC 8441 is not supported: an upgrade on an HTTP/2 connection is a 400.
- Sockets in page routes and loaders. Only the actions app has
.ws(); an upgrade anywhere else is an ordinary request. - Server-sent events and endless streamed responses. A response body is read in full before it is sent (limited by
max_response_bytesand a 30 second deadline), so a stream that never ends is a 504. Use a WebSocket for pushing data to a page. - Elysia version.
.ws()relies on parts of Elysia 2 release candidates that are not public API; a test in brust fails when an update changes them.