Files
skills/docs/foundation/flow/rules/src/events/event-bus.md
2026-05-29 08:20:56 +01:00

5.6 KiB

EventBus module — @suaveplan/rules/events

(no submodules)

Purpose

Pub/sub bus used by the engine and downstream consumers (Hono middleware, audit logs, observability sinks) to broadcast lifecycle events without coupling. Topics are drawn from the typed RuleEngineEvent union — adding a new topic is a type change rather than a string literal. The bus supports plain on / off / once subscriptions, priority-ordered hook registrations (higher fires first), a bounded ring-buffer history (default 1000 entries), an awaitable waitFor(event, { timeoutMs?, signal? }) API, and OTel emission counting every payload by type.

A sibling HookManager class scopes the priority-hook surface to a single topic so callers can manage hooks without holding the parent bus — useful for engine plugins that own a handful of lifecycle hooks but should not have a back-channel to the global event stream.

Features

  • Typed topic API — every on / emit is keyed by RuleEngineEvent.
  • Priority hooks — hook(event, { priority, listener }); sorted descending by priority on register; finite-priority enforcement raises RULES.HOOK_INVALID.
  • Awaitable waitFor — resolves with the payload on first match; rejects with RULES.EVENT_TIMEOUT past timeoutMs or RULES.EVENT_ABORTED when the supplied AbortSignal fires (including pre-aborted).
  • Bounded history — getHistory() returns the last N payloads in arrival order; clearHistory() drops the buffer.
  • Injectable Clock — bus.now() for callers that timestamp their own payloads.
  • OTel counter — every emit / emitSync increments rules.event.emitted_total{type}.
  • HookManager — standalone priority-hook bag; register / remove / invoke / size.

Usage: Basic Example

import { createEventBus } from "@suaveplan/rules/events";

const bus = createEventBus();
bus.on("rule.passed", (payload) => {
    console.log(`rule passed at ${payload.timestamp}`);
});
await bus.emit({ type: "rule.passed", timestamp: Date.now() });

Usage: Advanced Example

import { createEventBus, createHookManager } from "@suaveplan/rules/events";

const bus = createEventBus({ historySize: 200, clock: () => Date.now() });

// Priority-ordered hooks — higher fires first
bus.hook("rule.passed", {
    priority: 100,
    listener: async (payload) => audit.append(payload),
});
bus.hook("rule.passed", {
    priority: 10,
    listener: async (payload) => metrics.increment("rule.passed", { type: payload.type }),
});

// Await the next event with a hard 5s ceiling and caller-side abort
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
try {
    const payload = await bus.waitFor("ruleset.completed", {
        timeoutMs: 5000,
        signal: controller.signal,
    });
    console.log("ruleset finished:", payload.timestamp);
} catch (err) {
    console.warn("waitFor failed:", err);
}

// Standalone hook manager — convenient for engine plugin internals
const mgr = createHookManager();
mgr.register({ priority: 1, listener: () => undefined });
await mgr.invoke({ type: "rule.failed", timestamp: bus.now() });

API Reference

class EventBus

  • new EventBus(options?) — options.clock defaults to () => Date.now(). options.historySize defaults to DEFAULT_EVENT_HISTORY = 1000; <= 0 raises RULES.HOOK_INVALID.
  • on(event, listener) → unsubscribe fn.
  • off(event, listener) → boolean.
  • once(event, listener) → unsubscribe fn; auto-removes after first fire.
  • hook(event, { priority, listener, id? }) → unsubscribe fn. Sorted descending; non-finite priority raises RULES.HOOK_INVALID.
  • emit(payload) → Promise<void>. Records history, increments OTel counter, runs hooks first (in priority order), then listeners (in registration order). Awaits every callback.
  • emitSync(payload) — fire-and-forget variant; same ordering but callbacks are not awaited.
  • waitFor(event, options?) → Promise<EventPayload>. Rejects with RULES.EVENT_TIMEOUT or RULES.EVENT_ABORTED.
  • getHistory() / clearHistory() — history-buffer accessors.
  • now() — convenience over the injected clock.

class HookManager

Standalone priority-hook bag scoped to one topic. Methods: register, remove, invoke, size. Same priority + finite-number rules as the bus.

Factory aliases

createEventBus(options?), createHookManager().

Implementation Notes

  • The bus runs hooks before listeners on emit. This lets plugins inspect and (in future) decorate the payload before user-land code observes it.
  • on / hook snapshots their bucket via [...bucket] before iterating so that unsubscribing during emission does not skip subsequent callbacks.
  • waitFor registers a one-shot listener plus optional abort + timeout handlers. Every cleanup path (resolve, reject-timeout, reject-abort, pre-abort) runs every cleanup function exactly once via the local cleanup: (() => void)[] queue.
  • historyBuffer is a plain array trimmed with shift(). Trimming via a ring index would shave a microsecond per insert but adds bookkeeping; the cost is dominated by listener / hook execution in any realistic workload.
  • OTel emission is per-emit and not per-listener, so the counter measures bus activity rather than work done by subscribers. Subscribers that want their own metrics emit via their own meter.
  • emitSync does not return a value (no Promise to await), but callback exceptions are NOT silently swallowed — the runtime will surface them as unhandled promise rejections through the void discard. Callers that need synchronous error handling should use emit and await it.