Actors/Packages/WebSocket
@sigx/actors-ws · Preview

WebSocket#

One socket from the browser to your host, carrying every call and every live subscription on the page.

v0.9.2 MIT

Installation#

Terminal
pnpm add @sigx/actors-ws

Client side, the transport is the whole integration:

TypeScript
import { actorsPlugin } from '@sigx/actors/app';
import { socketTransport } from '@sigx/actors-ws/client';

app.use(actorsPlugin({ transport: socketTransport({ url: 'wss://example.com/_sigx/socket' }) }));

Server side on Node, attach the upgrade listener:

TypeScript
import { createServer } from 'node:http';
import { createAppHandler } from '@sigx/actors/node';
import { attachActorSocket } from '@sigx/actors-ws/node';

const server = createServer(createAppHandler(app));

await app.start();                                  // the session needs a running host
attachActorSocket(server, { host: app.host! });

await new Promise<void>((r) => server.listen(7311, r));

useActorState and useActorAction need no changes — the plugin routes them over the socket for you.

What it buys#

Three things, and they are all about the shape of the connection rather than raw speed:

  • No cross-origin preflight. A WebSocket upgrade is not a CORS-preflighted request, so a browser talking to an actor host on another origin stops paying an OPTIONS round trip per call.
  • No held-open POST. Live reads over HTTP ride a long-lived response body, which proxies, mobile NATs and corporate middleboxes all treat with suspicion. A WebSocket is the thing that infrastructure expects to stay open.
  • Subscriptions change incrementally. Adding one is a ~40-byte {i,sub} message and removing one is {i,uns} — nothing reopens, so a page whose live set changes as components mount does not reconnect and re-seed every time.

Per-call latency and throughput are not the win. Against a warm same-origin HTTP/2 connection, a socket call measures about the same — and reliability is worse, because sockets drop and in-flight calls fail with them. fetchTransport() remains the default, and it is the right default.

What happens when it drops#

In-flight calls fail, and are never retried. Re-sending a call whose response was lost would mean re-running an actor method that may not be idempotent — a correctness bug wearing a reliability costume. They reject, and the caller decides.

Subscriptions re-establish on their own. They are declarative, so replaying them is safe. Each one re-seeds on reconnect, and fingerprint() suppresses the value if it has not changed — so a reconnect does not repaint the page.

Reconnection backs off from retryMs (default 300 ms) to maxRetryMs (default 10 s).

It is host-affine#

Every call re-dispatches through placement anyway, so a per-call endpoint makes no sense over a socket and is ignored, with a __DEV__ warning. If you need to reach a specific host, that is locality routing's job.

Not only WebSocket#

The primary seam is a link, not a URL:

TypeScript
socketTransport({
    connect: (handlers) => ({
        send: (message) => channel.emit('sigx', message),
        close: () => channel.disconnect(),
    }),
});

url is sugar that dials the global WebSocket. Supply connect instead and socket.io, a MessageChannel, a React Native bridge or anything else message-oriented can carry the same protocol — see Installation for worked recipes.

Next steps#