npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sigx/actors

v0.2.0

Published

Virtual actors for SignalX — addressable, single-threaded, persistent server state

Readme

@sigx/actors

Virtual actors for SignalX: addressable, single-threaded, persistent server objects that integrate with sigx's server layer — actor calls ride the same wire protocol, codec, and security posture as serverFn.

// cart.actor.ts
import { defineActor } from '@sigx/actors';
import { requireUser } from './guards.server';

export const CartActor = defineActor({
    type: 'Cart',
    authorize: [requireUser],
    state: () => ({ items: [] as Item[] }),
    methods: (ctx) => ({
        async addItem(item: Item) {
            ctx.state.items.push(item);   // deep signal — just mutate
            await ctx.save();             // explicit persistence
            return ctx.state.items.length;
        }
    })
});
// Anywhere — browser, serverFn, SSR render. Same expression.
import { actor } from '@sigx/actors';
const count = await actor(CartActor, cartId).addItem(item);

The actor need not exist: the first call activates it (loading its state from storage), and idle actors deactivate automatically. No create, no destroy, no connection management — that is the virtual-actor model.

The model, in five guarantees

  1. Addressable. An actor is (type, key). actor(CartActor, 'user-42') always reaches the user-42 cart — one activation per key.
  2. Single-threaded. One turn (method call) at a time per activation. Plain mutation on ctx.state is race-free; no locks, ever. (reentrant: 'always' and methodReentrancy opt out per actor or per method — see Reentrancy & interleaving.)
  3. await does not yield the actor. A turn ends when the method's promise settles — an awaited fetch inside a method blocks every queued call to that actor until it resolves. This is the non-reentrant default and the model's core trade: state safety over intra-actor concurrency. (Dev builds warn when a turn exceeds slowTurnMs.)
  4. Persistent. ctx.save() writes state through the pluggable ActorStorage (etag optimistic concurrency); activation loads it back. A conflicting writer faults the stale activation (ActorStateConflictError) — the next call loads the winning state.
  5. Deadlock-detected. Every call carries its chain; A → B → A into a non-reentrant actor throws ActorDeadlockError immediately with the full chain, instead of hanging until a timeout. reentrant: true allows call-chain re-entry (the cycle runs inline against your own turn); reentrant: 'always' interleaves everything, making the deadlock impossible by construction.

Turns

Guarantees 2 and 3 are one mechanism, and most surprises trace back to it.

A turn is one method invocation on one activation, from the moment it starts running to the moment its promise settles. Every activation runs its turns one at a time, in the order they arrived.

That is the whole reason ctx.state.count++ is safe with no lock — while your turn is running, nothing else on that actor is. It is also why await inside a turn costs more than it does in ordinary JS: the turn has not ended, so every later call to that actor waits. Slow I/O inside a turn is a queue for that actor, and metrics() splits the two halves so you can tell them apart — queueMs (waiting for a turn; high means the actor is hot) versus turnMs (holding the activation; high means the turn itself is slow).

Three things deliberately run outside a turn, so they cannot block one: guards, task bodies (ctx.tasks) and stream iteration. Each can re-enter with ctx.turn(...) when it needs to touch state safely. reentrant: 'always' lets an actor's own turns overlap instead.

Setup

pnpm add @sigx/actors

Vite app — add the plugin; it runs a dev host for you and mounts the endpoint at /_sigx/actor:

// vite.config.ts
import { sigxActors } from '@sigx/actors/vite';
export default { plugins: [sigxActors({ app: '/src/actors.app.ts' })] };

Actors live in *.actor.ts modules. The build swaps them wholesale for typed client stubs — implementations never reach the browser (values are swapped, types are not, so the client proxy is fully typed).

sigxActors({ app }) points dev at the same app module your production entry imports, so storage, placement, codec handlers, defaults and every plugin are identical in both. The app module gets its registry from virtual:sigx-actors; add its types once, next to your other Vite types:

// src/env.d.ts
/// <reference types="@sigx/actors/vite-client" />

The app module leaves out its registry, so it imports nothing Vite-specific and loads under any runtime — which is what lets a plain-Node entry share it, and what makes it safe for actor modules to import the bound defineActor from it:

// src/actors.app.ts — no `actors`, no virtual import
export const app = defineActorApp({ storage: fileStorage({ dir: '.actors' }) });
export const { defineActor } = app;

Under Vite the plugin supplies the registry it already builds (its loaders go through the module runner, so HMR keeps working). Anywhere else, name the actors:

// server.mjs — the SAME app module
const host = await app.withActors([Counter]).start();

withActors throws if the app already declared actors, so a host can never silently replace what the author configured. examples/counter runs exactly this shape in dev and in production.

Production server entry — explicit composition, the sigx idiom:

import { createHost } from '@sigx/actors/host';
import { handleActorRequest, matchesActorRequest } from '@sigx/actors/server';
import { actors } from './dist/server/sigx-actors.js'; // build-emitted registry

const host = createHost({ actors, storage });
await host.start();

export default {
    async fetch(request: Request): Promise<Response> {
        if (matchesActorRequest(request)) return handleActorRequest(request, { host });
        if (matchesServerFn(request))     return handleServerFnRequest(request, { resolve });
        return documentHandler(request);
    }
};

Node servers use createActorHandler / attachSignalHandlers from @sigx/actors/node (see examples/counter/server.mjs).

The app: one config, many runtimes

createHost stays 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 is the composition root that fixes that: it folds every plugin's contributions into the single placement, storage and context createHost already understands.

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

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;
const host = await app.start();   // builds the host, starts it, runs onStart
await app.stop();                 // drains the host, then onStop in reverse

The app 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.

A started app is single-use: start() is idempotent while running, but after stop() it refuses to restart (a plugin placement mints its identity per run — a cluster host id is gone once its membership entry is), so 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.

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)
                })
            }));
        }
    };
}

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

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

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

| Registry hook | Composes? | Notes | |---|---|---| | addTypeHandlers | concatenated | codec handlers for state persistence | | decorateStorage | chained | last registered is outermost | | setPlacement | exclusive | a factory, run once the host exists; a second claim throws, naming both plugins | | onBeforeActivate | in order | throwing refuses the activation | | onAfterDeactivate | reverse order | errors caught per hook and dev-logged | | useDispatch | outside-in | first registered is outermost; must forward dispatchStream | | onStart / onStop | in order / reverse | onStop runs after the drain | | route | collected | exposed as app.routes for adapters | | extendContext | merged | never overwrites a built-in ctx member |

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.

Custom placement

