Building your own transport

Remote actors are transport-agnostic: the seam (serveRemoteActor and remoteClient) speaks only in a Channel of decoded frame objects, plus the frame guards. Protocol, transport, and spawner are all pluggable, so the same actor can run over Kafka, a Unix socket, WebRTC, or an in-memory pipe once you write one transport and a spawner.

The three layers

Layer Interface You write it when
Protocol json1Channel, encode / decode, VERSION reuse JSON (rarely write your own)
Transport StringTransport or Channel every new medium
Spawner Spawner / ClientSpawner every new medium

The shipped FIFO, WebSocket, and worker transports are three concrete instances of this pattern, and a useful reference for writing your own.

Channel vs StringTransport

Frames are plain objects carrying one $-key each ($proto, $init, $state, $msg, $exit). Two interfaces move them:

// decoded frame objects — the seam's currency (also exported as `Channel`)
interface FrameChannel {
  send(frame: Record<string, unknown>): void | Promise<void>;
  onMessage(handler: (frame: Record<string, unknown>) => void): () => void;
  onClose(handler: () => void): void;
  close(): Promise<void>;
  // only on a connection that carries octets beside its lines
  sendSegment?(frame: Record<string, unknown>, bytes: Uint8Array): void | Promise<void>;
  onSegment?(handler: (frame: Record<string, unknown>, bytes: Uint8Array) => void): () => void;
}

// encoded frame strings — for byte streams (Kafka, TCP, sockets, fifos)
interface StringTransport {
  send(frame: string): void | Promise<void>;
  onMessage(handler: (frame: string) => number | void): void;
  onBytes(handler: (bytes: Uint8Array) => void): void;
  removeHandler(): void;
  sendBytes(bytes: Uint8Array): void | Promise<void>;
  onClose(handler: () => void): void;
  close(): Promise<void>;
}

onMessage answers how many octets follow the line, or nothing when none do. Those octets reach onBytes whole, however the medium split them, and the next line starts after them. A medium that only ever moves lines can answer nothing every time; the seam's sendSegment and onSegment then stay unavailable.

json1Channel(transport) lifts a StringTransport into a Channel with JSON encoding (json.v1). A medium with native message boundaries and structured clone (such as postMessage) can implement Channel directly and skip JSON — that's what WorkerTransport does (clone.v1).

A transport

For a byte stream, implement StringTransport: frame each outgoing message and split the incoming stream back into frames. Kafka or a Unix socket differs only in how the bytes move:

import type { StringTransport } from "posipaki/remote";

type ByteStream = {
  write(data: string | Uint8Array): void;
  on(event: string, cb: (chunk?: string | Uint8Array) => void): void;
  end(): void;
};

class NewlineStreamTransport implements StringTransport {
  private handler: ((line: string) => number | void) | null = null;
  private byteHandler: ((bytes: Uint8Array) => void) | null = null;
  private closeHandler: (() => void) | null = null;
  private buffer = Buffer.alloc(0);
  /** How many octets the last line said would follow it, or 0. */
  private want = 0;

  constructor(private stream: ByteStream) {
    stream.on("data", (chunk = "") => this.feed(Buffer.from(chunk)));
    stream.on("close", () => this.closeHandler?.());
  }

  private feed(chunk: Buffer) {
    this.buffer = Buffer.concat([this.buffer, chunk]);
    for (;;) {
      if (this.want > 0) {
        if (this.buffer.length < this.want) return;
        this.byteHandler?.(this.buffer.subarray(0, this.want));
        this.buffer = this.buffer.subarray(this.want);
        this.want = 0;
        continue;
      }
      const nl = this.buffer.indexOf(0x0a);
      if (nl === -1) return;
      const line = this.buffer.subarray(0, nl).toString("utf8");
      this.buffer = this.buffer.subarray(nl + 1);
      this.want = this.handler?.(line) ?? 0;
    }
  }

  send(frame: string) {
    this.stream.write(frame.endsWith("\n") ? frame : frame + "\n");
  }
  sendBytes(bytes: Uint8Array) {
    this.stream.write(bytes);
  }
  onMessage(handler: (line: string) => number | void) {
    this.handler = handler;
  }
  onBytes(handler: (bytes: Uint8Array) => void) {
    this.byteHandler = handler;
  }
  removeHandler() {
    this.handler = null;
  }
  onClose(handler: () => void) {
    this.closeHandler = handler;
  }
  async close() {
    this.stream.end();
  }
}

The shipped FifoUtf8NlineTransport is the full version: it also handles errors, makes close idempotent and enforces a single handler.

A spawner

A spawner obtains the transport and performs the $proto handshake, keeping the version string out of the seam. The server spawner sends it; the client spawner awaits and validates it:

import { json1Channel, VERSION, isProto } from "posipaki/remote";

// server: () => Promise<Channel>
const serverSpawner = async () => {
  const channel = json1Channel(new NewlineStreamTransport(acceptConnection()));
  await channel.send({ $proto: VERSION });
  return channel;
};

// client: (args) => Promise<Channel>
const clientSpawner = async (args) => {
  const channel = json1Channel(new NewlineStreamTransport(openConnection(args)));
  const frame = await new Promise((resolve) => {
    const off = channel.onMessage((f) => {
      off();
      resolve(f);
    });
  });
  if (!isProto(frame) || frame.$proto !== VERSION) {
    throw new Error("unsupported protocol");
  }
  return channel;
};

Wire it into the seam

import { serveRemoteActor, remoteClient } from "posipaki/remote";

// server process
serveRemoteActor(actor, serverSpawner);

// client process
const proxy = remoteClient("myactor", clientSpawner);
const proc = await proxy.spawn(args);
proc.send({ type: "PING", count: 1 });

remoteClient returns a normal ActorDefinition, so spawn, state, and wait behave like any other actor. serveRemoteActor takes the ActorDefinition (not a bare function), so plugins and reflection survive the trip.

Structured-clone transports

If your medium moves objects rather than bytes, implement Channel directly and handshake with WORKER_VERSION (clone.v1) instead of VERSION (json.v1).

Handshake reference

frame direction purpose
$proto server → client version, once
$init client → server domain args + parent identity
$state server → client state snapshot
$msg both a message crossing the boundary
$exit server → client { code, state }
$bad both a frame that could not be read: { reason }
$error both a serve error, as text
$stop / $pause / $resume / $release / $tune both control, addressed to a process
$r.methods / $r.call.<name> / $r.result.<name> both reflection
$stream / $streamAsk / $streamDone / $streamFail / $streamCancel both a stream's life
$chunk + octets + $chunkEnd both one piece of a stream, with its length and checksum