The client#

actor(Def, key) is the same expression everywhere. On the server it dispatches in-process; in a browser it goes through a transport. @sigx/actors/client is where you configure the second one.

The default#

Inside a SignalX app, install the plugin and you are done:

TypeScript
import { actorsPlugin } from '@sigx/actors/app';

app.use(actorsPlugin());

The build stamps each actor's endpoint onto its client ref, so an ordinary same-origin app needs no configuration at all.

Pointing somewhere else#

TypeScript
import { configureActors } from '@sigx/actors/client';

configureActors({
    endpoint: 'https://api.example.com/_sigx/actor',
    headers: () => ({ authorization: token() }),
});

This is sugar for fetchTransport(config). headers may be a function, so it is re-read per call — which is what you want for a token that rotates.

Because init.endpoint carries the endpoint the build baked in, configureActors({ headers }) overrides headers alone without restating where the server is.

configureActors is independent of configureServerFn, so a native or remote client can point actors at one origin and server functions at another.

Supplying a whole transport#

TypeScript
configureActors({
    name: 'batching',
    call: (symbol, args, init) => /* … */,
    stream: (symbol, args, init) => /* … */,
    live: () => /* optional push channel */,
    close: () => /* optional */,
});

call and stream are required; live and close are optional. Omit live and live reads are driven over your stream() instead.

close() is idempotent by contract. A live channel resolved from a transport's own live() is released through the transport that produced it, so either owner — the plugin or the channel — may release first. (ActorLiveChannel deliberately has no close() of its own.)

The socket transport#

@sigx/actors-ws is the ready-made one: every call and every live subscription on the page over a single connection.

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

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

It implements live(), so subscriptions change incrementally instead of reopening. Note that it is host-affine — a per-call endpoint is ignored, because every call re-dispatches through placement anyway — and that in-flight calls fail un-retried when a socket drops, while subscriptions re-establish.

fetchTransport() stays the default; reach for the socket when the connection shape is the problem, not when you want a faster call.

Routing#

ActorTransport decides how a call travels; ActorRouter decides where it goes. They compose:

TypeScript
import {
    configureActors, fetchTransport, learningRouter, routedTransport,
} from '@sigx/actors/client';

configureActors(routedTransport(fetchTransport({ endpoint }), learningRouter()));
RouterHow it decidesGood for
(none — the default)always the configured endpointone origin, browsers
learningRouter()caches what redirects teach itany caller that can reach hosts directly
staticRouter(map)a fixed key → host mappingtenancy or sharding you already own
chainRouters(a, b)first answer winsa static override in front of learning

A learned endpoint is dropped as soon as it proves wrong — a connection failure or a 5xx — and the caller falls back to the configured endpoint.

learningRouter() is not a browser feature. It pays off for a service that can reach hosts directly. A browser talking to one public origin should stay on the default and get its locality from the routing token instead.

Refs are inspectable#

A ref's get trap synthesizes a dispatcher for any unknown property — that is what lets methods need no registration. A handful of names are answered locally instead, so inspecting a ref never issues a call:

TypeScript
const ref = actor(CartActor, 'user-42');

String(ref);            // '[actor Cart#user-42]'
JSON.stringify(ref);    // undefined — no call
ref.constructor;        // undefined — no call

toString reads [actor Type#key], so refs interpolate usefully into logs. Every other Object.prototype name — plus toJSON and Node's legacy inspect — reads undefined, exactly like symbols and then. This holds for every proxy: actor() in the browser and on the server, host.actor(…) and ctx.actor(…).

The cost is one naming rule: an actor method named after an Object.prototype member (toString, valueOf, hasOwnProperty, toJSON, …) is not reachable through a proxy. Pick another name.

The request-context bag#

Server-side callers can attach a small string-only bag of app data to a call — a request id, a tenant hint, a feature flag — without adding an argument:

TypeScript
await actor(RoomActor, id).with({ bag: { tenant: 'acme' } }).post(text);

The method reads it as ctx.bag. Caps, propagation rules and the deliberately silent drop-whole posture for a malformed bag arriving en route are covered under the request-context bag. Identity does not belong here — use ctx.principal.

Per-call options#

TypeScript
actor(Cart, id).with({ get: false }).summary();      // force POST on a declared read
actor(Notifier, id).with({ oneWay: true }).ping();   // resolve at acceptance
actor(Cart, id).with({ signal }).checkout();         // abort

with() also accepts headers, endpoint and route for one call.

Calling from outside a SignalX app#

Nothing about the wire requires a SignalX client. It is the serverFn protocol — a POST with a JSON body — so a script, a mobile app or another service can call an actor with fetch and no SDK.

@sigx/actors/client is worth using anyway when you want the typed proxy, since it is dependency-free and small: 5 kB budgeted for the whole entry, and 2.3 kB for a bundle that pulls in no router.

Next steps#