Choosing between the shipped policies? See Which placement policy should you use? — the short answer is that the default is right unless your load balancer hashes the routing token.

Two independent axes:

  • The backendwho hosts an actor at all: the local host, a cluster, Durable Objects. One per app, claimed with setPlacement.
  • The strategywhich host a new activation goes to. That is PlacementPolicy from @sigx/actors/cluster; ship your own choose(ref, view, self) alongside the built-in randomPlacementPolicy(), consistentHashPolicy(), preferLocalPolicy() and activationCountPolicy().

A strategy can be declared on the actor, a per-type placement attribute that beats the central typePolicies map:

export const Session = defineActor({
    type: 'Session',
    placement: preferLocalPolicy(),   // pin hot session-shaped types local
    // ...
});

setPlacement takes a factory, not an instance, precisely so a backend can read those declarations — it runs once the host exists, so the context can resolve definitions:

registry.setPlacement((ctx) => clusterPlacement({
    ...providers,
    // per-type strategies resolve lazily, since a `virtual:sigx-actors`
    // registry only loads a type's module on demand
    definition: ctx.definition,
    secret,
}));

Precedence for a new activation: defineActor({ placement })clusterPlacement({ typePolicies })clusterPlacement({ policy }) → uniform random.

A declared strategy the cluster cannot use — no choose() — is an error, not a fallback: silently placing an actor somewhere other than where its author declared is the kind of failure that leaves no signal pointing at its cause. A strategy intended for a different placement backend should say so, and is then ignored in silence:

placement: { name: 'my-do-strategy', backend: 'durable-objects' }

Other runtimes

createFetchHandler(app) is the portable entry — the public actor endpoint plus every plugin route as one (Request) => Response:

import { createFetchHandler } from '@sigx/actors/server';

await app.start();
Deno.serve(createFetchHandler(app));           // Deno
export default { fetch: createFetchHandler(app) };  // Bun, Workers

@sigx/actors/node's createAppHandler stays the Node mount — it keeps core's connect adapter for the public endpoint (backpressure-aware body pumping) rather than routing everything through the generic bridge.

Pluggable reminders

Durable reminders are a seam too. The default shardedReminders() keeps the table in ActorStorage under a reserved type, split into 16 hash shards that hosts divide between them — which assumes many actors per host:

defineActorApp({ actors, reminders: shardedReminders() })   // the default

Where that assumption is false, replace it. Under Cloudflare's one-Durable-Object-per-actor model each actor's reminders live in its own DO and fire from its own alarm, so there is nothing to shard and nothing to poll. An implementation gets bind({ storage, scheduler, tickMs, ownsShard, deliver }) once before start(), mirroring ActorPlacement.bind().

The clock seam

Background work — the idle sweeper, the reminder tick, ctx.timer, write-behind flushes — runs through an ActorScheduler. Those are the jobs that must keep running between requests, so they are the ones a runtime has to redirect. Call deadlines and the shutdown drain stay on host timers, since they are scoped to an in-flight request or stop:

defineActorApp({ actors, scheduler: timerScheduler() })   // the default

That matters for two things. Tests can drive time exactly:

const scheduler = manualScheduler();
const host = createHost({ actors, scheduler, defaults: { idleAfterMs: 0 } });
scheduler.advance(60_000);   // an hour of sweeps, instantly

And it is what makes a runtime with no background execution possible at all: a Cloudflare Worker only runs while handling a request, so an interval registered at startup never fires. That is an architectural difference, not something a polyfill can hide — hence a seam rather than a shim.

Shipping actors in a package

An app registers actors from anywhere — withActors([Greeter, Presence]). The build is the part that needs care: sigxActors() only transforms first-party source (node_modules is excluded), so a package must do its own client swap, through its exports map:

{
    "exports": {
        ".": {
            "types": "./dist/server.d.ts",
            "browser": "./dist/client.js",
            "import": "./dist/server.js"
        }
    }
}
// client.ts — the browser half
import { __actorRef } from '@sigx/actors/client';
import type { Greeter as GreeterDef } from './server';

// Types come from the real definition; the value is a ref. The `import
// type` is erased, so no implementation reaches the browser.
export const Greeter = __actorRef('acme/greeter', '/_sigx/actor', ['watch']) as typeof GreeterDef;

That is the same swap the Vite plugin performs for *.actor.ts, done by static resolution instead — so it works with any bundler.

Three things are on the package author, because the consuming app's build never sees the source:

  • Authorization. requireAuthorization cannot inspect a package, so declare authorize or allowAnonymous yourself. The host warns for a registered actor that declares neither.
  • Stream names in the ref must match the definition; they drive wire routing.
  • Cacheable read names likewise, as the ref's fourth argument (__actorRef(type, endpoint, streams, reads)): they are what makes the client issue GET, and a name that is in one place but not the other means either a 405 or a read that quietly never caches.
  • type is public API. It is the wire, directory and storage key, so renaming it breaks deployed state. Two different actors claiming one type is refused at startup. Namespace it after the package (acme/greeter), but not with the npm scope form — a type starting with @ or $ is refused: those heads belong to the runtime's own data keys (@actor) and mounts ($live).

Authorization

Actor methods are as security-sensitive as server functions, and the runtime is fail-closed: an actor that declares nothing, in a process with no server app configured, denies every call with 401. Access is decided by an authorize policy, or waived explicitly with allowAnonymous: true, or left to the app's default policy.

// src/server-app.ts — the ONE place app-wide policy lives
export const app = createServerApp<User>({
    middleware: [requestId, auditLog],   // work, every transport
    authenticate: sessionFromCookie,     // -> User | null
    codec: { encode: (u) => u.id, decode: (id) => ({ id }) }
});
defineActor({ type: 'Room', methods });                      // app default decides
defineActor({ type: 'Auth', allowAnonymous: true, methods }); // deliberately public

A policy is core's ServerPolicy(principal, rq, op) => boolean, strict-true: anything else denies (403; 401 when the principal is null). A thrown ServerFnError passes through verbatim for a custom status. Policies run on every transport — the wire endpoint, $live, and in-process actor() calls — and always outside any turn, so a slow check never occupies the actor's turn.

The policy sees the instance, which is what makes the common actor question expressible at all:

defineActor({
    type: 'Cart',
    // op.resource is { kind: 'actor', type: 'Cart', key, method }
    authorize: (user, _rq, op) => op.resource.key === user.id,
    methods
});

methodAuthorize: { methodName: [...] } adds per-method policies, ANDed after authorize. Actor-to-actor calls (ctx.actor) do not re-authorize: authentication is per REQUEST and authorization is per ENTRY POINT — the wire endpoint, the live endpoint, an in-process call, a job enqueue — and a hop is none of those.

