@diegesis/events
v0.2.1
Published
Mature, schema-validated event & hook bus for Diegesis — cross-VTT interoperability and browser-extension friendly.
Maintainers
Readme
@diegesis/events
Mature, schema-validated event & hook bus for Diegesis — cross-VTT interoperability and browser-extension friendly.
The foundation of the Diegesis hook system: typed, validated events and extensible hook pipelines that stay drop-in simple and friendly to browser extensions and injected scripts.
- Events (notifications, fire-and-forget) — powered by
mitt: typed, wildcard-capable. - Hooks (interception / pipelines) — powered by
tapable: eight strategies fromsynctoasyncParallelBail. - Validation — every event/hook payload is validated with Valibot schemas (
v.GenericSchemaaccepted directly). - IDs — UUID v7 (time-ordered, sortable) for every event/correlation id.
- Bridge — exposes the bus to
globalThis.__DIEGESIS_EVENTS__and dispatches DOMCustomEvents, so content scripts and extensions can participate with plainaddEventListener. - Broadcast — optional cross-tab propagation via
BroadcastChannel. - Robust — a throwing handler never crashes the bus; errors are isolated and observable.
- SSR-safe (no
window/documentaccess at import time), ESM + CJS, tree-shakeable.
Installation
npm install @diegesis/eventsQuick start
import * as v from 'valibot';
import { createBus, defineContract } from '@diegesis/events';
const contract = defineContract({
namespace: 'dice',
events: {
rolled: v.object({ result: v.number() }),
},
hooks: {
beforeRoll: { strategy: 'syncWaterfall', schema: v.object({ sides: v.number() }) },
},
});
const bus = createBus(contract);
bus.on('rolled', (payload, meta) => {
console.log('rolled', payload.result, meta.id); // meta.id is a UUID v7
});
bus.tap('beforeRoll', 'physics', (ctx) => ctx); // waterfall taps return the (transformed) value
const result = bus.call('beforeRoll', { sides: 20 }); // → { sides: 20 }
bus.emit('rolled', { result: 42 }); // validated against the schemaFreeform usage without a contract also works:
import { createBus } from '@diegesis/events';
const bus = createBus();
const off = bus.on('scene:ready', (payload, meta) => {
console.log(meta.id, meta.timestamp);
});
bus.emit('scene:ready', { id: 'forest' });
off();API
createBus(contract?, options?) / new EventBus(contract?, options?)
contract: { namespace?, events?, hooks? } — events maps name → Valibot schema; hooks maps name → { strategy, schema? }. Build typed contracts with defineContract({ namespace, events, hooks }).
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| validate | 'throw' \| 'warn' \| 'off' | 'throw' | Payload validation behaviour. |
| unknownEvents | 'allow' \| 'reject' | 'allow' | Reject emits for events not in the contract. |
| bridge | boolean \| BridgeOptions | false | Expose the bus to extensions/scripts. |
| broadcast | boolean \| BroadcastOptions | false | Cross-tab BroadcastChannel. |
| debug | boolean \| ((...a) => void) | false | Debug logging. |
| sourceId | string | uuid v7 | Unique instance id used for broadcast de-dup. |
Events (notify)
bus.on(name, (payload, meta) => {}); // subscribe → returns unsubscribe fn
bus.once(name, (payload, meta) => {}); // fire once
bus.off(name, handler); // remove a handler
bus.onAny((name, payload, meta) => {}); // observe every event
bus.emit(name, payload); // → EventMeta (contains the uuid v7 id)
bus.onError((err, ctx) => {}); // observe handler/validation errorsemitis synchronous and returns theEventMeta(so you get the eventidfor correlation).- A handler that throws is isolated: it is reported via
onErrorand the remaining handlers still run.
Hooks (pipelines)
Hooks are extensible pipeline points where plugins can intercept, transform, or short-circuit a process.
bus.tap(name, 'plugin-name', (value) => value); // sync tap
bus.tapPromise(name, 'plugin-name', async (value) => {}); // async tap
bus.call(name, value); // invoke a SYNC hook (sync / syncBail / syncWaterfall)
bus.callAsync(name, value); // invoke ANY hook (required for async* strategies)Hook inputs are validated against the hook's schema before any tap runs.
Runtime registration
bus.registerEvent('spawned', v.object({ tokenId: v.string() }));
bus.registerHook('damage', { strategy: 'syncWaterfall', schema: v.object({ amount: v.number() }) });Validation
Validation mode is set per-bus via validate: 'throw' | 'warn' | 'off' (default 'throw').
- Events without a schema and hooks without a
schemaare never validated (freeform-friendly). - On
'throw', invalid payloads raise anEventValidationErrorlisting every issue.
Errors
EventBusError, EventValidationError, UnknownEventError, UnknownHookError, HookError — all carry a typed code.
Hook strategies
| Strategy | Runs | Behaviour |
| ------------------------ | ------ | -------------------------------------------------------------------------- |
| sync | sync | All taps run; return values ignored. |
| syncBail | sync | Stops at the first tap returning non-undefined; returns it. |
| syncWaterfall | sync | Each tap receives the previous return and returns the next value. |
| asyncSeries | async | Taps run sequentially in registration order. |
| asyncSeriesBail | async | Sequential; stops at the first resolved non-undefined. |
| asyncSeriesWaterfall | async | Sequential; each tap receives the previous resolved value. |
| asyncParallel | async | All taps run concurrently. |
| asyncParallelBail | async | Concurrent; resolves with the first non-undefined. |
Browser extensions & external scripts (the bridge)
Enable the bridge to let content scripts, page scripts, and browser extensions talk to the bus using only standard APIs. No build step required for them.
const bus = createBus(contract, { bridge: true });This does two things:
1. A global handle at globalThis.__DIEGESIS_EVENTS__ (constant: GLOBAL_KEY):
// from a content script, page script, or extension
const bus = window.__DIEGESIS_EVENTS__.get('dice');
bus.on('rolled', (payload, meta) => console.log(payload, meta.id));
bus.emit('rolled', { result: 42 }); // validated against the contract schema
bus.events(); // → ['rolled']2. DOM CustomEvents dispatched on document (the most reliable channel
across Chrome content-script isolated worlds) with the namespaced name
${namespace}:${event} and event.detail = { payload, meta }:
// works from any content script / injected script, no SDK needed
document.addEventListener('dice:rolled', (e) => {
console.log(e.detail.payload, e.detail.meta.id);
});The default bridge target is
documentwhen available (for maximum content-script compatibility), falling back toglobalThis. Override withbridge: { target: myTarget }.
External emit/tap calls are validated just like internal ones, so the
bus stays reliable even when untrusted scripts participate.
Cross-tab broadcast
const bus = createBus(contract, { broadcast: true }); // channel defaults to `diegesis:<namespace>`Emits are mirrored to other tabs/windows on the same origin via
BroadcastChannel. Received messages are re-dispatched locally but never
re-broadcast (no loops). Inbound payloads that fail validation are reported via
onError and dropped (a misbehaving peer can never crash your tab).
Because the bus is VTT-agnostic and namespaced, you can wire several adapters against the same bus to translate between platforms.
Part of diegesis-sdk. MIT.
