Configuration
Every actor is created by a factory and an options object. The factory decides the embodiment; the options tune lifecycle, trace level, and error handling. (Tracing/telemetry is not an option — it is a cross-cutting concern configured globally; see Observability below.)
| Factory | Use | Default trace level |
|---|---|---|
makeActor(Top, ctx, port?, options?) | root actor (server or browser) | TraceLevel.DEBUG |
makeChildActor(parent, Top, ctx, port?, options?) | child actor spawned from a handler | TraceLevel.DEBUG |
makeTestActor(Top, ctx, port?, options?) | tests only (ihsm/testing) | TraceLevel.VERBOSE_DEBUG |
Actor options
import { defaultDispatchErrorCallback, defaultTraceWriter, makeActor, Port, TraceLevel } from 'ihsm';
const actor = makeActor(CounterTop, { value: 0 }, {
// Run the initial-state entry chain on creation. Set false to hydrate with restore().
initialize: true,
// PRODUCTION = no tracing overhead · DEBUG = lifecycle lines · VERBOSE_DEBUG = full detail.
traceLevel: TraceLevel.PRODUCTION,
// Sink for trace lines (see the Tracing example).
traceWriter: defaultTraceWriter,
// Invoked when a handler/transition throws and no onError/onUnhandled hook recovers it.
dispatchErrorCallback: defaultDispatchErrorCallback,
// Optional declarative transition table (advanced).
// transitions: myTransitionResolver,
});
Every option is optional. initialize defaults to true; omit port to use a production Port with host timers and crypto.
The port: where every side effect lives
A port is the single boundary between an actor and the impure world — timers, randomness, and
domain I/O. This is the cornerstone of determinism: an actor never calls setTimeout, Date.now,
Math.random, crypto, or fetch directly.
import { Port } from 'ihsm';
const actor = makeActor(Top, ctx); // production: real host timers + crypto
Port(production) delegates to the host:setTimeout/setInterval,crypto.randomUUID,crypto.getRandomValues, andMath.random.TestPort(tests) replaces all of it with a virtual clock and scripted randomness, so a test can simulate days of time in microseconds with no flakiness.- A domain port adds your own I/O methods (
request,connect,attempt, …) by extendingPortand declaring aportfacet in the config. See the Time & I/O examples.
Trace levels
import { TraceLevel } from 'ihsm';
TraceLevel.PRODUCTION; // no per-event tracing — lowest overhead
TraceLevel.DEBUG; // dispatch + transition lifecycle lines
TraceLevel.VERBOSE_DEBUG; // every entry/exit/handler step (and self-call deadlock detection)
Trace level is mutable at runtime: actor.hsm.traceLevel = TraceLevel.PRODUCTION.
Error handling
A throw inside a handler is routed, in order:
- to an
onError(err)hook on the active state (or an ancestor), if present; - otherwise to the actor's
dispatchErrorCallback.
An event with no handler anywhere up the chain triggers onUnhandled(err) (or the callback). See
the Error recovery example.
const actor = makeActor(Top, ctx, {
dispatchErrorCallback: (hsm, err) => {
console.error(`[${hsm.currentStateName}]`, err);
},
});
Observability (console & OpenTelemetry)
Observability is a cross-cutting concern: a globally enforced protocol, not a constructor
argument. ihsm exposes a generic Instrumentation callback surface and emits neutral signals
(onMacrostepBegin, onMicrostepBegin, onError, onLog, …); you install a collector once
with registerCollector() and every actor created afterwards is observed automatically. Actor and
handler code never reference tracing. If you register no collector, there is zero cost.
For users who do not want OpenTelemetry, ihsm ships a basic console output built on that same
surface:
import { createConsoleInstrumentation, makeActor, Port, registerCollector } from 'ihsm';
// Register once, globally — every actor spawned afterwards prints its signals.
const unregister = registerCollector(createConsoleInstrumentation());
// options: createConsoleInstrumentation({ prefix: 'demo', microsteps: false, logs: false, write: (line) => myLogger(line) })
const actor = makeActor(CounterTop, { value: 0 }, { initialize: true });
await actor.hsm.sync();
actor.notify.increment();
await actor.hsm.sync();
// [ihsm] 1f2e3d4a ▶ macrostep m1 external:increment @Running
// [ihsm] · #1 increment [notifications/default] @Running
// [ihsm] ■ macrostep m1 → Running (1 step(s), ok)
// Later: stop tracing new actors (already-running actors keep their snapshot).
unregister();
The active collector is snapshotted at spawn: a collector registered before an actor is created
observes it (and its children), while register/unregister afterwards does not retroactively change
already-running actors. For full OpenTelemetry traces and logs (spans, W3C propagation, OTLP
export), install the optional @ihsm/otel package, whose startOtelNode() / startOtelBrowser()
register a collector for you — nothing about OTEL lives in the core runtime.
Deterministic identity (DST)
Each actor receives a stable UUID derived from a run seed and its hierarchical path. For deterministic simulation testing, pin the seed before creating actors — identical seed ⇒ identical identities and reproducible runs.
import { configureRunSeed, getRunSeed } from 'ihsm';
configureRunSeed('replay-2026-06-19'); // same seed ⇒ same actor UUIDs every run
getRunSeed(); // 'replay-2026-06-19'
You can also set IHSM_RUN_SEED in the environment; when unset, a random seed is generated per
process. The seed only affects identity/telemetry correlation — never an actor's behaviour.