Low-Level API

defineActor is a convenience over a small core: a process is an async generator that yields state and receives stamped messages. This page covers that core — spawnAsync, AsyncProcess, ProcessCtx, and the message loop.

A process is a generator

The lowest-level building block is a function with this shape:

type AsyncProcessFn<Args, State, InMsg, OutMsg> = (
  ctx: ProcessCtx<Args, State, InMsg, OutMsg>,
  args: Args,
) => AsyncGenerator<State | null, void, WithSender<InMsg | StopMessage>>;

Three ideas:

Your first process

import { spawnAsync, runDispatchAsync } from "posipaki";

async function* greeter(ctx) {
  const state = { seen: 0 };
  yield state;                              // initial state

  yield* runDispatchAsync(
    ctx.pname,
    async ([msg, sender]) => {
      state.seen++;
      ctx.toParent({ type: "GREETED", who: sender.fromName });
    },
    () => state.seen >= 1,                  // exit after one message
  );
}

const proc = spawnAsync(
  greeter,
  "greeter",
  (msg) => console.log(msg.type),           // observe out-messages
)(null);

await proc.ready();
proc.send({ type: "HELLO" });
await proc.wait();                          // resolves on generator return

runDispatchAsync is the standard message loop: it yields null (no state change) and feeds each incoming [msg, sender] to the reducer until the readyFn returns true. The next section unpacks it.

Inside runDispatchAsync

runDispatchAsync reduces to this (minus the debug logging):

async function* runDispatchAsync(fn, readyFn = () => false) {
  while (!readyFn()) {
    const msg = yield null;   // suspend; receive the next [msg, sender]
    await fn(msg);
  }
}

So a process can drop the helper and drive the generator by hand. The greeter again, driven by yield alone:

async function* greeter(ctx) {
  const state = { seen: 0 };
  yield state;                        // initial state → proc.state

  while (state.seen < 1) {
    const [msg, sender] = yield null; // no new snapshot; receive a message
    state.seen++;
    ctx.toParent({ type: "GREETED", who: sender.fromName });
  }
}

A bare yield does two things at once:

  1. Sets the state once — the framework reads the first yielded value as proc.state and never reassigns it. To expose changes, mutate the state object in place; proc.state holds the same reference, so the mutation is visible. yield null is the idiom for "no new snapshot" — after the first yield the value is ignored either way.
  2. Receives a message — the yield expression evaluates to the next incoming message as [msg, sender].

That is the entire message loop. runDispatchAsync layers the reducer callback and the exit predicate on top of it.

ProcessCtx

Every process receives a ctx:

The process handle

spawnAsync returns an AsyncProcess:

Messages are processed one at a time

send enqueues and schedules a tick. If a reducer is mid-await, later messages buffer until it settles — the same serial queue defineActor gives you.

STOP and EXIT

At this level nothing is special-cased. STOP is just a message — your generator decides what to do with it (typically return). EXIT is what a process emits when it returns, carrying any still-running children as orphans. See Lifecycle for the full stop story.

Sync processes are deprecated

spawn, Process and runDispatch still exist for synchronous generators but are deprecated — use spawnAsync, AsyncProcess and runDispatchAsync. asyncify wraps a sync generator into an async one when needed.

defineActor builds on this

defineActor compiles its config into an AsyncProcessFn and drives it with spawnAsync, layering hooks, plugins and typed messages on top. See API Reference.