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:
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
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
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.
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:
import {
configureActors, fetchTransport, learningRouter, routedTransport,
} from '@sigx/actors/client';
configureActors(routedTransport(fetchTransport({ endpoint }), learningRouter()));
| Router | How it decides | Good for |
|---|---|---|
| (none — the default) | always the configured endpoint | one origin, browsers |
learningRouter() | caches what redirects teach it | any caller that can reach hosts directly |
staticRouter(map) | a fixed key → host mapping | tenancy or sharding you already own |
chainRouters(a, b) | first answer wins | a 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:
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:
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
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
- Wire protocol — the contract a transport implements.
- Locality routing — redirects,
follow, and the token. - Reads & writes in components — the ergonomic layer above this.
