Plugins

A plugin is a function that rewrites an actor's config before assembly. Plugins add lifecycle hooks, decorate the handler context, and register reflection methods — all by returning a new config.

A plugin is a function

type ActorPlugin<C = AnyConfig> = (config: C) => C | Promise<C>;

defineActor applies each plugin once, in order, at spawn time (not at definition time). Each plugin receives the config assembled so far and returns the next one. Because assembly happens per-spawn, a plugin factory can close over per-process state.

The workhorse is mergeConfigs:

import { defineActor, mergeConfigs, type ActorPlugin } from "posipaki";

const announce: ActorPlugin = (config) =>
  mergeConfigs(config, {
    afterStart() {
      console.log(`${this.name} started`);
    },
    beforeEnd(reason) {
      console.log(`${this.name} ending:`, reason);
    },
  });

const actor = defineActor({
  plugins: [announce],
  handlers: {},
});

mergeConfigs chains hooks

mergeConfigs(base, overlay) returns a new config where every key matching on*, before* or after* (onMessage, beforeEnd, afterStart, …) is chained: the overlay hook runs first, then the base hook. Non-hook keys (setup, handlers, methods, $decorate, …) are plain properties — the overlay's value wins.

Because plugins apply in order and each chains onto the previous result, the last plugin in the array runs first — the familiar middleware order:

plugins: [a, b, c],

For an onMessage hook, the call order is c → b → a → actor's own hook.

stopPropagation

Any chained hook can short-circuit the rest of the chain by returning stopPropagation():

import { mergeConfigs, stopPropagation, type ActorPlugin } from "posipaki";

const authGuard: ActorPlugin = (config) =>
  mergeConfigs(config, {
    onMessage(msg) {
      if (msg.type === "SECRET") return stopPropagation();
    },
  });

A stopPropagation() from onMessage skips the remaining onMessage hooks and the handler itself.

Decorating this

A plugin can put values on the handler context via the $decorate field. They become properties on this inside handlers, methods and hooks:

const clock: ActorPlugin = (config) =>
  mergeConfigs(config, {
    $decorate: {
      ...config.$decorate,
      now: () => Date.now(),
    },
  });

const actor = defineActor({
  plugins: [clock],
  handlers: {
    PING() {
      console.log(this.now());
    },
  },
});

Type the new property via declaration merging on ActorDecorated — see the Type augmentation section below.

Reflection methods

Plugins can register callable methods on the process handle via $reflectionMethods. They land on proc.$reflection, this inside a method is the full actor context, and every method answers with a promise — locally and over a wire alike:

const counters: ActorPlugin = (config) =>
  mergeConfigs(config, {
    $reflectionMethods: {
      async "counters.get"() {
        return this.state.count;
      },
      async "counters.reset"() {
        this.state.count = 0;
      },
    },
  });

const actor = defineActor({
  plugins: [counters],
  setup: () => ({ count: 0 }),
  handlers: {},
});

const proc = await actor.spawn(null);
await proc.ready();
await proc.$reflection["counters.get"](); // 0

mergeConfigs deep-merges $reflectionMethods across plugins, so several plugins can each register their own namespaced methods. Type them via declaration merging on ActorReflection — see the Type augmentation section below.

Type augmentation

Plugins that decorate this or add reflection methods want their additions to be typed for consumers. posipaki ships two empty interfaces for that — augment them via declaration merging in a .d.ts:

declare module "posipaki" {
  // `this.<prop>` inside handlers, methods and hooks
  interface ActorDecorated {
    log: (message: string) => void;
    now: () => number;
  }

  // `proc.$reflection.<method>`
  interface ActorReflection {
    "counters.get": () => Promise<number>;
    "counters.reset": () => Promise<void>;
  }
}

ActorDecorated keys become properties on this; ActorReflection keys become methods on proc.$reflection. The merge is global — importing a plugin that ships these augmentations types them for your whole project.

Installing

Plugins go on the config's plugins field:

plugins: [debugLogger(), inspect()],

A plugin that throws during assembly is caught and logged — the actor still spawns without that plugin's contribution.

Inheritance

Plugins are inherited down the actor tree. Each child decides how:

const child = defineActor({
  plugins: (parents) => [...parents, inspect()], // keep + extend
  handlers: {},
});

On a standalone spawn there is no parent, so the function receives [].

spawn and fork also accept an addPlugins option — plugins appended after the resolved chain that cannot be overridden:

const proc = await actor.spawn(null, { addPlugins: [collector.plugin] });

Plugins are deduplicated by function name, so the same plugin can be inherited and re-added without running twice. Anonymous plugin functions skip dedup (and emit a warning), so give your plugins names.

Bundled plugins

posipaki ships two plugins.

debug-logger

import { debugLogger } from "posipaki/plugins/debug-logger";

Decorates this.log (debug, info, warn, error, msg, lifecycle) and observes lifecycle events and message traffic. Options:

See Debug Logger for the full picture.

tree-introspection

import { inspect } from "posipaki/plugins/tree-introspection";

Registers reflection methods for poking at a live tree:

See Introspection for the full picture.

const proc = await actor.spawn(null);
const tree = await proc.$reflection["inspect.getTree"]();

Testing plugins

posipaki/testing is built on the plugin system — createCollector and createRootTracker both return { plugin, … } objects installed via addPlugins. See Testing.