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:

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:

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:

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.