Installation
One package, two halves: a transport in the browser and an upgrade adapter on the server. The adapter is Node-only; every other runtime already has what it needs.
pnpm add @sigx/actors-wsws is an optional peer (>= 8), imported lazily and only by the Node adapter — and only
once a server actually sees an upgrade. Browsers, Bun, Deno and Workers never pull it in.
The client
import { actorsPlugin } from '@sigx/actors/app';
import { socketTransport } from '@sigx/actors-ws/client';
app.use(actorsPlugin({ transport: socketTransport({ url: 'wss://example.com/_sigx/socket' }) }));
| Option | Default | Meaning |
|---|---|---|
connect(handlers) | — | build one connection attempt; the primary seam |
url | — | sugar over connect, dialling the global WebSocket |
retryMs | 300 | backoff floor between failed attempts |
maxRetryMs | 10_000 | backoff ceiling |
Nothing is dialled until the first call, so a transport configured at module scope costs nothing on pages that never touch an actor.
The Node adapter
import { attachActorSocket } from '@sigx/actors-ws/node';
const detach = attachActorSocket(server, { host, origin: 'same-origin' });
It registers an upgrade listener and returns a detach function; sockets an already
detached listener accepted keep running until they close on their own.
| Option | Default | Meaning |
|---|---|---|
path | /_sigx/socket | upgrade path, matched exactly — never a prefix |
wss | — | bring your own new WebSocketServer({ noServer: true }); without one, ws is imported dynamically |
Everything else is forwarded to the session — see
Socket sessions for origin, maxMessageBytes,
maxConcurrent, maxSubscriptions, pingMs, revalidateMs, maxConnectionMs,
throttlePolicy and stats. The adapter supplies the session's bufferedBytes() seam itself,
from the socket's bufferedAmount, so socketStats()
reports the host's own send-buffer depth with nothing to configure.
Two details worth knowing:
- An unmatched upgrade is left alone unless nothing else is listening for one, in which case the socket is destroyed rather than hanging unanswered forever.
- Messages arriving before the session finishes constructing are buffered, not dropped.
wsbegins reading the moment the upgrade completes, which is earlier than the session's own async setup can finish.
Interop
The adapter is a convenience, not a requirement. createActorSocketSession takes a Request
and a pair of callbacks, so any server that can hand you those can drive it.
An existing ws server
Construct the session in your own upgrade handler, and use toRequest() to build the
WinterCG Request a Node upgrade does not come with:
import { createActorSocketSession } from '@sigx/actors/server';
import { toRequest } from '@sigx/actors-ws/node';
wss.on('connection', async (socket, req) => {
const session = await createActorSocketSession({
host,
request: toRequest(req),
send: (message) => socket.send(message),
close: (code, reason) => socket.close(code, reason),
});
socket.on('message', (data) => session.handle(String(data)));
socket.on('close', () => session.close());
});
Cookies and Origin carry over verbatim, which is exactly what the session's upgrade-time
authentication and origin check need.
socket.io
About ten lines per side. The protocol is text, so carry it as an event payload:
// server
io.on('connection', async (socket) => {
const session = await createActorSocketSession({
host,
request: toRequest(socket.request),
send: (message) => socket.emit('sigx', message),
close: () => socket.disconnect(true),
});
socket.on('sigx', (message: string) => session.handle(message));
socket.on('disconnect', () => session.close());
});
// client
socketTransport({
connect: (handlers) => {
io.on('connect', handlers.onOpen);
io.on('sigx', handlers.onMessage);
io.on('disconnect', handlers.onClose);
return { send: (m) => io.emit('sigx', m), close: () => io.disconnect() };
},
});
This is a recipe rather than a package — there is nothing to abstract.
uWebSockets.js
Build the Request synchronously inside upgrade().
uWS reuses its req object and it is dead the moment the handler returns, so
reading headers from it later yields garbage. Read what you need, construct the
Request, then go async.
Bun and Deno
No adapter needed. Both hand you a real Request at upgrade time and a real WebSocket
afterwards, so createActorSocketSession wires up directly.
Security
The origin check happens at the upgrade, and it is the one check that cannot be deferred
to per-message: a browser will open a cross-origin WebSocket with cookies attached and no
preflight to stop it. The default is posture.origin ?? 'same-origin'.
Identity is pinned once, at the upgrade, from the cookies the browser sent. For an app with
sign-out or short-lived sessions, set revalidateMs or maxConnectionMs — see
session lifetime.
TLS is your terminator's job; use wss:// in production.
Next steps
- Socket sessions — the server half in full.
- API — the exported surface.
- Observability —
socketStats()and the ops section.
