콘텐츠로 이동
한국어

Server WebSocket

이 콘텐츠는 아직 번역되지 않았습니다.

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.

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),
);
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 Data

The 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.

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.

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 / maxPreAttachBytes bound 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 by maxFrameBytes), so a route that raises maxFrameBytes above 4 MiB only needs to raise maxPreAttachBytes if its clients send several such frames at once.
  • acceptTimeoutMs bounds how long the window may stay open. If no connection actor has attached within it, the socket is closed with 1013 and its maxConnections slot 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. Set Infinity to 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.

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.

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.

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.

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:

BackendWhat also runs at the handshake
Fastifyroute hooks, including preValidation
Expresseverything registered with app.use(...)
Honoeverything 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).

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;
}

Inside onMessage and the hooks, these are available:

MemberWhat it does
this.connectionThe 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.clientsReadonlyMap<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
}

Every event for a given connection is serialized through the one hub actor in this order:

onClientConnected → onMessage* (in frame order) → onClientDisconnected

onClientConnected 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.

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.

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' }.

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.

websocket() works on all three HTTP backends:

BackendPeer dependencyRuntime notes
Fastify (default)@fastify/websocketRuns on Bun and Node; on Deno prefer Hono.
Expressws—
HonoBun & Deno: built into hono. Hono-on-Node: @hono/node-ws.Per-runtime helpers.
Terminal window
npm install @fastify/websocket # Fastify (default)
npm install ws # Express
npm install @hono/node-ws # Hono on Node only, 1.2.0 or newer

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.

Three good fits:

  1. Real-time UIs — pushing updates to browser clients.
  2. Custom messaging protocols — game servers, chat backends.
  3. 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.