The app#

defineActorApp is the composition root. It is an inert description until start(), which is what lets the same module be started by a Node entry, the Vite dev server or a Worker — and it is where plugins fold their contributions together.

Why it exists#

createHost is still the low-level primitive, but it takes exactly one placement and one storage, and ActorPlacement.bind() is its only lifecycle-hook shape. So two things that both want beforeActivate cannot coexist.

defineActorApp fixes that by folding every plugin's contributions into the single placement, storage and context createHost already understands.

TypeScript
// src/actors.app.ts — one typed source of truth
import { defineActorApp } from '@sigx/actors/host';
import { fileStorage } from '@sigx/actors/node';
import { metrics } from '@sigx/actors/host';

export const app = defineActorApp({
    actors,
    storage: fileStorage({ dir: '.actors' }),
    defaults: { idleAfterMs: 60_000 },
}).use(metrics());

/** Bound to this app's plugin set — import it from your actor modules. */
export const { defineActor } = app;
TypeScript
const host = await app.start();   // builds the host, starts it, runs onStart
await app.stop();                 // drains the host, then onStop in reverse

A started app is single-use#

start() is idempotent while running, but after stop() the app refuses to restart. That is deliberate: a plugin placement mints its identity per run, and a cluster host id is gone once its membership entry is. Build a new app instead.

A start that fails is the exception — the rejection is not cached, so fixing the cause and calling start() again really retries.

Writing a plugin#

setup() receives a registry. Everything composes across plugins except setPlacement, which is exclusive by nature — a second claim throws, naming both plugins.

TypeScript
import type { ActorPlugin } from '@sigx/actors/host';

interface Logger { info(message: string): void }

export function logging(logger: Logger): ActorPlugin<{ log: Logger }> {
    return {
        name: 'logging',
        setup(registry) {
            registry.extendContext(() => ({ log: logger }));
            registry.onBeforeActivate((ref) => logger.info(`activating ${ref.type}/${ref.key}`));
            registry.useDispatch((next) => ({
                dispatch: async (ref, method, args, call) => {
                    logger.info(`${ref.type}#${method}`);
                    return next.dispatch(ref, method, args, call);
                },
                // Forward streaming — `dispatchStream` is optional, so a
                // middleware that omits it silently breaks every
                // `streams:` method. Dev-warns if you forget.
                ...(next.dispatchStream && {
                    dispatchStream: (ref, method, args, call) =>
                        next.dispatchStream!(ref, method, args, call),
                }),
            }));
        },
    };
}

If you write dispatch middleware, forward dispatchStream. It is optional on the interface, so omitting it does not fail to compile — it silently breaks every streams: method in the app. Dev builds warn.

Typed context, per app#

The ActorPlugin<{ log: Logger }> type argument is what makes ctx.log typed inside every actor that imports the app-bound defineActor. There is no global declaration merging, so the additions stay scoped to this app:

TypeScript
// src/counter.actor.ts
import { defineActor } from './actors.app';

export const Counter = defineActor({
    type: 'Counter',
    unguarded: true,
    state: () => ({ count: 0 }),
    methods: (ctx) => ({
        increment(by: number) {
            ctx.log.info('increment');   // typed, contributed by .use(logging(...))
            return (ctx.state.count += by);
        },
    }),
});

This is the reason actor modules import defineActor from your app module rather than from @sigx/actors. Both produce the same object at runtime; only the type differs.

The registry hooks#

Registry hookComposes?Notes
addTypeHandlersconcatenatedcodec handlers for state persistence
decorateStoragechainedlast registered is outermost
setPlacementexclusivea factory, run once the host exists; a second claim throws, naming both plugins
onBeforeActivatein orderthrowing refuses the activation
onAfterDeactivatereverse ordererrors caught per hook and dev-logged
useDispatchoutside-infirst registered is outermost; must forward dispatchStream
onStart / onStopin order / reverseonStop runs after the drain
routecollectedexposed as app.routes for adapters
extendContextmergednever overwrites a built-in ctx member
reportHealthcollectedsee Health & readiness
reportOps / reportDigestcollectedsee The ops endpoint

A placement's own hooks bracket the plugins'. Its beforeActivate — a cluster's directory claim — runs first, and its afterDeactivate — the release — runs last, so plugin hooks always observe an activation the placement already owns.

The app factory pattern#

On a runtime that constructs your app more than once — notably Cloudflare Durable Objects — building the app at module scope binds whichever instance constructed it first, and every other instance is then served from those seams. Export a factory instead:

TypeScript
export const createApp = (base: ActorAppOptions) => defineActorApp(base).use(metrics());

/** Type-only binding, never started. */
export const { defineActor } = createApp({});

On Node and under Vite the module-scope form is fine.

Next steps#