Wire protocol#

handleActorRequest is handleServerFnRequest with a host-backed resolver. Everything you configured for server functions applies here unchanged.

POST {base}/r/{token}/{Type}%23{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}%23{method} and everything else is unchanged.

configureActors() from @sigx/actors/client points remote and native clients at another base, independently of configureServerFn.

The GET form#

A method with a reads: declaration also answers:

GET {base}/r/{token}/{Type}%23{method}?args=[key,...args]

Same codec, same envelope, plus the Cache-Control the declaration asked for.

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. Declaring a method that shadows a prototype name is fine — async toString() in the literal is an own key and is callable.

Reserved symbols#

Two $-prefixed shapes exist, which is why defineActor refuses a type starting with $ or @:

SymbolWhat it is
$live#subscribethe multiplexed live-read mount — the only one a browser talks to
$watch:{Type}#{method}internal host-to-host watch forwarding; not a public endpoint

@ is reserved for data keys (['@actor', …] — see Reads & writes in components).

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.

Next steps#