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 type with an empty, . or .. path segmentnew URL() would resolve it away and quietly retarget the route.
  • A method containing /Type#a/b would encode to Type/a%2Fb, and the reading half decodes each segment before it splits on the last /, so the symbol would come back as Type/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 scalarnull, 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:

TypeScript
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 @:

SymbolOn the wireWhat it is
$live#subscribe{base}/$live/subscribethe 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:

TypeScript
// 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:

TypeScript
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#

limitvalue
entries8
key length64 B
value length256 B
total1 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:

TypeScript
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.handle returns a Response, 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.

TypeScript
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#