Перейти к содержимому
Русский

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, HttpExtensionId,
WebsocketServerActor, websocket, type WebsocketConnection,
} from 'actor-ts';
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 DSLpath(), concat(), and withMiddleware(). Two forms:

import { websocket, path, concat } from 'actor-ts';
// 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
};
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.

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

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.

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 { jsonCodec, WebsocketRouteOptions } from 'actor-ts';
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';
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

Route defaults live under actor-ts.http.websocket:

actor-ts.http.websocket {
maxFrameBytes = 1048576
onOversizeFrame = close
onInvalidMessage = close
maxBufferedBytes = 4194304
onBackpressure = drop
}

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.