Remote Actors
Run an actor in another execution context: a subprocess, a WebSocket connection, or a worker. Drive it as you would a local spawn. The actor doesn't know it's remote, and neither does your code.
The idea
A remote actor is a server/client pair speaking a small protocol:
- Server holds the real actor —
setup, handlers, and its storage. - Client holds a proxy. You
spawn/send/waiton it as usual, and it bridges messages and state across the wire.
import { serveRemoteActor } from "posipaki/remote"; // server
import { remoteClient } from "posipaki/remote"; // client
serveRemoteActor(actor, spawner) serves one actor over a channel. remoteClient(name, spawner) returns a full ActorDefinition whose spawn connects and mirrors the
remote. A spawner is a transport factory: it obtains the channel and does
the version handshake, so the seam itself never deals with a specific
transport.
Subprocess — one file, both ends
The flagship use: the same module is the server and the client. Import
defineSubprocessActor from the node subpath, pass your actor and import.meta.url,
and it detects the server side via a --remote=<hash> argv marker:
import { defineActor } from "posipaki";
import { defineSubprocessActor } from "posipaki/remote/node";
const echoActor = defineActor({
name: "echo",
setup: () => ({ pings: 0 }),
handlers: {
PING(msg) {
this.state.pings++;
this.emit({ type: "PONG", count: msg.count });
},
},
});
const { actor: remoteEcho, isRemoteRoot } = defineSubprocessActor(echoActor, import.meta.url);
if (!isRemoteRoot) {
const proc = await remoteEcho.spawn({});
await proc.ready();
proc.send({ type: "PING", count: 1 });
await proc.wait();
}
defineSubprocessActor(actor, url, opts?) returns:
actor— a proxyActorDefinition. Spawn it like any actor; the first thing it does is boot the server process and bridge messages.runRemoteRoot()— serve the real actor (run this when you are the server).isRemoteRoot—truewhen this process is the server side.
Pass { manual: true } to disable argv auto-detection and call runRemoteRoot()
yourself. The runner is auto-detected (bun or node).
WebSocket
Connect to a served actor from anywhere — including the browser:
import { remoteClient, wsClientSpawner } from "posipaki/remote";
const echo = remoteClient("echo", wsClientSpawner("ws://example.test/echo"));
const proc = await echo.spawn({});
await proc.ready();
proc.send({ type: "PING", count: 1 });
On the server side, wsServerSpawner(ws) serves an already-upgraded socket.
With Bun, bunServerWebSocket(ws) adapts the socket:
import { serveRemoteActor, wsServerSpawner, bunServerWebSocket } from "posipaki/remote";
Bun.serve({
fetch(req, server) {
if (server.upgrade(req)) return;
return new Response("upgrade failed", { status: 400 });
},
websocket: {
open(ws) {
const adapter = bunServerWebSocket(ws);
ws.data = adapter;
serveRemoteActor(echoActor, wsServerSpawner(adapter));
},
message(ws, msg) { ws.data.onmessage?.({ data: msg }); },
close(ws) { ws.data.onclose?.(); },
},
});
Serving many connections
serveRemoteActor serves one channel. A server that accepts connections puts a
listener and a reactor around it: the listener owns the medium and admits
connections, the reactor accepts from it and serves one session per connection.
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import { reactor, type WebSocketLike } from "posipaki/remote";
import { webSocketListener } from "posipaki/remote/node";
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
/** `ws` reports a text frame as a Buffer; decode it before the transport sees it. */
function asSocket(ws: WebSocket): WebSocketLike {
const adapter: WebSocketLike = {
send: (data) => ws.send(data),
close: (code, reason) => ws.close(code, reason),
get readyState() {
return ws.readyState;
},
onmessage: null,
onclose: null,
};
ws.on("message", (data, isBinary) => {
const bytes = Array.isArray(data) ? Buffer.concat(data) : data;
adapter.onmessage?.({ data: isBinary ? bytes : bytes.toString("utf8") });
});
ws.on("close", () => adapter.onclose?.());
return adapter;
}
const listener = webSocketListener({
server,
path: "/echo",
// Completing the HTTP upgrade is the one step the library does not carry.
upgrade: (request, socket, head) =>
new Promise((resolve) =>
wss.handleUpgrade(request, socket, head, (ws) => resolve(asSocket(ws))),
),
// Optional: decide about the connection before an actor exists.
identify: (upgrade) => ({ token: upgrade.url.searchParams.get("token") }),
});
const live = reactor(listener, echoActor, {
onError: (error) => console.error("session failed", error),
});
server.listen(8080);
reactor(listener, serve, options) accepts an ActorDefinition or a
SessionFactory and returns { stop(), live }, where live counts the
sessions being served right now.
| option | when it runs |
|---|---|
onSession(session) |
the process is up; whatever it returns runs when the session ends |
onClosed(session) |
the connection has closed |
onError(error, connection) |
a connection had to be dropped |
streams |
this server's bound on what it will read, and how it chunks what it sends (64 MiB by default) |
An upgrade is admitted before an actor exists, so a refusal costs no process.
identify returns the meta a session carries; throwing refuses the upgrade,
and refuse decides what the far side is told. An upgrade on another path is
left alone, so one HTTP server can route several listeners.
A SessionFactory receives the connection and the $init the far side sent:
const serve = async ({ meta, channel }, init) => ({
proc: await myActor.spawn(init.args, {
name: init.rootName,
connection: channel,
}),
});
The connection a process holds
A served process can speak on the connection it was spawned over.
ctx.connection is a FrameChannel from setup on, and null for a process
spawned without one:
const echo = defineActor({
name: "echo",
setup: () => ({ pings: 0 }),
handlers: {
PING() {
this.ctx.connection?.send({ $notice: { at: Date.now() } });
this.state.pings++;
this.emit({ type: "PONG", count: this.state.pings });
},
},
});
FrameChannel has send, onMessage, onClose and close. onMessage
returns the function that unsubscribes, and several readers can subscribe at
once. When the transport carries octets beside its lines, it also has
sendSegment(frame, bytes) and onSegment(handler).
The client side of a remote actor holds its connection too, and
remoteClient(name, spawner, options) exposes it:
onChannel(channel, { pname })runs once the channel is up and before the first frame is sent or read. Install a reader here and it sees the start of the conversation. What it returns runs when the proxy ends.onOutputis where the far side'sstdoutandstderrgo. By default they are written to our stderr with the process name attached.
Bytes and streams
A message can carry a Uint8Array, a Blob or File, or an async iterable of
chunks, and the far side reads the octets at its own pace rather than all at once.
The values on each side, the limits and a program's output have
their own page.
Connections that vanish
Nothing in the reactor or its transports pings a peer or times a connection
out. A peer that disappears without closing — a dropped NAT flow, a suspended
machine — looks connected indefinitely, and its session keeps what it holds.
If you need a deadline, put it in the listener's adapter (a WebSocket ping and
pong, or TCP keepalive underneath), and when it fires destroy the socket: a
polite close waits for a peer that is not coming, and only a destroyed socket
produces the close the reactor ends the session on.
Workers
Workers pass frame objects by structured clone — no JSON round-trip. The worker
script serves itself with workerSelfSpawner:
// worker.js
import { serveRemoteActor, workerSelfSpawner } from "posipaki/remote";
import { echoActor } from "./echo.js";
serveRemoteActor(echoActor, workerSelfSpawner());
// main thread
import { remoteClient, workerClientSpawner } from "posipaki/remote";
const echo = remoteClient("echo", workerClientSpawner("./worker.js"));
const proc = await echo.spawn({});
Building your own transport
The seam is pluggable — a custom transport (Kafka, a Unix socket, anything)
can be wired into serveRemoteActor / remoteClient with one transport and
one spawner. See Building your own transport for the
full walkthrough.
Imports — portable vs node
The seam is split so a browser never drags in node:fs:
| Import | Contents | Runs in |
|---|---|---|
posipaki/remote |
seam, reactor, frame vocabulary, WebSocket + worker transports and spawners, stream store | browser, service worker, node, bun |
posipaki/remote/node |
defineSubprocessActor, the FIFO transport, and its spawners |
node, bun |
posipaki/remote/node additionally exposes the lower-level subprocess pieces —
FifoUtf8NlineTransport, commandSpawner, and fifoArgvSpawner — if you want
to hand-roll the FIFO bootstrap.
Wire protocol
Server and client exchange the frames below; the last three are one reflection
call and its answer. Over FIFO and WebSocket they are JSON-encoded (json.v1);
over a worker they are structured-cloned (clone.v1).
A frame may also announce raw octets: a $stream announcement comes before the
frame that references it, and a $chunk head names how many bytes follow it
({ id, seq, bytes }), then the bytes, then a $chunkEnd with the checksum.
$streamAsk carries the reader's credit, and $streamDone, $streamFail and
$streamCancel end a stream. Process output travels this way now, as streams
named stdout and stderr; the older $fd output frames are gone.
| direction | frame | payload |
|---|---|---|
| server → client | $proto |
version (json.v1 / clone.v1) |
| server → client | $state |
state snapshot / update |
| server → client | $msg |
out-message { fromName, body } |
| server → client | $exit |
{ code, state } |
| client → server | $init |
init args + parentName / parentIdName |
| client → server | $msg |
in-message { fromName, body } |
| either way | $stop / $pause / $resume / $release |
control, addressed to the process it is about |
| either way | $tune |
{ streams: ["message", "state", "exit"] } — what to be told about it |
| either way | $bad |
a frame that could not be read: { reason } |
| either way | $error |
a serve error, as text |
| server → client | $r.methods |
the reflection methods it can answer |
| client → server | $r.call.<name> |
{ seq, args } |
| server → client | $r.result.<name> |
{ seq, value } or { seq, error } |
A frame that is about a process carries to at the top level, the id this connection
uses for that process, and every frame that is about a process names it:
{"to": 3, "$msg": {"fromName": "main", "body": {"type": "PING"}}}
{"to": 0, "$msg": {"fromName": "main", "body": {"type": "PING"}}}
The roots are numbered apart, each taking the first id of its own side's parity: 0 on the server, 1 on the client, so the second frame above is the one addressed to the server's root. A frame that names no process has nowhere to land and is dropped.
Each side keeps one table for the connection: its own root, the far side's root as a handle, and an id for every process it hands over — allocated the first time one crosses, never reused. Every frame is walked once on the way out (processes become references) and once on the way in (references become what this side knows), so a process on the state, in a message body, in a call argument or in a result is the same rule; a frame addressed to an id the table does not hold is dropped, and a call addressed that way is answered with an error rather than left hanging.
Reflection across the wire
A proxy answers to the same $reflection a local handle does. The far side sends
what it can answer when the proxy starts, and each call carries a sequence number
that pairs it with its answer — so calls can be in flight at once, and answers may
come back in any order. A method that returns a process hands back a reference
instead: the id this connection uses for it, and the name the far side knows it
by. See Reflection.
A process on the far side
A state field that holds a process crosses as a reference, and what you get for it
is a RemoteProcess — a handle on that process, named the way the far side names
it:
const kid = proc.state.kid; // RemoteProcess
kid.pname; // "remote:kid"
kid.state; // what the far side has published
kid.$reflection; // what it announced it can answer
kid.send({ type: "PING" }); // a message for it
kid.subscribe("message", (msg) => {}); // what it says
kid.subscribe("state", () => {}); // what it holds
kid.tune(["state", "exit"]); // exactly this much, or "silent" for nothing
kid.pause(); // stop feeding it messages
kid.resume(); // feed it again
await kid.stop(); // ends it there, and answers when it is gone
await kid.wait(); // {code, state} — what it left behind
kid.hasEnded(); // true once that exit has arrived
kid.isConnected(); // whether the process can still be reached
kid.release(); // let it go: the far side forgets this id
A process that crosses is announced from that moment — what it can answer — and then
says as much of what it holds, what it says and its end as you ask for. Nothing is
said about a process until something asks, and a subscription is what asks: subscribing
to state turns that category on, subscribing to message turns that one on, and
wait() asks to hear the end. tune(["message", "state"]) asks for both at once, and
tune("silent") asks for nothing at all.
The root of a connection is a process like any of these, and it is asked for the same way. The proxy on the other end asks a far root for all three the moment it connects, since what that root holds is the proxy's own state and its end is the end of the connection. So it looks as if a root says everything unasked; it is simply asked first.
Asking for the state says it there and then, since in silence you have heard nothing about it yet. The announcement of its methods is not one of the three categories: a handle that cannot name a method can do nothing with the process however much it is told about it, so that crosses even in silence, once.
Its end crosses as the last thing said about it, to a handle that asked for it — which
wait() and stop() do for you, and stop() asks before it asks the process to stop.
After the $exit arrives nothing more about it crosses, wait() answers with what it
left behind, and send or stop throw — nothing can be asked of the process any more,
though the handle on it is still a handle. A handle
told to hear nothing hears nothing: it never learns the process ended, and a wait() on
it never answers. A process that fails to run says so the same way, with a non-zero
code, rather than leaving a waiting handle without an answer.
The root of a connection is stopped the same way anything else is: the proxy's
stop() asks the far root to end, and what comes back is the exit the connection
closes on.
Letting a handle go is release(): the far side drops that id and stops telling you
about the process behind it, while the process itself runs on. The id is never
handed out again, so a later crossing numbers it afresh, and everything that needed
the old handle fails — send, stop, pause and resume throw, wait() rejects,
and what still arrives for it is not news. An id that an end has already dropped is
not yours to let go of: release() on a handle whose process has ended is refused,
the same as a send.
A handle is not a node in this side's tree: asking the proxy for the tree walks
the far side and gives that tree. isConnected() answers whether the process can still
be reached, and hasEnded() whether it is over — two questions, and a process that has
ended is still one you hold a handle on. What puts a handle out of reach is letting it
go, or the connection going, and neither can be walked back: there is no reconnect and
no un-release. What neither answer tells you is the far side's fate when the wire drops
before an exit — that handle knows only that it died — and a wait() that was pending
when the wire went rejects for the same reason.
A process of your own, handed over
The same reference works the other way. A process you hold — a child of the proxy, or anything else you have — goes over as an argument to a call, or inside a message, and is a handle on the far side:
const kid = await root.fork(worker, undefined, { name: "kid" });
await env.$reflection["tools.use"](kid); // a RemoteProcess over there
What it can answer crosses from the moment it does, and what it holds and what it says
follow once the far side asks — the same tune and the same subscriptions, from the
other end. A send on the far side's handle reaches it either way, and whatever it does
about it comes back on the categories that were asked for. A message that arrives for it
is delivered into the process where it lives; the sender travels as a name, since a
symbol cannot cross.
Method calls go both ways. The far side gets the announcement of what a process of
yours can answer, and asking it is a frame answered here, where the process lives: the
call comes in by name, and what it returns or refuses goes back under the same seq.
The root of a connection is announced and asked the same way as any other process; its
calls ride the connection's own frames rather than a process's.
Live state and shutdown
State streams server → client, so proc.state is always in sync with the
remote. Stopping goes end to end the same way: proc.stop() asks the far root
to end with $stop → the server's actor stops → its $exit crosses back → the
client's proc.wait() resolves.