Wire protocol
handleActorRequest is handleServerFnRequest with a host-backed resolver.
Everything you configured for server functions applies here unchanged.
POST {base}/r/{token}/{Type}/{method}
{"args": [key, ...args]}
→ {"data": …} | {"error": …} (NDJSON for streams)
Same origin policy, body caps, prototype-pollution guards, error masking and codec — because it is literally the same endpoint implementation. See Actors or server functions?.
The /r/{token}/ segment is an optional routing hint. Under
route: 'none', and for the reserved symbols below, the URL is plain
{base}/{Type}/{method} and everything else is unchanged.
configureActors() from @sigx/actors/client points remote and native clients at another
base, independently of configureServerFn.
The path
The separator between type and method is a real path separator, and encoding is
per segment — so a normal call spends no % at all:
Cart#addItem → /_sigx/actor/Cart/addItem
acme/greeter#greet → /_sigx/actor/acme/greeter/greet
$live#subscribe → /_sigx/actor/$live/subscribe
$watch:Counter#read → /_sigx/host/$watch:Counter/read
A type may itself contain / — acme/greeter is the packaged-actor convention — so the
reading half splits on the last separator. The last segment is always the method.
Three characters are restored after encoding: @, $ and :. The runtime's own reserved
vocabulary spends $ and : ($live, $watch:, $sigx:host), and all three are RFC 3986
pchar, so they are legal literal path characters. The other legal sub-delims — &, +,
,, ;, = — stay escaped deliberately; + especially, because a naive query-ish parser
decodes it as a space.
The routing token stays percent-encoded, and that asymmetry is deliberate. The symbol
spans several segments; the token is escaped so it stays exactly one, which is what keeps
{base}/r/{token}/{symbol} parseable from the left — the first segment after r/ is the
whole token, and everything after it is the whole symbol.
The in-memory symbol is the same string everywhere else. It is `${type}#${method}` everywhere that is not
a URL: it is what the per-call HMAC signs, what ServerFnInfo.symbol reports to a policy, and
what a frame transport like @sigx/actors-tcp carries
with no URL anywhere near it. Only the URL spelling differs.
Two names are refused, both because the alternative is a silent misroute rather than an error:
- A
typewith an empty,.or..path segment —new URL()would resolve it away and quietly retarget the route. - A
methodcontaining/—Type#a/bwould encode toType/a%2Fb, and the reading half decodes each segment before it splits on the last/, so the symbol would come back asType/a#b: a different actor, silently. This one throws in production as well as dev, since escaping it would not save the round trip.
The GET form
A method with a reads: declaration also answers:
GET {base}/r/{token}/{Type}/{method}?a0=p-9&a1=EUR
Same codec, same envelope, plus the Cache-Control the declaration asked for.
Arguments ride as named params when every one of them is a simple scalar — null, a
string, a boolean, or a finite number. Argument 0 is the actor key, so it is a0. A string
that would read back as a number, true, false or null, or that already starts with a
quote, is JSON-quoted (a0="42").
Anything richer — an object, a Date — falls back to the ?args=<JSON> blob, and does so
all-or-nothing, so one call never mixes the two encodings and the cache key stays a pure
function of the arguments.
This grammar is core's, character for character: the same declared read must decode
identically on /_sigx/fn and /_sigx/actor, so the actor endpoint delegates to
handleServerFnRequest and never re-derives it.
The callable surface
The callable surface is the method table's own keys. A name that is not an own, callable
key of methods: or streams: is a 404 method-not-found.
That means inherited Object.prototype members — toString, constructor, valueOf,
__proto__ — are not callable. It also means a methods: factory returning a class
instance does not work, because its methods live on a prototype:
methods: (ctx) => ({ async addItem(i) { /* … */ } }), // ✓ own keys
methods: (ctx) => new CartMethods(ctx), // ✗ prototype methods
Dev builds warn on the second form.
Shadowing a prototype name in the literal does not help either: async toString() is an own
key the server would dispatch, but the client proxy answers those names
locally and never issues the call. Pick another
name.
Reserved symbols
Two $-prefixed shapes exist, which is why defineActor refuses a type starting with $
or @:
| Symbol | On the wire | What it is |
|---|---|---|
$live#subscribe | {base}/$live/subscribe | the multiplexed live-read mount — the only one a browser talks to |
$watch:{Type}#{method} | /_sigx/host/$watch:{Type}/{method} | internal host-to-host watch forwarding, on the internal mount — not a public endpoint |
@ is reserved for data keys (['@actor', …] — see
Reads & writes in components).
The request-context bag
A small string-only key/value bag that rides the call envelope, for app data a method needs but should not take as an argument — a request id, a tenant hint, a feature flag.
Stamp it at the edge and read it in the method:
// in a middleware or policy, server-side
stampCallBag(rq, { tenant: 'acme', reqId });
// in an actor method
methods: (ctx) => ({
async post(text: string) {
log(ctx.bag.reqId, ctx.key, text);
},
}),
Server-side callers can also pass entries explicitly, and explicit wins on conflict:
await actor(RoomActor, id).with({ bag: { tenant: 'acme' } }).post(text);
ctx.bag is frozen and rebuilt per read, and it is empty outside a turn — that empty
value is exported from the root entry as EMPTY_CALL_BAG, so a caller comparing against it
does not have to mint its own.
ctx.actor and ctx.publish hops inherit it; detached task bodies and
volatile timer ticks deliberately read it empty, the same way traceparent does.
Caps, and what happens at the edges
| limit | value |
|---|---|
| entries | 8 |
| key length | 64 B |
| value length | 256 B |
| total | 1 KiB |
Exported as CALL_BAG_MAX_*. The posture differs by who made the mistake: your own input
throws, so you find it in development; a bag arriving en route that is malformed or over
cap is dropped whole and silently — never a 400.
Because an en-route bag drops whole, a missing entry must read as
unauthenticated, never as "allow". The bag is never populated from a request
header — client-settable identity would be an authorization bypass — and it is not where
identity belongs at all. Use ctx.principal
for that.
The envelope decode rebuilds the bag into a fresh null-prototype object. Between hosts its integrity is the envelope's perimeter posture: the cluster HMAC signs the call identity, not the body, so run mTLS or a private network between hosts.
Pluggable transports
The client proxy never speaks HTTP itself — it delegates to an ActorTransport, so batching,
a different auth scheme, or a protocol other than fetch drops in without any call site
changing:
import { configureActors, fetchTransport } from '@sigx/actors/client';
configureActors({ endpoint: '/actors', headers: () => ({ authorization: token() }) });
// …sugar for fetchTransport(config). Or supply the whole seam:
configureActors({
name: 'batching',
call: (symbol, args, init) => /* … */,
stream: (symbol, args, init) => /* … */,
live: () => /* optional push channel */,
});
fetchTransport() is the default and implements exactly the contract above. init.endpoint
carries the endpoint the build baked into the ref, so configureActors({ headers }) can
override headers alone without restating where the server is.
live() is the one optional member. Leave it out and @sigx/actors/app drives the
$live mount over your stream() instead — which is how the default transport gets live
reads without carrying a line of push logic in ./client, whose bytes ride every bundle that
touches an actor. Implement it, as a WebSocket transport would, and the app uses yours.
Server-side, a transport is a plugin. PluginRegistry.route() lets one contribute its own
mount, which createAppHandler and createFetchHandler serve in dev and prod alike.
One current limit:
ActorRoute.handlereturns aResponse, which cannot express a Node WebSocket upgrade — that needs the raw socket. This applies to the client-facing transport. A host-to-host transport is not bound by it, because it may bring its own listener instead of a route.
The socket wire
@sigx/actors/socket-wire is the protocol a socket speaks, published as its own subpath so an
adapter written outside this repo reaches the one true wire instead of copying it — the same
standing ./cluster/frames has for host transports.
import { encodeWire, parseWire, reviveWire, wireFail } from '@sigx/actors/socket-wire';
import type { SocketReply, SocketRequest } from '@sigx/actors/socket-wire';
Replies are {i,v} for a value and {i,e} for an error, and those are the $live
LiveFrame shapes by construction, not a parallel spelling of them. Calls and subscriptions
share one id namespace, which is why a reply carries only i and needs no kind tag.
parseWire is pollution-safe and wireFail is branded, so a malformed message is a typed
failure rather than something that has to be guessed at.
Two things live here rather than in either channel, so the two cannot fork: identity
coalescing with late-subscriber replay and fingerprint() re-seed suppression, and
createSocketLiveChannel.
What it deliberately is not: there is no principal field, no envelope and no inbound-call direction. That is the browser trust model expressed as a shape — a client may ask, and may subscribe, and may do nothing else. The cluster frames are a different protocol for a different perimeter.
Next steps
- The client — configuring transports and routers.
- Locality routing — the token and what it is for.
- Cacheable reads — the GET form in depth.
