Skip to content
GitHubXDiscord

Runtime

A Runtime is the single object that owns everything that’s “alive” in Triggery: the trigger registry, the event index, the schedulers, the middleware chain, the inspector ring buffer, and the in-flight controllers for async runs.

You usually create exactly one runtime per app. You can create more — for tests, for micro-frontends, for multi-tenant isolation — and each one is independent.

import { createRuntime } from '@triggery/core';

const runtime = createRuntime({
  middleware:          [tracing, analytics],
  maxCascadeDepth:     3,
  inspectorBufferSize: 50,
  inspector:           true,               // or { dev: true, prod: false }
});

Every option is optional. The defaults are tuned for the common case:

OptionDefaultWhat it does
middleware[]Array of Middleware objects applied to every trigger.
maxCascadeDepth3How deep cross-trigger fanout can recurse before the runtime emits onCascade({ kind: 'overflow' }).
inspectorBufferSize50Ring buffer size for the inspector. Ignored when the inspector is disabled.
inspectorundefined (auto: DEV on, PROD off)true / false / { dev?, prod? }. Controls the per-run snapshot allocation and subscribe() payloads.

Inspector defaults are worth highlighting: DEV gets snapshots, PROD doesn’t. With it off, the hot path skips the per-run snapshot allocation entirely — roughly 30-40% extra dispatch throughput. Bridges like @triggery/devtools-redux and the React useInspectHistory hook detect a disabled inspector and surface a one-time DEV warning if you forgot to turn it on.

createRuntime returns a Runtime object:

type Runtime = {
  readonly id:                string;
  readonly inspectorEnabled:  boolean;

  fire(eventName: string, payload?: unknown):     void;
  fireSync(eventName: string, payload?: unknown): void;

  subscribe(listener: (snap) => void):  RegistrationToken;
  getInspectorBuffer():                 readonly TriggerInspectSnapshot[];
  getTrigger(triggerId: string):        Trigger | undefined;
  graph():                              RuntimeGraph;
  dispose():                            void;

  // internal-public: bindings call these, not application code
  registerTrigger(config):              RegistrationToken;
  registerCondition(triggerId, name, getter, options?): RegistrationToken;
  registerAction(triggerId, name, handler, options?):   RegistrationToken;
};

Most fields are observational; the ones you’ll reach for from app code are fire, fireSync, subscribe, getInspectorBuffer, and dispose.

If you don’t pass a runtime explicitly anywhere, Triggery uses a lazy global singleton:

import { getDefaultRuntime, setDefaultRuntime, createRuntime } from '@triggery/core';

// Lazily created on first access.
getDefaultRuntime().fire('app:ready');

// Replace the default — e.g. in a setup file.
setDefaultRuntime(createRuntime({ inspector: false }));

The default runtime is what createTrigger(config) registers into when no runtime is passed. It’s also what the framework providers fall back to if you mount components without <TriggerRuntimeProvider> — handy for prototyping, less ideal for tests and SSR.

For apps with a single runtime, ignore the default and pass a runtime explicitly via the provider. For libraries, accept a runtime argument or fall back to getDefaultRuntime() so consumers can override.

Every binding ships a provider component that publishes the runtime to descendant hooks:

src/main.tsx
import { createRuntime } from '@triggery/core';
import { TriggerRuntimeProvider } from '@triggery/react';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';

const runtime = createRuntime();

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <TriggerRuntimeProvider runtime={runtime}>
      <App />
    </TriggerRuntimeProvider>
  </StrictMode>,
);

Hooks resolve the runtime by walking up: the nearest provider wins; if none is mounted, the default runtime is used. That fallback exists so a single missing provider doesn’t break the app — instead you see all your hooks talking to the global default, which is probably what you wanted anyway.

A few situations warrant additional runtimes:

Tests. Each test gets its own runtime so triggers, conditions and actions registered in one test don’t leak into the next. @triggery/testing exposes createTestRuntime() which wraps createRuntime with a fake scheduler suitable for synchronous assertions.

import { createTestRuntime } from '@triggery/testing';

test('shows toast when settings.notifications is true', () => {
  const runtime = createTestRuntime();
  // …mount components / register triggers against this runtime…
  runtime.fireSync('new-message', { /* … */ });
  // …assert
  runtime.dispose();
});

Micro-frontends. Two independently-shipped React apps mounted on the same page should not share a trigger registry by default — they might define the same trigger id with different schemas, or assume different middleware. Each MFE creates its own runtime; the host page decides whether to bridge events between them with a top-level middleware.

Multi-tenant. A SaaS dashboard that runs multiple tenant sandboxes side-by-side gives each sandbox its own runtime. Triggers, conditions and inspector entries stay isolated; the tenant’s debug panel only sees that tenant’s runs.

