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/emitis keyed byRuleEngineEvent. - Priority hooks —
hook(event, { priority, listener }); sorted descending by priority on register; finite-priority enforcement raisesRULES.HOOK_INVALID. - Awaitable
waitFor— resolves with the payload on first match; rejects withRULES.EVENT_TIMEOUTpasttimeoutMsorRULES.EVENT_ABORTEDwhen the suppliedAbortSignalfires (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/emitSyncincrementsrules.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.clockdefaults to() => Date.now().options.historySizedefaults toDEFAULT_EVENT_HISTORY = 1000;<= 0raisesRULES.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 raisesRULES.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 withRULES.EVENT_TIMEOUTorRULES.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/hooksnapshots their bucket via[...bucket]before iterating so that unsubscribing during emission does not skip subsequent callbacks.waitForregisters 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 localcleanup: (() => void)[]queue.historyBufferis a plain array trimmed withshift(). 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-
emitand 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. emitSyncdoes not return a value (noPromiseto await), but callback exceptions are NOT silently swallowed — the runtime will surface them as unhandled promise rejections through thevoiddiscard. Callers that need synchronous error handling should useemitandawaitit.