The endpoint-level guard option is gone (it was wire-only by construction). App middleware runs in the same pre-decode slot and reaches in-process calls too; a wire-only concern is one if (fn.transport !== 'wire') return; in the middleware body.

Identity: ctx.principal

The authenticated principal reaches every actor as ctx.principal, with nothing to stamp and nothing to thread through arguments:

defineActor({
    type: 'Room',
    methods: (ctx) => ({
        async post(text: string) {
            const from = (ctx.principal as User | null)?.id;
            // …
        }
    })
});

It rides a first-class slot on the call envelope, not the context bag — so it cannot be forged through .with({ bag }), and it cannot be dropped by forgetting to stamp it. It is inherited unchanged by ctx.actor and ctx.publish hops and carried host-to-host, so a downstream actor sees whoever entered the system, not the actor that called it. Decoding is lazy and memoized, so an actor that never reads it pays nothing.

Inside a defineJob run body it is job.principal instead: a job outlives the request that started it, so authorization happens once at enqueue and the run reads the snapshot recorded there — persisted, so it survives deactivation and every crash-resume.

It needs codec on createServerApp (a principal has to round-trip as a string to ride the envelope). Without one it propagates nothing and dev-warns once — fail-closed at the reader. Treat null as unauthenticated, never as a default principal: between hosts this rides outside the cluster HMAC, exactly like the bag, so its trust is the deployment perimeter rather than a proof.

The request-context bag