SSR & RSC. Per-request isolation matters on the server. Create a runtime per request, mount the React tree against it, dispose at the end. See Server-side rendering.

Both fire an event through the runtime’s event index. The difference is scheduling:

  • runtime.fire(name, payload) — respects the trigger’s schedule ('microtask' by default). The fire returns immediately; handlers run in a future tick.
  • runtime.fireSync(name, payload) — bypasses the per-trigger scheduler entirely; the handler runs before the call returns.
runtime.fire('new-message',     msg);   // handler runs at next microtask
runtime.fireSync('new-message', msg);   // handler runs before this line returns

Use fire for almost everything. Use fireSync in tests and inside very tight benchmarks where you need to assert a side effect in the same call frame. Production code rarely needs it — the microtask scheduler is cheap and prevents render-loop foot-guns.

subscribe registers a listener that receives every TriggerInspectSnapshot the inspector records:

const token = runtime.subscribe((snapshot) => {
  console.log(snapshot.triggerId, snapshot.status, snapshot.executedActions);
});
// …later
token.unregister();

getInspectorBuffer() returns the most recent N snapshots (inspectorBufferSize), newest-first. Both methods are no-ops when the inspector is disabled.

This is the substrate every devtools panel sits on. The React useInspectHistory(limit) hook is a thin layer on top — it subscribes, slices the buffer, and re-renders. See Inspector.

runtime.use(...) is not in the V1 API — middleware is set at construction time and immutable for the runtime’s lifetime. The reason is performance: the dispatch hot path caches hasMiddleware and trackTiming flags once, then skips entire branches when no middleware is attached. Mutating middleware mid-flight would invalidate that.

For dynamic enable/disable, write a middleware with its own toggle:

const tracing: Middleware = {
  name: 'tracing',
  onActionEnd(ctx) {
    if (!tracingEnabled) return;
    metrics.histogram('action.duration', ctx.durationMs, { actionName: ctx.actionName });
  },
};

const runtime = createRuntime({ middleware: [tracing] });

Late attachment lives on the V1.1 roadmap once the runtime cost is benchmarked against your apps. See Middleware for the full hook list (onFire, onBeforeMatch, onSkip, onActionStart, onActionEnd, onError, onCascade).

Cascading is when an action or handler fires another event. Two hazards exist:

  1. Unbounded depthA → B → C → D → … spirals. The runtime stops at maxCascadeDepth (default 3) and emits onCascade({ kind: 'overflow' }).
  2. CyclesA → B → A. The runtime walks the parent-dispatch chain (a linked list, not a set, so it’s allocation-free) and skips the offending fire with onCascade({ kind: 'cycle' }).

Adjust the depth limit when your domain genuinely needs it:

const runtime = createRuntime({ maxCascadeDepth: 5 });

But: think twice before raising it. Deep cascades are almost always a sign that the rule should be split into a smaller graph, not a deeper one.

See Cascade.

runtime.graph() returns a JSON-friendly snapshot of the registered triggers and the event index — stable shape, safe to log or send over postMessage:

const g = runtime.graph();
// g.triggers:   [{ id, events, required, schedule, concurrency, enabled, scope }, …]
// g.eventIndex: { 'new-message': ['message-received', 'analytics:new-message'], … }

The CLI’s triggery graph command uses this to render a dependency diagram of which events feed which triggers across the app. The devtools panel uses it to render the registry tree.

runtime.dispose() is a graceful shutdown:

  • Every in-flight async run is aborted via its AbortController.
  • Every pending debounce / defer timer is cleared.
  • The trigger registry is emptied; the event index is cleared.
  • The inspector buffer is cleared.

Existing references to Trigger objects continue to exist as JS values — they’re just no longer attached to a runtime. Calling .fire(...) after dispose() is a no-op (the event index has no matches).

For SSR per-request isolation, dispose() at the end of request handling is the right call. For tests, do it in afterEach. For long-running apps, you’ll never call it.

Dev mode. The inspector is on, last-mount-wins collisions emit a one-time warning per (triggerId, name), scope mismatches emit a one-time warning. None of these are errors — they’re signals you read in DevTools.

React StrictMode. The mount → unmount → mount cycle is safe. Registration is on a stack; unmounting pops the latest registration; the next mount pushes again. The runtime treats each push as the new top of stack.

SSR. Create one runtime per request, mount your tree against it, await any in-flight async runs (if you have them), dispose at the end. See Server-side rendering for the streaming / hydration story.

Test mode. Combine createTestRuntime (from @triggery/testing) with fireSync for fully synchronous assertions. The fake scheduler advances on demand for microtask-based assertions. See Unit testing.