Server WebSocket
Este conteúdo não está disponível em sua língua ainda.
Server-side WebSocket is part of the HTTP route DSL. The
websocket() directive upgrades matching requests and wires every
connection to a single WebsocketServerActor — the hub — which
you implement. For outbound client connections, see
WebsocketClientActor.
import { ActorSystem,} from 'actor-ts';import { HttpExtensionId, WebsocketServerActor, websocket, type WebsocketConnection,} from 'actor-ts/http';import { match } from 'ts-pattern';
type SetNameMessage = { kind: 'setName'; name: string };type SayMessage = { kind: 'say'; text: string };type ClientMessage = SetNameMessage | SayMessage;type ServerMessage = { kind: 'system'; text: string } | { kind: 'chat'; from: string; text: string };
class ChatRoom extends WebsocketServerActor<ServerMessage, ClientMessage> { private readonly names = new Map<string, string>();
onMessage(message: ClientMessage): void { match(message) .with({ kind: 'setName' }, (m) => this.onSetName(m)) .with({ kind: 'say' }, (m) => this.onSay(m)) .exhaustive(); }
private onSetName(m: SetNameMessage): void { this.names.set(this.connection.id, m.name); this.reply({ kind: 'system', text: `hi ${m.name}` }); }
private onSay(m: SayMessage): void { this.broadcast({ kind: 'chat', from: this.names.get(this.connection.id) ?? 'anon', text: m.text }); }
override onClientDisconnected(c: WebsocketConnection<ServerMessage>): void { this.names.delete(c.id); }}
const system = ActorSystem.create('chat');const chat = system.spawn(ChatRoom, 'chat');
await system.extension(HttpExtensionId) .newServerAt('0.0.0.0', 8080) .bind(websocket('/ws', chat));The hub’s type parameters read from the server’s point of
view: TOut = the messages the server sends, TIn = the decoded
messages it receives.
The websocket() directive
Section titled “The websocket() directive”websocket() produces a Route, so it composes with the rest of
the route DSL — path(), concat(), and
withMiddleware(). Two forms:
import { websocket, path, concat } from 'actor-ts/http';
// 1. Bare directive — mount it under a path yourself:path('ws', websocket(chat));
// 2. Path sugar — equivalent to path(p, websocket(target)):websocket('/ws', chat);
// Mixed with normal HTTP routes on the same server:concat( path('api', apiRoutes), websocket('/ws', chat),);Options
Section titled “Options”type WebsocketRouteOptions<TOut, TIn> = { codec?: WebsocketCodec<TOut, TIn>; // default jsonCodec() maxFrameBytes?: number; // default 1 MiB onOversizeFrame?: 'close' | 'drop'; // default 'close' (1009) onInvalidMessage?: 'close' | 'drop' | 'hook'; // default 'close' (1003) maxBufferedBytes?: number; // default 4 MiB onBackpressure?: 'drop' | 'close'; // default 'drop' allowedOrigins?: string[]; // CSWSH defence — see below maxConnections?: number; // concurrent-connection cap; default unlimited maxPreAttachFrames?: number; // setup-window frame cap; default 256 maxPreAttachBytes?: number; // setup-window byte cap; default 4 MiB acceptTimeoutMs?: number; // setup deadline; default 10 000, Infinity to disable};const webSocketRouteOptions = WebsocketRouteOptions.create() .withMaxFrameBytes(256 * 1024) .withOnOversizeFrame('close') // close with 1009 Message Too Big .withOnInvalidMessage('close');websocket('/ws', chat, webSocketRouteOptions); // close with 1003 Unsupported DataThe frame-size cap is enforced on the raw frame before decode.
onInvalidMessage: 'hook' routes decode failures to the actor’s
onInvalidMessage hook instead of closing.
Where the cap is enforced
Section titled “Where the cap is enforced”maxFrameBytes is checked twice, at two different moments:
- In the transport, at your resolved
maxFrameBytes— handed to the runtime’s own socket when the server binds. A frame over it is refused while it arrives, so a hostile peer cannot make the process buffer a 16 MiB (Bun) or 100 MiB (ws) frame just to have it thrown away afterwards. Because the runtime hangs up rather than sending a policy close, the client sees an abnormal close (1006) here, not 1009. Two runtime/backend pairs have no such layer — see the caution below. - In the connection actor, at the same
maxFrameBytes— on the fully received frame, before the codec decodes it, closing with 1009.
Only the second layer is a guarantee. It runs everywhere and it is what keeps an oversize frame away from your actor; the transport cap is an optimisation on top of it that spares the process the buffering.
The number is yours in both directions: lowering maxFrameBytes narrows
the buffering window too, and raising it above the 1 MiB default really
does let frames that large through. It is resolved the usual way — route
options, then actor-ts.http.websocket.maxFrameBytes, then the built-in
default — and the resolution happens when the server binds, so a
malformed value is an error at bind() rather than on the first upgrade.
The transport cap is per server, not per route. A server has a single
transport shared by all its WebSocket routes (one WebSocketServer on
Express, one plugin registration on Fastify, one Bun.serve), so where
several routes disagree the transport takes the widest of their caps.
A stricter route is unaffected in what it accepts — the connection actor
still refuses its oversize frames with 1009 — it just does not get the
narrower buffering window it would have had on its own.
That is a decision rather than a gap. Fastify and Bun.serve can hold
only one limit at a time; Express structurally could hold one per route,
and taking it up there would make the same configuration mean different
things on different backends — for a limit whose whole job is bounding
what a hostile peer can allocate, one shape everywhere beats a narrower
window on one backend of three.
The connection setup window
Section titled “The connection setup window”Between the handshake completing and the connection’s actor attaching its listeners there is a short window — two mailbox hops — in which the socket is live and nothing is reading it. Frames that arrive there are held and replayed the moment the actor attaches, so nothing is lost. Two knobs bound what that window may cost, and both exist because the window’s exit is not guaranteed: the actor is spawned by the hub, and a hub that has stopped, stalled or been reconfigured may never produce one.
maxPreAttachFrames/maxPreAttachBytesbound what is held. Past either the socket is closed with 1013 (“try again later”) rather than buffered further. A legitimate client sends nothing or a greeting into this window, so the defaults — 256 frames, 4 MiB — are orders of magnitude above ordinary traffic; what they refuse is a peer that streams into a connection nobody has accepted yet. The first frame is always admitted whatever its size (it is already bounded bymaxFrameBytes), so a route that raisesmaxFrameBytesabove 4 MiB only needs to raisemaxPreAttachBytesif its clients send several such frames at once.acceptTimeoutMsbounds how long the window may stay open. If no connection actor has attached within it, the socket is closed with 1013 and itsmaxConnectionsslot is released. Ten seconds by default: a healthy hub attaches in microseconds, so this is a fallback, not a liveness policy — and an actor that turns up after the deadline is handed the close rather than a socket the framework already killed. SetInfinityto switch it off.
Neither is reachable by accident. Both answer the same failure: an upgraded socket with no actor behind it, which before they existed stayed open for the process lifetime, accumulated frames nothing would ever drain, and held an admission slot nothing would ever return.
Origin allowlist (CSWSH defence)
Section titled “Origin allowlist (CSWSH defence)”Browsers attach the user’s cookies to a WebSocket upgrade automatically, so a
WS route whose auth is ambient (a session cookie, or IpAllowlist) is open
to Cross-Site WebSocket Hijacking: any web page can open
new WebSocket('wss://your-host/ws') and ride the victim’s credentials.
Set allowedOrigins to gate the handshake by the browser Origin header:
const wsOptions = WebsocketRouteOptions.create() .withAllowedOrigins(['https://app.example.com']);websocket('/ws', chat, wsOptions);An upgrade whose Origin is present but not listed is rejected with 403
before the handshake, on all three backends. A missing Origin
(non-browser client — native WebSocket, server-to-server) is allowed, since
CSWSH is a browser-only attack. Comparison is case-insensitive.
BearerTokenAuth is already resistant (browsers can’t set Authorization on a
WS handshake), so allowedOrigins matters most for cookie- or IP-based auth.
Middleware runs at upgrade time
Section titled “Middleware runs at upgrade time”withMiddleware() composes with websocket(), but the middleware
runs once, at upgrade time, against the HTTP upgrade request —
not per WebSocket message. A rejecting middleware returns a normal
HTTP error and the upgrade never completes, so auth middleware
like BearerTokenAuth or IpAllowlist gates the handshake:
import { websocket, withMiddleware } from 'actor-ts/http';
withMiddleware(BearerTokenAuth({ /* ... */ }), websocket('/ws', chat));// A bad token → HTTP 401, no upgrade. A good token → the socket opens.See the HTTP overview for the middleware set.
Response-decorating middleware works too
Section titled “Response-decorating middleware works too”A middleware that calls next() and returns a decorated copy of the
result — securityHeaders(), contentSecurityPolicy(),
strictTransportSecurity(), requestId(), csrfProtection() — composes
over a websocket() route as well, so one wrap can harden a whole tree
without carving the socket out of it:
import { requestId, securityHeaders, websocket, withMiddleware } from 'actor-ts/http';
withMiddleware(securityHeaders(), withMiddleware(requestId(), websocket('/ws', chat)));// Both run at upgrade time. The socket opens.The headers such a middleware adds do not reach a successful
handshake — the backend writes that 101 response itself, and a WebSocket
connection is not a document a browser would apply X-Frame-Options or a
CSP to. They do ride on a rejection: a 401 from an inner
BearerTokenAuth comes back with the security headers on it, which is the
case that matters, since that response really is rendered by a browser.
Backend-native middleware runs too
Section titled “Backend-native middleware runs too”withMiddleware() is the portable path — it works the same on every
backend — but a handshake is also a normal request to the underlying
framework, and that framework’s own middleware runs as well:
| Backend | What also runs at the handshake |
|---|---|
| Fastify | route hooks, including preValidation |
| Express | everything registered with app.use(...) |
| Hono | everything registered with app.use(...) |
So an app that gates /ws with app.use(requireLogin) really is
gated, and a native middleware that answers the request refuses the
upgrade. The framework’s own guard runs after all of them, so
withMiddleware() and allowedOrigins always have the last word.
Prefer withMiddleware() when a route should behave identically
across backends; reach for native middleware when you are reusing an
existing ecosystem (sessions, rate limiting, observability).
WebsocketServerActor
Section titled “WebsocketServerActor”One actor per route — the hub. It sees every connection’s events, serialized:
abstract class WebsocketServerActor<TOut, TIn, TSelf = never> { // You implement: abstract onMessage(message: TIn): void | Promise<void>;
// Optional overrides: protected onClientConnected(client: WebsocketConnection<TOut>): void; protected onClientDisconnected(client: WebsocketConnection<TOut>, info: WebsocketCloseInfo): void; protected onInvalidMessage(client: WebsocketConnection<TOut>, error: WebsocketDecodeError): void; protected onSelfMessage(message: TSelf): void;}Helpers
Section titled “Helpers”Inside onMessage and the hooks, these are available:
| Member | What it does |
|---|---|
this.connection | The WebsocketConnection<TOut> whose event is being processed. |
this.reply(message) | Send message to the current connection. |
this.broadcast(message, filter?) | Send to every connection (optionally filtered by predicate). |
this.clients | ReadonlyMap<string, WebsocketConnection<TOut>> of all live connections. |
this.closeAll(code?, reason?) | Close every connection. |
onMessage(message: ClientMessage): void { this.reply({ kind: 'system', text: 'got it' }); // → the sender this.broadcast({ kind: 'chat', from: 'x', text: 'hi' }); // → everyone this.broadcast(notice, (c) => c.id !== this.connection.id); // → everyone else}Per-connection ordering
Section titled “Per-connection ordering”Every event for a given connection is serialized through the one hub actor in this order:
onClientConnected → onMessage* (in frame order) → onClientDisconnectedonClientConnected runs once, then zero or more onMessage
calls in frame order, then exactly one onClientDisconnected.
Because it all runs on a single actor, you get the actor model’s
usual guarantee — no concurrent handler execution, no locks.
Bounding the hub’s mailbox
Section titled “Bounding the hub’s mailbox”One hub carries every inbound frame from every connection on the route, which makes it the textbook case for bounding a mailbox: an actor exposed to a producer you do not control. It is also where the framework’s own connection traffic rides. Two things follow.
The command that spawns a connection’s actor cannot be shed. It
travels the same lane a death-watch Terminated takes — queued at the
tail like anything else, but exempt from every overflow policy, so
drop-head, drop-new, reject and a PriorityMailbox of your own
all leave it alone. This matters because that command is sent exactly
once, from the backend’s upgrade callback, and it carries the only
reference to a socket whose handshake has already completed. Losing it
would leave that socket upgraded with nothing attached to it: no
listeners, inbound frames piling up unread, and its maxConnections
slot held until the client gave up. A hub that cannot answer for a
different reason — it has stopped, or it never gets to the command — is
caught by the connection setup window
instead, which closes the socket and gives the slot back.
What a bound does still shed is the rest. Inbound frames, and the
onClientConnected / onClientDisconnected signals the connection
actors report — so a bounded hub under load can hand you a client that
never appears in this.clients, or one that never leaves it. Both are
your protocol, and only you know whether losing one is acceptable.
WebsocketConnection
Section titled “WebsocketConnection”Each connection is a WebsocketConnection<TOut>, which extends
ActorRef<TOut>:
interface WebsocketConnection<TOut> extends ActorRef<TOut> { readonly id: string; readonly remoteAddress?: string; readonly upgrade: WebsocketUpgradeInfo; readonly isOpen: boolean; tell(message: TOut): void; // encode via codec + send sendRaw(frame: WebsocketFrame): void; // bypass the codec close(code?: number, reason?: string): void;}upgrade carries the handshake context:
type WebsocketUpgradeInfo = { path: string; params: Record<string, string>; query: Record<string, string | string[] | undefined>; headers: Record<string, string>; remoteAddress?: string; subprotocol?: string;};WebsocketCloseInfo (passed to onClientDisconnected) is
{ code: number; reason: string; initiatedBy: 'client' | 'server' | 'error' }.
Codecs
Section titled “Codecs”The route codec decodes inbound frames into TIn and encodes
TOut replies. Default is jsonCodec():
import { WebsocketRouteOptions, jsonCodec } from 'actor-ts/http';
const webSocketRouteOptions = WebsocketRouteOptions.create().withCodec(jsonCodec<ServerMessage, ClientMessage>({ validate: (v: unknown): ClientMessage => ClientMsgSchema.parse(v), }));websocket('/ws', chat, webSocketRouteOptions);For binary protocols, rawCodec() gives you raw frames
(TOut = TIn = WebsocketFrame):
import { match } from 'ts-pattern';import { rawCodec, WebsocketRouteOptions, WebsocketServerActor, type WebsocketFrame,} from 'actor-ts/http';
const webSocketRouteOptions = WebsocketRouteOptions.create().withCodec(rawCodec());class BinaryHub extends WebsocketServerActor<WebsocketFrame, WebsocketFrame> { onMessage(frame: WebsocketFrame): void { match(frame) .with({ kind: 'binary' }, (f) => this.onBinary(f)) .otherwise(() => {}); }
private onBinary(frame: BinaryFrame): void { this.reply({ kind: 'binary', data: process(frame.data) }); }}
websocket('/stream', binaryHub, webSocketRouteOptions);Decode failures throw WebsocketDecodeError and follow the route’s
onInvalidMessage policy.
Backends and runtimes
Section titled “Backends and runtimes”websocket() works on all three HTTP backends:
| Backend | Peer dependency | Runtime notes |
|---|---|---|
| Fastify (default) | @fastify/websocket | Runs on Bun and Node; on Deno prefer Hono. |
| Express | ws | — |
| Hono | Bun & Deno: built into hono. Hono-on-Node: @hono/node-ws. | Per-runtime helpers. |
npm install @fastify/websocket # Fastify (default)npm install ws # Expressnpm install @hono/node-ws # Hono on Node only, 1.2.0 or newerHOCON config
Section titled “HOCON config”Route defaults live under actor-ts.http.websocket:
actor-ts.http.websocket { maxFrameBytes = 1048576 onOversizeFrame = close onInvalidMessage = close maxBufferedBytes = 4194304 onBackpressure = drop maxPreAttachFrames = 256 maxPreAttachBytes = 4M acceptTimeoutMs = 10s}Precedence: route options > HOCON > built-in defaults.
When to use it
Section titled “When to use it”Three good fits:
- Real-time UIs — pushing updates to browser clients.
- Custom messaging protocols — game servers, chat backends.
- Binary streaming — use
rawCodec()and handle frames directly.
For one-way streams from server to client, SSE is simpler. For request/reply RPC, plain HTTP is the norm.
Where to next
Section titled “Where to next”- I/O overview — the bigger picture.
- WebsocketClientActor — the outbound client half.
- Route DSL —
path,concat, and howwebsocket()composes. - SSE — server-sent events, the one-way alternative.
- HTTP overview — backends, middleware, and server binding.