The bag is the channel for app DATA — a correlation id, a tenant hint — that inner hops would otherwise never see. (Before the guard split it also carried identity; that is ctx.principal's job now, precisely so a forgotten stamp cannot silently drop the caller mid-chain.)

import { stampCallBag } from '@sigx/actors';

const withTenant: ServerMiddleware = (rq) => {
    stampCallBag(rq, { tenant: tenantOf(rq) });   // ← stamped once…
};

defineActor({
    type: 'Room',
    methods: (ctx) => ({
        async post(text: string) {
            const tenant = ctx.bag.tenant;        // ← …read anywhere
        }
    })
});

stampCallBag(rq, entries) merges string entries (last wins) onto the request; after the pipeline runs, the endpoint lifts them into the call context, where they ride the whole chain: ctx.bag on the called actor, inherited by ctx.actor(...) and ctx.publish(...) hops (policies do not re-run on those — a hop is not an entry point), across host-to-host hops on the envelope, and into $live watches. The in-process actor() entry lifts the same store, so a serverFn's stamp reaches the actor with no HTTP hop. actor(Def, key).with({ bag }) sets entries explicitly (server-side scripts, tests, ops), merging over the stamped/inherited ones — explicit wins.

The rules, all deliberate:

  • String-only and size-capped: at most 8 entries, 64-byte keys, 256-byte values, 1 KiB total (UTF-8 bytes; the CALL_BAG_MAX_* exports). The bag rides an HTTP header host-to-host, and a cap that exists keeps the wire honest. stampCallBag and .with({ bag }) throw on violation — developer input fails at the developer's stack.
  • Never client-settable. The public endpoint reads no header into the bag; the only sources are server-side. A browser-settable user entry would be an authorization bypass.
  • Dropped whole, never partially, and never a 400. En route (the envelope), a malformed or over-cap bag is silently dropped in one piece — a partial identity is worse than none, and a header-triggered 400 is a DoS lever. Consequence: treat a missing entry as unauthenticated, never as some default principal.
  • Not integrity-protected between hosts. The cluster HMAC signs the call identity, not the envelope body, so the bag's integrity rests on the same perimeter posture as the rest of the envelope: run mTLS/VPC between hosts.
  • Detached work does not inherit it: task bodies and volatile timer ticks read an empty ctx.bag, exactly as they carry no traceparent — they outlive (or have no) caller.
  • ctx.bag is frozen and resolved per read, so it is turn-correct on interleaving activations, and an empty frozen object outside any turn.

Cacheable reads (reads:)

Declare a method a cacheable read and the endpoint accepts GET for it and emits the Cache-Control you asked for, so browsers, CDNs and reverse proxies absorb read traffic that would otherwise reach an actor:

defineActor({
    type: 'Product',
    allowAnonymous: true,
    reads: {
        summary: { maxAge: 5 },
        price: { maxAge: 60, public: true, staleWhileRevalidate: 30 }
    },
    state: () => ({ cents: 999 }),
    methods: (ctx) => ({
        async summary(currency: string) { … },
        async price() { return ctx.state.cents; }
    })
});

GET {base}/r/{token}/Product%23price?args=["p1"] → the same envelope a POST returns, plus Cache-Control: public, max-age=60, s-maxage=60, stale-while-revalidate=30. The declaration is core's ServerFnReadCache vocabulary, unchanged, and the build stamps the names onto the client ref so the proxy issues GET on its own — nothing at the call site changes.

What you are trading, stated plainly: a cached read bypasses the turn ordering guarantee. For maxAge seconds the response an intermediary serves may be older than the actor's state, and nothing on the server can pull it back — not ctx.save(), not useActorAction, not cells.invalidate(), which refresh this page's cells and never a CDN's copy. Declare it where staleness is a product decision, not where it would be a bug. The declaration is also a promise the runtime cannot check: a listed method must be side-effect-free and idempotent, exactly as with core's cache on a serverFn — a mutating method declared cacheable re-opens CSRF.

The rest follows from that, and is checked:

  • public is gated. It puts the response in SHARED caches, where one caller's copy is served to the next, so core's contract is args-only — never cookies, auth or headers. A guard is the one thing here that provably reads the request and nothing can inspect what it reads, so public on a guarded read is a definition-time throw. Without public the read is still cached, per client, and the endpoint adds Vary: Cookie.
  • Guards still run, on GET exactly as on POST. A rejection answers its status with Cache-Control: no-store — a failed read is never cacheable.
  • Streams cannot be declared, and saying so is a definition-time throw rather than a silently ignored declaration.
  • POST keeps working for every declared read: the declaration lives on the definition, not on the wire, so a hand-built host with no build transform, an older client, or a service calling by hand all still work.
  • Routing is unchanged — the token travels in both carriers, and an actor another host owns is proxied as usual, with the answering host making the caching promise.
  • No content-type on the GET: it would describe a body that does not exist, and it is a non-safelisted header, so leaving it off is one fewer reason to preflight. Not a promise of no preflight — the routing token header ships by default and triggers one on its own, so a cross-origin caller who needs a genuinely simple GET wants route: 'none' and no custom headers too. Same-origin (the usual case) never preflights either way.
  • A GET puts the actor key and every argument in the URL, where a POST body kept them out of access logs, proxy traces and referrer headers. That is the same log-hygiene concern the hashed routing token exists for, and it now applies to the arguments too, in plaintext. maxAge values are the whole non-negative seconds Cache-Control actually defines — a fractional one is a malformed directive, so it is refused rather than emitted.

Per call, actor(Def, key).with({ get: false }).summary() sends a declared read as a POST instead: no caching, but no arguments in the URL either, and no query-length cap (a long enough query is a 414). The endpoint accepts both carriers for a declared read, so this is a client-side choice.

One-way calls

await actor(Notifier, userId).with({ oneWay: true }).notify(event);

A one-way call resolves as Promise<void> when it is accepted into the target's activation — locally when scheduled, remotely when the transport reply comes back from the receiving host's enqueue — never when the turn completes. Fire-and-forget with backpressure and error accounting, instead of the floating-promise workaround that has neither: awaiting the call still tells you the runtime has taken responsibility for it, without paying for the turn. It works on every proxy — the browser client, the server entry, and ctx.actor(...) — and types narrow to Promise<void> at the call site.

What happens to failures depends on when they happen, and the line is acceptance:

| Failure | When | The caller sees | |---|---|---| | guard veto, unknown type, activation failure, wrong host, auth, unreachable peer, host shutting down | before acceptance | a normal rejection | | the method throws, a deadline expires mid-turn | after acceptance | nothing — dropped, counted as oneWayFailures in metrics() on the host that ran the turn (and a dev-mode console warning) |

The details that follow from "the caller is gone":

  • Ordering holds. The call was enqueued before the promise resolved, so an awaited call issued afterwards runs after it — same turn FIFO as always.
  • A one-way self-call cannot deadlock. Awaited, A → A on a non-reentrant actor is a detected ActorDeadlockError; one-way it is "schedule more work for myself" and simply queues behind the current turn.
  • The caller's deadline race is skipped — there is no caller left to race. The deadline still rides the context and bounds the turn's own nested awaited calls.
  • Streams refuse it (a stream is consumed, not fired-and-forgotten), and a declared read goes out as a POST even under get: true — an ack must never be served from an HTTP cache.
  • On the public wire the proxy sends x-sigx-one-way: 1 and the endpoint answers at acceptance; host-to-host the flag rides the envelope as an additive ow field, no protocol bump.
  • Mixed-version clusters degrade gracefully: an older receiving host ignores the flag and answers at turn completion — the call is still delivered exactly once; the sender just waits longer, and a post-acceptance failure can reach it as a rejection during the rolling deploy.
  • Delivery is at-most-once after the ack, exactly as for normal calls: a connection lost between acceptance and the reply can be retried by the cluster's routing, the same window every unary call already has.

Persistence

  • Default is explicit: only ctx.save() writes — a method that returns success has persisted what it acked.

  • persistence: { mode: 'write-behind', debounceMs } saves automatically after mutating turns. Sharp edge, stated plainly: acked ≠ persisted under write-behind — use it for lossy-tolerant state only. Pending writes flush on deactivation and shutdown.

  • Rich types (Date, Map, Set, BigInt, URL, RegExp, plus your serverPlugin({ types }) handlers) survive storage and the wire — the same @sigx/serialize vocabulary everywhere.

  • migrateState evolves a record whose shape predates this deploy — the answer to "the state: shape changed and the stored records didn't". It runs between the storage read and activation, and only on a load that found a record: never on the state(key) fresh path, never on ctx.clearState(). It also runs before onActivate, which therefore always sees migrated state.

    defineActor({
        type: 'Cart',
        state: () => ({ v: 2, items: [], coupons: [] }),
        migrateState: (stored) => {
            const s = stored as CartV1 | CartV2;
            if ('v' in s) return s;                              // fast path
            return { v: 2, items: s.items ?? [], coupons: [] };  // v1 → v2
        },
        // …
    });

    stored is already codec-revived (per the bullet above — Date/Map are real objects), so unknown means unknown shape, not raw JSON. A second argument carries { raw, key } when the revived view can't tell two stored versions apart: raw is the encoded record as storage holds it.

    Returning the input unchanged is the fast path, and identity is how that's detected — so to migrate, return a new object.

    The migrated shape is written back lazily: it rides the next save the actor would have made anyway, so a read-only activation still issues zero writes and a rolling deploy costs no extra ones. That rule holds in both persistence modes — migrateState never causes a write by itself, so a write-behind actor that is only ever read after a migration doesn't persist it. For a record that would otherwise never be saved at all (and so would be re-migrated on every activation forever), { persist: 'eager', migrate } opts into one CAS write-back at activation; if a peer migrated first, the loser adopts the winner's record rather than failing.

    The trade is stated rather than hidden: a fleet mid-deploy can migrate the same record on several hosts. That's safe because the hook is a pure function of the stored value and every write is etag-CAS'd — first save wins, and the loser either adopts the winner (eager) or gets ActorStateConflictError and re-activates against it (lazy).

    Synchronous, and a throw fails activation with ActorActivationError — the same posture as a throwing onActivate. Corrupt state is loud, and the stored record is never silently reset. Version bookkeeping is your convention: the runtime neither reads nor writes a version field, and this is deliberately not a scheme for versioning an actor's interface across a mixed-version fleet. defineJob does not take migrateState: a job's stored record is the job envelope (status/progress/checkpoint/… with your own state under extra), so a hook over it would hand you a runtime shape you don't own. Migrating the extra half wants its own option, and is not part of this.

  • Providers: memoryStorage() (tests/dev), fileStorage({ dir }) (dev; one cat-able JSON file per actor). Implement ActorStorage (load/save/clear with etags) for real databases.

  • Under Vite, a fileStorage dir inside the project root belongs in server.watch.ignored — actor state is not source, and a save is a temp file plus a rename, which the HMR file reader races and reports as ENOENT … .tmp:

    export default defineConfig({
        server: { watch: { ignored: ['**/.actors/**'] } }
    });

Reentrancy & interleaving

By default an actor is strictly serial (guarantees 2–3) and a call cycle back into it is a detected deadlock (guarantee 5). reentrant widens that, in two steps:

reentrant: 'call-chain'   // alias: true — the v1 behavior
reentrant: 'always'       // full interleaving, per actor
methodReentrancy: { stats: 'always' }   // full interleaving, per method
  • 'call-chain' (true): A → B → A runs inline against your own up-stack turn instead of deadlocking. Unrelated calls still serialize — no foreign interleaving, so state stays turn-consistent.
  • 'always': every call is its own turn, launched immediately — unrelated calls interleave at every await (the Orleans [Reentrant] model). The single-threaded guarantee narrows to what JS itself gives you: no two turns run between awaits, but your state can change across every await — re-read, don't cache, anything another turn may move. In-chain calls complete as concurrent turns (never inline), so a self-cycle cannot deadlock by construction.
  • methodReentrancy marks individual methods 'always' on an otherwise serial (or 'call-chain') actor — the canonical case is a read-only get-style method that must not queue behind a slow write (pairs well with reads:). A mapped method never waits and is never waited for; unlisted methods keep the actor-level behavior, including their mutual exclusion. Keys must name methods: entries; the runtime's own deliveries ($sigx:reminder, $sigx:topic) follow the actor-level setting only. Redundant next to reentrant: 'always', so that combination is refused.

What interleaving changes elsewhere — saves from concurrent turns are single-flighted (last-writer-wins at whole-state granularity; a save resolves once a snapshot at-or-after your mutations is durable), a write-behind flush may capture mid-logical-turn state (it is still a synchronously-consistent frame), and deactivation drains all in-flight turns before onDeactivate. Declarations are validated at the type's first activation, loudly, in every build. Interleaving needs AsyncLocalStorage (per-turn call context): built into Node/Deno/Bun; on Cloudflare Workers it rides the nodejs_compat flag the DO package already requires. Serial actors never touch it.

Timers & reminders

  • ctx.timer(name, cb, { due, period, keepAlive })volatile: ticks run as ordinary turns (coalesced under load) and die with the activation. Timers don't keep an actor alive unless keepAlive: true.
  • ctx.reminders.set(name, { due, period })durable: stored through ActorStorage, fired by the host's scheduler, and they re-activate an idle or restarted actor (onReminder(ctx, name)). Minimum period 60s; coarse resolution ("at or after"); at-most-once per tick.

Streams

Declare server→client streams in the streams: factory as async generators; clients get them as AsyncIterable over NDJSON:

streams: (ctx) => ({
    async *watch() {
        yield* ctx.changes({ initial: true });
    }
})
// client: for await (const s of actor(CartActor, id).watch()) render(s);

Stream bodies are observers, not turns: they run detached from the any turn (a stream that waited on its own actor's next turn while holding the activation would self-deadlock), so they must read ctx.snapshot() / ctx.changes() — never mutate live state. Dev builds warn when a stream body reads live ctx.state. An open stream keeps the activation alive; consumer disconnect runs the generator's finally. ctx.changes() yields a detached snapshot after every mutating turn (bounded buffer, drop-oldest).

  • Use changes({ initial: true }) to seed, not a yield ctx.snapshot() prologue. The prologue subscribes only once the consumer resumes past that first yield, so every mutation in between is lost — and the snapshot it yielded is already stale. { initial: true } queues the current snapshot in the same synchronous call that registers the subscription, leaving no gap.

  • The streams: factory must not touch ctx while constructing the table (its method names are read at definition time); inside generator bodies, anything goes. computed/watch setup belongs in methods:.

  • It runs once per subscription, with a context of its own. That is what lets a disconnect close the feeds a body opened: an async generator parked inside ctx.changes() is suspended at an internal await, where the spec queues return() instead of running it, so the subscription has to be closable from outside the body. Nothing an author writes changes — the factory is a table constructor, not a place for per-activation state.

  • A quiet stream is kept alive at the byte layer. A stream that yields nothing sends nothing, and every intermediary with an idle timeout (ingress at 60 s, cloud load balancers at ~4 min, mobile NATs) closes it; the client then sees a stream that "ended without a done/error terminator". The endpoint therefore emits a {"ping":1} line after 30 s of silence, which the client's reader skips. Tune or disable it with handleActorRequest({ streamPingMs })0 is off.

Reading current state? Prefer useActorState(…, { live: true }) over a hand-written streams: body. It pushes the result of the read you already declared, multiplexes every live read on the page onto one connection, and reconnects on its own. streams: is for a feed that is not a read of current state: a log tail, a progress sequence, an event history.

Topics (actor-to-actor pub/sub)

An actor that changes something often needs to tell N interested actors without knowing who they are. Declare the interest on the subscriber and publish from anywhere on the server:

import { topic } from '@sigx/actors';

export const chatMessages = topic<{ from: string; text: string }>('chat-messages');
// per-room: topic('chat-messages', roomId)

// The subscriber declares its interest — nothing registers, nothing is stored.
export const RoomFeed = defineActor({
    type: 'RoomFeed',
    authorize: [requireSession],
    state: () => ({ recent: [] as { from: string; text: string }[] }),
    methods: (ctx) => ({
        async recent() {
            return ctx.snapshot().recent;
        }
    }),
    subscriptions: {
        // subscriber key = topic key, so RoomFeed/room-1 gets room-1's events
        'chat-messages': async (ctx, event) => {
            ctx.state.recent.push(event.payload as { from: string; text: string });
            await ctx.save();
        }
    }
});

// Publish from another actor's turn…
const report = await ctx.publish(topic('chat-messages', ctx.key), { from, text });
// …or from a serverFn / script via the running host:
await publishTopic(topic('chat-messages', roomId), { from, text });

Subscriptions are implicit and declarative: the subscriber set is a pure function of the deploy — every registered type whose subscriptions: names the topic. A publish activates idle subscribers exactly the way a reminder delivery does, and each delivery is an ordinary dispatch of the reserved $sigx:topic method through placement, so a subscriber owned by another host is reached over the internal transport (HMAC, deadlines, branded errors — all of it) with no topic-specific wire machinery. The cost model is S dispatches per publish, S = subscribing types, not activations.

Delivery is best-effort, at-most-once, and settled. publish() resolves when every subscriber's handler turn has settled and reports what happened:

const { subscribers, delivered, failures } = await ctx.publish(chatMessages, msg);
// failures: [{ type, key, message, kind? }] — a throwing handler, a dead
// host, a detected deadlock. The publisher NEVER throws for a subscriber.

Nothing is persisted or retried; a subscriber that was down missed the event. Backpressure is intrinsic — the publisher awaits the turns, bounded by its call deadline. FIFO holds per publisher→subscriber pair only when the publisher awaits its publishes sequentially; concurrent publishes have no relative order.

The details worth knowing:

  • Key mapping. An entry may be { key: (topicKey) => subscriberKey, handle }key: () => 'aggregate' makes one singleton receive every key's events. The default is identity: topic key = subscriber key.
  • Cycles are deadlocks, not hangs. ctx.publish carries the publishing turn's call chain, so a subscription that dispatches back into a non-reentrant publisher fails that delivery with kind: 'deadlock' in the report — the publisher is awaiting the fan-out, so an undetected cycle could never complete. reentrant: true delivers inline instead; reentrant: 'always' delivers as a concurrent turn of the publisher.
  • Handlers are turns. They mutate state and ctx.save() like any method; a throwing handler fails only its own delivery and does not fault the activation. Handlers are not wire-callable and never appear on the client.
  • Pages observe topics through a projection. A subscriber actor folds events into state; the page reads it with useActorState(RoomFeed, roomId, 'recent', { live: true }) — the existing live channel pushes after every handler turn. No new wire.
  • Rolling deploys skew the subscriber set. A host publishes to the subscribers its registry declares, so a newly-added subscribing type misses publishes from not-yet-rolled hosts until the deploy completes — consistent with best-effort delivery.
  • Hot topics pin subscribers active: every delivery is activity, so a busy topic resets its subscribers' idle clocks (idleAfterMs).

Tasks (long-running operations)

A method call holds the activation until it settles — fine for milliseconds, wrong for a sync job or an AI workflow run. Declare that kind of work in the tasks: factory and start it with ctx.tasks.start(name, input?): the body runs detached, outside any turn, so ordinary reads, streams and watches keep answering while it works.

const Sync = defineActor({
    type: 'Sync',
    allowAnonymous: true,
    state: () => ({ done: 0, total: 0, phase: 'idle' as string }),
    methods: (ctx) => ({
        begin: (total: number) => ctx.tasks.start('run', total),
        status: () => ctx.snapshot(),
        stop: () => ctx.tasks.cancel('run')
    }),
    tasks: (ctx) => ({
        async run(total: number) {
            await ctx.turn((c) => { c.state.phase = 'running'; c.state.total = total; });
            for (let i = 0; i < total; i++) {
                ctx.abortSignal.throwIfAborted();
                await syncOne(i, { signal: ctx.abortSignal });
                await ctx.turn((c) => { c.state.done = i + 1; });
            }
            await ctx.turn(async (c) => { c.state.phase = 'done'; await c.save(); });
        }
    })
});

The rules, and why they hold the actor model together:

  • State only through ctx.turn(fn). A task body is detached, so it gets no state/save()turn() enqueues fn as one ordinary serialized turn with the full context. Every mutation stays race-free, and everything downstream of a turn (change feeds, watches, write-behind) works unmodified. Reads in the body use ctx.snapshot() / ctx.changes().
  • ctx.abortSignal in a task is the RUN's signal. It fires on ctx.tasks.cancel(name) (reason 'cancelled') and on deactivation for any reason (reason: the DeactivationReason) — before the turn drain. Deactivation gives signalled tasks a bounded grace (taskGraceMs, default 10s) with turns still open, so a winding-down task can run one final turn() checkpoint.
  • cancel is a request, not a join. It aborts and returns; the run leaves ctx.tasks.list() when its body settles. (Awaiting settlement from a method turn would deadlock with the task's own wind-down turn().)
  • A running task keeps the actor alive — the idle sweeper skips it, like an open stream.
  • start is single-flight per name and resolves when the body is launched, not finished. A task that throws is terminal — no automatic retry; that policy belongs to the layer above.
  • No wire surface. Start/cancel/status go through your own methods, so your guard chain governs them like any other call.

Crash-resume is built in. start() resolves only after the run is durably recorded: a ledger entry in the reserved $sigx:tasks storage record plus a liveness reminder armed under the same name. From then on:

  • A run interrupted by deactivation (any reason but cancel) keeps its entry — the next activation of the actor restarts it, with TaskInfo.restarts bumped and the original input replayed through the state codec. Completion, a throw, and cancel all remove the entry (a thrown task is terminal; crash ≠ throw), and an empty ledger disarms the reminder.
  • The reminder is the crash driver: when a host dies, the reminder shards are re-owned by the surviving hosts, the next tick delivers through placement, and the actor — tasks and all — re-activates wherever the cluster puts it, within roughly 60–90s. No client call needed.
  • The contract is at-least-once: the runtime resumes the function; your code resumes the work from its own checkpointed state (the ctx.turn() + save() pattern above — resume by reading how far the last checkpoint got). A run that completes in the same instant its host stops may restart once more; make the last step idempotent or gate it on state.
  • On the Cloudflare Durable Object backend this degrades gracefully: the ledger lives in the DO's own storage and the liveness reminder maps onto its alarm; a fiber does not survive eviction, so a task there is checkpoint-and-resume with short gaps rather than one continuous run — the same at-least-once contract, checkpoint aggressively.

Jobs (@sigx/actors/job)

defineJob is the packaged experience on top of tasks: — one durable long-running operation per actor, with the state machine, progress, checkpoints and client surface already decided. Start a job from a request handler and return immediately; check on it from anywhere in the cluster.

import { defineJob } from '@sigx/actors/job';

export const SecuritySync = defineJob({
    type: 'SecuritySync',
    authorize: [isAdmin],
    maxAttempts: 3,          // crash-resume attempts before 'failed'
    retainMs: 86_400_000,    // keep the terminal record a day, then forget
    run: async (job, input: { providerId: string }) => {
        const users = await loadUsers(input.providerId, { signal: job.signal });
        const from = (job.resumedFrom as { cursor: number } | undefined)?.cursor ?? 0;
        for (let i = from; i < users.length; i++) {
            job.signal.throwIfAborted();
            await syncOne(users[i]);
            await job.progress({ done: i + 1, total: users.length });
            if (i % 100 === 0) await job.checkpoint({ cursor: i + 1 });
        }
        return { synced: users.length };
    }
});

// A request handler — returns immediately, the job runs on the cluster:
const runId = crypto.randomUUID();
await actor(SecuritySync, runId).start({ providerId });
// Later, from anywhere:
await actor(SecuritySync, runId).status();   // JobInfo: status/progress/attempts
await actor(SecuritySync, runId).cancel();   // marked immediately, run aborted
await actor(SecuritySync, runId).result();   // the return value, once completed
for await (const info of actor(SecuritySync, runId).watch()) render(info);

What the layer decides for you:

  • State machine: pending → running → (paused ⇄ running) → completed | failed | cancelled. status()/watch() return JobInfo — never the checkpoint (private) or the result (fetched once via result()).
  • One actor per run — key = your run id. The directory's single-activation guarantee is the "exactly one runner" guarantee.
  • start is idempotent under retry: a non-pending job returns its current info and never restarts.
  • Crash-resume counts, pause-resume is free: a crash-resumed run arrives with job.attempt bumped and job.resumedFrom set to the last checkpoint; past maxAttempts the job is marked failed. resume(data) on a paused job re-runs with job.resumeData and no attempt cost.
  • pause parks durably: return job.pause(checkpoint) writes the checkpoint, marks paused, and releases the task — the actor idles at zero cost until resume(). For a timeout, arm job.reminders before pausing and handle it in onReminder(control, name)control.resume() / control.cancel() are internal, so no self-dispatch deadlock.
  • Progress rides the change feed, not storage: job.progress() (and job.update() for your own state: extra fields) mutate state in a turn so watch() pushes them live, but nothing is persisted per tick — after a crash, progress honestly regresses to the last checkpoint.
  • retainMs keeps the terminal record around for late result() readers, then a one-shot reminder clears the state and deactivates; discard() does it on demand.

A singleton queue-worker (strict ordering, bounded concurrency), a cron-on-reminders scheduler, and the Cloudflare DO posture are recipes, not API — see docs/job-recipes.md.

Stateless workers (defineWorker)

Everything above assumes an actor is somebody — one identity, one activation, turns in order. Pure compute (validation, transformation, fan-out work) has none of that: two calls to the same worker have no shared state to protect, so serializing them behind one serialization is a bottleneck the semantics never asked for. defineWorker declares a type whose activations are interchangeable:

import { defineWorker } from '@sigx/actors';

export const Resize = defineWorker({
    type: 'Resize',
    authorize: [requireUser],
    maxLocal: 8,             // pool cap; default: hardwareConcurrency (≤16)
    methods: () => ({
        async run(image: Uint8Array, width: number) {
            return transform(image, width);
        }
    })
});

await actor(Resize, 'any').run(img, 800);   // callers look exactly the same

The contract, stated loudly because it is the whole point:

  • Two calls to the same key may run concurrently, on different pool members. The host keeps up to maxLocal activations per (type, key), spun up under load, and each dispatch rides the member with the fewest queued turns. ctx.key is still the key the caller addressed — it just no longer names a single runner.
  • Always local, zero directory traffic. A worker activates on whichever host (or Cloudflare isolate) received the call: no directory claim, no lookup, no routing, no 421 redirect — and nothing for a cluster to fence, migrate or rebalance. (Both invariants are gated exactly in CI: directory_ops == 0, pool ≤ cap.)
  • No identity, so no identity-bound surface. state, persistence, reminders, tasks:, subscriptions:, placement and reentrant do not exist on WorkerOptions — the option is a compile error, and ctx.state / ctx.save() etc. are typed away (WorkerContext) and throw if reached through a cast.
  • What remains: authorization (authorize/methodAuthorize/allowAnonymous, same build gate), reads: (a pure read is the ideal cacheable GET), streams: (pure generators — an open stream pins its member against the sweep), onActivate/onDeactivate for per-member warm-up/teardown (load a model once per member, close it on the way out), and ctx.timer / ctx.actor / ctx.publish.
  • Pool members idle-collect individually after idleAfterMs — a quiet worker shrinks back to zero footprint.
  • A same-key self-call is a deadlock, deterministically. reentrant does not exist for workers, so ctx.actor(Self, ctx.key) throws ActorDeadlockError rather than working only when the pool happens to have a free member. A different key is a different pool and fine.
  • Watches are refused — a watch is a state-change feed and a worker has no state.

Workers live in *.actor.ts files like every other definition (not *.worker.ts — that suffix belongs to Vite's Web-Worker convention), and the build swaps them for the same wire client, so a browser can call one directly. In an app, app.defineWorker is the plugin-typed twin, exactly like app.defineActor.

Lifecycle

  • onActivate(ctx) / onDeactivate(ctx, reason) hooks; an onActivate throw fails all parked callers and forgets the activation (nothing is remembered — the next call retries from scratch). migrateState runs before both, between the storage load and activation — see Persistence.
  • Idle actors deactivate after idleAfterMs (default 20 min; per-actor override). ctx.deactivate(): finish the queue, then go.
  • defaults.maxActivations (default 0 = unlimited) is a soft cap: when the sweep finds more active than this, it deactivates the least-recently-used idle, unheld ones with reason 'capacity' — LRU pressure relief before memory pressure does it for us. Busy, queued, or kept-alive activations are never shed, so a genuinely loaded host may sit over the cap until it quiets; a shed actor re-activates on its next call, state intact. Rides the sweeper (sweepIntervalMs > 0), and metrics().activations.byReason.capacity says how often it fires.
  • host.stop() drains every activation, flushes persistence, ends open streams and rejects new external calls. attachSignalHandlers(host, { server, onStopBegin }) wires it to SIGTERM/SIGINT and drains the HTTP edge, which is the other half of a graceful shutdown. Both options matter: onStopBegin is what retires keep-alive sockets gracefully, while server alone only closes the listener at the end — see Clustering for the full recipe and why the order is what it is. A failed drain exits non-zero rather than vanishing as a clean stop.
  • Deactivation fires ctx.abortSignal first — before draining the any turn — so a parked turn or a running task can observe it and wind down inside the drain window instead of holding it hostage.
  • Calls that arrive during deactivation wait and land on a fresh activation.
  • External calls get a deadline (callTimeoutMs, default 30s) — on expiry the caller gets ActorCallTimeoutError; the turn itself always runs to completion. Enforcement is never early but coarse when far: a deadline ≥ 10 s away shares one registry tick and may fire up to ~2 s late, while a short budget (a wire hop arriving nearly spent) gets an exact timer.

Seeing which actors are live

host.stats() gives you the counts. host.activations() gives you the actors themselves — bounded, sorted, and safe to poll:

host.activations({ sortBy: 'queued', limit: 20 });
// [{ type: 'Cart', key: 'user-42', queued: 7, ageMs: 812_004,
//    idleMs: 0, keptAlive: false, tasks: 0 }, …]

tasks is the running detached-task count — the actors hosting long-running work, and the usual reason a keptAlive row is being skipped by the idle sweeper. sigx actors top shows it as a TASKS column.

sortBy picks which end you care about: 'queued' (default) is the hot actors, 'age' the long-lived ones, 'idle' the next sweep's candidates. type filters. Ties break on the actor id so the order is stable between polls — a table that reshuffles equal rows at 1 Hz is unreadable.

It walks the directory, so it costs O(activations) and allocates a record per candidate; limit defaults to 100 because this is a "top N" view and a host can hold millions. Poll it at human rates, not per request.

ageMs is monotonic and idleMs is wall-clock — deliberately different clocks. Age is a duration and must survive an NTP step; idle is compared against idleAfterMs by the sweeper, which genuinely wants wall time.

stats() also reports transitional: { activating, deactivating }. Those slots have no activation to read yet, so they are not in activations and never were in the counts — which meant a host in the middle of an activation storm read as idle, at exactly the moment you were looking at it.

Metrics

metrics() is a plugin that counts what the host is doing. Pull-based: no exporter, no push pipeline, no metrics-library dependency — you read snapshot() whenever you want, from a route, a health check, or a test.

import { defineActorApp, memoryStorage, metrics } from '@sigx/actors/host';

const m = metrics();
const app = defineActorApp({ actors, storage: memoryStorage() }).use(m);
const host = await app.start();

m.snapshot();
// {
//   windowMs: 60_000,
//   calls:       { total: 12_400, failed: 3, streams: 2 },
//   latencyMs:   { count, minMs, maxMs, meanMs, p50Ms, p90Ms, p99Ms },
//   queueMs:     { ... },   // waiting for a turn
//   turnMs:      { ... },   // holding the activation
//   byType:      { Cart: { calls, failed, latencyMs, queueMs, turnMs } },
//   byMethod:    { 'Cart#checkout': { calls, failed, latencyMs, queueMs, turnMs } },
//   errors:      { byKind: { 'call-timeout': 3 },
//                  recent: [{ at, type, method, kind, message }] },
//   activations: { created: 91, destroyed: 88, byReason: { idle: 88 } },
//   storage:     { loads, saves, clears, conflicts, latencyMs },
//   gauges:      { activations: 3, queued: 0, perType: { Cart: 3 } }
// }

Read queueMs against turnMs first. They are the two halves of every call's latency and they mean opposite things:

| | meaning | fix | |---|---|---| | high turnMs | the method itself is slow | move I/O out of the turn, split the method | | high queueMs | the actor is a hotspot — callers are waiting behind each other | shard the key, or reduce traffic to it |

A dispatch middleware only ever sees the sum, which is why queueMs is the number people usually lack. metrics() gets it from observeTurns, the one seam this needed (see below).

conflicts is worth an alert rather than a graph: each one is an etag mismatch that discarded an activation.

Per method, and why calls fail

byType tells you a type is slow; it never tells you which of its methods is. byMethod carries the same five numbers keyed Type#method, and the queue/turn split is most useful there — within one type, a hot actor and one slow method look identical until you separate the methods.

m.snapshot().byMethod['Cart#checkout'];
// { calls: 4_100, failed: 2, latencyMs, queueMs, turnMs }

errors.byKind counts ActorErrorKind'call-timeout', 'wrong-host', 'state-conflict', 'unreachable', 'deadlock', 'activation', 'method-not-found', 'host-shutdown' — plus '(unknown)' for anything an actor method threw itself. calls.failed says a host is failing; this says what is wrong with it, and the two are very different questions: a rising 'unreachable' is a network or membership problem, a rising '(unknown)' is your code.

errors.recent keeps the last few failures (default 32, recentErrors: 0 to disable) as { at, type, method, kind, message }. Message only — no args and no state, because this is read over an HTTP endpoint and a failing call's arguments are exactly where the secrets are.

Both breakdowns are capped like byType, overflowing into '(other)'. Methods get their own cap (maxMethods, default 256) rather than sharing maxTypes, because methods multiply types: 64 types under the type cap would leave under one method each and the breakdown would be almost entirely '(other)' on a perfectly ordinary app.

Turning it on and off

Collection can be switched at runtime, and switching it off genuinely stops paying for it:

const m = metrics({ enabled: false });   // wired in, collecting nothing
m.enable();                              // ...investigate...
m.disable();                             // back to ~free; counters keep their values
m.enabled;                               // boolean

disable() drops the turn subscription rather than returning early inside it. That distinction is the whole point: the runtime only takes the per-turn timestamps while an observer is attached, so an inert-but-attached observer would keep paying for the larger half of the cost. Counters freeze at their current values — use reset() to clear them.

The intended shape is to leave metrics({ enabled: false }) wired into production and switch it on when you need to look.

What it costs

Measured on a noop dispatch — the cheapest call there is (~0.5µs) — with each configuration in its own process, median of 9 runs:

| | throughput | vs no plugin | |---|---:|---| | no plugin | 2.05 M ops/s | — | | an inert plugin (the control) | 2.08 M ops/s | ~0 | | metrics({ enabled: false }) | 2.05 M ops/s | ~0 | | metrics({ histograms: false }) | 1.88 M ops/s | −8% | | metrics() | 1.43 M ops/s | −30% |

Three things to read off that. Disabled is indistinguishable from not having the plugin at all — the residual branch in the dispatch wrapper is below measurement noise. Not attaching it is free: with no observer the dispatch path is unchanged, verified by comparing against a baseline of the previous commit with the benchmark suite (benchmarks/). And plugins themselves cost nothing — the inert control says so, which is what makes the other rows attributable to metrics rather than to the plugin machinery.

Read the −30% in absolute terms before it alarms you: full metrics adds ~200ns per call. It looks like a third of throughput only because the measured call does nothing at all — for an actor whose turn takes 100µs it is under 0.25%. Durations use performance.now() rather than the wall clock, which costs one extra read per observed turn and buys immunity to NTP or a VM host stepping the clock backwards mid-turn.

The per-method breakdown is ~3.5% of that per dispatch — it was −28% before byMethod existed. maxMethods: 0 gets the old cost back; what it buys is per-method call counts, failure counts and the queue/turn split, which is usually the trade you want.

That 3.5% was measured by loading both builds into one process and interleaving their rounds, because separate processes could not resolve it: the machine drifts ~10% between runs, larger than the effect, and the inert controls disagreed by more than the thing being measured (see benchmarks/README.md, "Trusting the numbers"). The metrics() row's absolute ops/s above is therefore the original machine's figure rescaled by that ratio rather than re-measured — the percentages are what was observed, the ops/s is derived.

observeTurns

The seam behind the split, available to any plugin:

registry.observeTurns((ref, method, queuedMs, elapsedMs, failed) => { ... });

Fires for dispatched turns only — the ones a caller waited for, including reminder delivery. Volatile ctx.timer ticks and write-behind flushes are excluded: they have no caller, and their cost is already visible as queue wait on whatever was behind them. Call-chain-reentrant ctx.actor calls run inline against the caller's turn and are excluded too. Interleaved turns (reentrant: 'always' / methodReentrancy) fire once per turn like any other, but launch immediately: queuedMs