API Reference
Everything defineActor accepts, in one place. See Quick Start for
the shortest working example.
defineActor and the actor runtime are portable — browser, service
worker, Node, Bun. The only Node-only surface is subprocess remote actors,
behind posipaki/remote/node — see Remote Actors.
defineActor
import { defineActor, defineMessages } from "posipaki";
const actor = defineActor(config);
defineActor(config) compiles a declarative config object into an
ActorDefinition with a .spawn() method. The config describes the actor's
initial state, message handlers, helper methods and lifecycle hooks.
Config fields
name
Type: string · optional
Preferred process name. Used by this.fork() when no explicit child name is
given.
inMessages / outMessages
Type: defineMessages<T>() · optional
Type tags for the actor's message contracts. Hand them a discriminated union
on type:
type MPoke = { type: "POKE" };
type MPing = { type: "PING"; step: number };
type MTick = { type: "TICK"; count: number };
const counter = defineActor({
inMessages: defineMessages<MPoke | MPing>(),
outMessages: defineMessages<MTick>(),
// ...
});
inMessages drives the narrowing of msg in handlers and the accepted
argument of proc.send(...). outMessages types this.emit(...) and the
parent's view of this actor's messages. Without them, messages fall back to
the loose Message type.
setup
Type: (args) => State | Promise<State> · optional
Returns the initial state. this.state is not available here — build the
state from args (and this.name / this.id if needed). Runs once, after
beforeStart and before afterStart. If omitted, the actor starts with
null state.
setup(args) {
return { count: 0, max: args.max };
},
handlers
Type: { [type]: (msg, sender) => void | Promise<void> } · required
The message dispatch table. The key is a message type; msg is narrowed to
the matching union member and sender is a SenderInfo. Handlers run one at
a time — an incoming message is buffered until the current handler finishes. Handlers can be async.
handlers: {
POKE(msg, sender) {
// msg: { type: "POKE" }
this.state.count++;
},
async PING(msg) {
// msg: { type: "PING"; step: number }
this.state.count += msg.step;
},
},
If a message has no handler, onUnhandled is called instead.
The special STOP type is handled automatically and is implied to be part of in-messages type.
See onStopRequested() in lifecycle section to learn more about it.
methods
Type: { [name]: (...args) => unknown } · optional
Helper methods merged onto this, so handlers can share logic without
repeating it:
methods: {
increment() { this.state.count++; },
beDone() {
this.emit({ type: "TICK", count: this.state.count });
this.exit("max reached");
},
},
handlers: {
POKE() { this.increment(); },
},
plugins
Type: ActorPlugin[] | (parents) => ActorPlugin[] · optional
Reusable units of actor behaviour that transform the config before assembly.
See Plugins. A plugin is a function (config) => config; the
array form replaces the chain, the function form receives the parent's
plugins and returns the child's. When plugins is omitted, a child inherits
the parent's chain. Plugins are deduplicated by function name.
plugins: [debugLogger()], // replace the chain
plugins: (parents) => [...parents, inspect()], // extend the chain
Lifecycle hooks
Hooks are additive, when defined by plugins and actor itself, several can register for the same point and they fire in
registration order. Any hook may return stopPropagation() to skip the rest
of the chain. Hooks fire in this order:
beforeStart → setup → afterStart → [message loop] → beforeEnd → afterEnd
See Lifecycle for more in-depth explaination of process lifecycle.
beforeStart / afterStart
Type: () => void | Promise<void>
beforeStart fires once this is built but before setup (this.state is
never). Use it to register the process before forking any child. afterStart
fires after setup, with this.state available and after proc.read() resolved.
onStopRequested
Type: () => HookResult | Promise<HookResult>
Fired when a STOP arrives. The hook may call this.agreeToStop() to accept
it; if it doesn't, the actor keeps running. When no onStopRequested is
defined, a STOP stops the actor immediately. In either case no messages, including ones already in the mailbox will be processed.
beforeEnd / afterEnd
Type: (reason?) => HookResult | Promise<HookResult>
beforeEnd fires when the message loop finishes, with the exit reason.
afterEnd fires after that, as the process tears down.
onError
Type: (error?) => HookResult | ErrorResult | Promise<HookResult | ErrorResult>
Fired when a handler or hook throws, with the error. Whether that error is fatal depends on the handler:
- Message hooks and handlers (
onMessage,onEmit, the message handlers):onErrordecides. Returning nothing absorbs the error and the actor carries on; returningpropagateError()declines it, and the error propagates — the actor goes down, exactly as it would with noonErrorat all, telling its parent with anEXIT. - Lifecycle hooks (
beforeStart,afterStart,beforeEnd,afterEnd,onStopRequested,onChildExit,onOrphan): always fatal.onErrorstill fires, so the error gets logged, but it cannot absorb it. A hook is the actor's own control flow: absorbing its error leaves the state machine half-applied — an actor that is still alive with nothing left to do, and a parent that never hears anEXIT.
onError(err) {
this.log.error(`boom: ${(err as Error).message}`);
return propagateError(); // observed, not handled
}
With chainHook, the actor's own onError has the last word: its
propagateError() is honoured even when a plugin's onError returned nothing.
onEmit / onMessage
Type: (msg, sender) => HookResult | Promise<HookResult>
onEmit observes every message the actor sends to its parent. onMessage
observes every incoming message before dispatch; returning stopPropagation()
skips the handler.
onUnhandled
Type: (msg, sender) => void | Promise<void>
Fired when a message arrives with no matching handler.
onChildExit
Type: (name, reason) => HookResult | Promise<HookResult>
Fired when a forked child exits. name is the child's tree-prefixed process
name (parent:child); reason is its ExitMessage.
onOrphan
Type: (orphan) => OrphanDecision | void
Fired for each orphan a child leaves behind in its EXIT. Return a policy:
"adopt"— promote to a child and drain its buffer"force-stop"— hard-kill it (the default whenonOrphanis absent)"unparent"— drop its buffer, keep it running inorphans"leave"— keep buffering, propagate it up on my exit
See Lifecycle page to learn more about orphan processes.
The handler context (this)
Inside handlers, methods and every hook, this is an ActorContext:
state— the current state, typed fromsetup's return.name/id— process name (string) and id (symbol).emit(msg)— send an out-message to the parent.exit(reason?)— terminate immediately with an optional reason.agreeToStop()— accept a pendingSTOP.fork(actor, args?, opts?)— spawn a child actor; returns its process handle. Children are tracked in$child.ctx— the underlying low-levelProcessCtx(sendSelf,toParent, and friends).
Spawning
const proc = await counter.spawn(args, {
name?, // the process name
toParent?, // adopt an existing process as the parent
addPlugins?, // plugins for this instance only
parentName?, // identity to report to the parent
parentId?,
connection?, // the FrameChannel this process speaks over
awaitReady?, // false to return before the initial state exists
});
await proc.ready();
proc.send({ type: "POKE" });
spawn returns an AsyncProcess handle:
ready()— resolves once the initial state is available.send(msg)— queue a message for the actor.state— the live state.$reflection— the reflection methods defined by the actor and its plugins; every call answers with a promise. See Reflection.subscribe("state" | "message", cb)— observe state changes or out-messages; returns an unsubscribe function.pause()/resume()— pause and resume message processing.wait()— resolves when the actor exits.
spawnAsChild(ctx, args, opts) spawns the actor as a child of an existing
process instead of a standalone root.
The AsyncProcess handle here is the same object the raw spawnAsync returns —
see Low-Level API for the process model underneath defineActor.
Remote actors
Remote actors extend the model across a boundary. The seam is two functions:
serveRemoteActor(actor, spawner)— serve a real actor over a channel produced by the spawner.remoteClient(name, spawner)— a proxyActorDefinition; spawning it connects and mirrors the remote.
A spawner is a transport factory: () => Promise<Channel> on the server,
(args) => Promise<Channel> on the client. It obtains the transport and does
the version handshake, leaving the seam transport-agnostic.
For a server that accepts many connections, reactor(listener, actor, options)
accepts from a listener and serves a session per connection. posipaki/remote
also carries the frame vocabulary, FrameChannel (the connection a process
holds as ctx.connection), and the stream store that moves typed arrays,
blobs and AsyncIterable<Uint8Array> streams across a connection. See
Remote actors.
To build your own transport, spawner or protocol, these primitives are
exported from posipaki/remote:
Channel/StringTransport— the two currency interfaces (frame objects vs frame strings).json1Channel,encode,decode,VERSION— the JSON protocol (json.v1).isProto/isInit/isState/isMsg/isExit— the frame guards.
posipaki/remote is portable; the FIFO subprocess pieces
(defineSubprocessActor, commandSpawner, fifoArgvSpawner,
FifoUtf8NlineTransport) live in posipaki/remote/node (Node/Bun only). See
Remote Actors. For building your own transport, spawner or
protocol, see Building your own transport.
Types
Message—{ type: string }, the base every message extends.SenderInfo—{ fromName: string; fromId: symbol }.ExitMessage—{ type: "EXIT"; orphans?: AnyProcess[] }.OrphanDecision—"adopt" | "force-stop" | "unparent" | "leave".HandlerFn<InMsg>—(msg, sender) => void | Promise<void>.stopPropagation()— sentinel a hook returns to skip later hooks.propagateError()— sentinel anonErrorhandler returns to decline the error.