A typed EventEmitter3 wrapper that adds history, replay, middleware, namespacing, and logging — with full TypeScript inference.
pnpm add bic-spark eventemitter3
eventemitter3is a peer dependency — install it alongsidebic-spark.
import { Spark } from 'bic-spark';
type Events = {
'user:login': [userId: string];
'user:logout': [userId: string];
'error': [err: Error];
};
const spark = new Spark<Events>({ historySize: 100 });
spark.on('user:login', id => console.log('logged in', id));
spark.emit('user:login', 'u-42');Creates a new emitter.
| Option | Type | Default | Description |
|---|---|---|---|
historySize |
number |
50 |
Max events kept per event name (ring buffer — oldest overwritten when full) |
logger |
SparkLogger |
undefined |
Any console-compatible logger. Calls logger.debug('[spark] <method>: <event>') on on, once, many, off, onAny, offAny, and emit / emitAsync. |
import { Spark } from 'bic-spark';
import type { SparkLogger } from 'bic-spark';
const spark = new Spark<Events>({
historySize: 200,
logger: console, // or a LogLayer instance, pino, winston, etc.
});
// Every call produces a debug line such as:
// [spark] on: user:login
// [spark] once: user:login
// [spark] many(3): user:login
// [spark] onAny
// [spark] emit: user:loginRegister a persistent listener. Pass an optional options.priority (higher = called first).
spark.on('user:login', handlerA); // no priority — appended after all priority listeners
spark.on('user:login', handlerB, { priority: 10 }); // runs before handlerA
spark.on('user:login', handlerC, { priority: 10 }); // same priority as handlerB — called after handlerB (registration order)Register a listener that fires once and then removes itself. Accepts the same options.priority as .on(). Equivalent to .many(event, 1, listener, options).
spark.once('user:login', id => console.log('first login:', id), { priority: 5 });Register a listener that fires exactly n times and then auto-removes itself. A generalisation of .once() — passing n = 1 is identical. Accepts the same options.priority as .on(). Throws RangeError when n < 1.
// Fire at most 3 times, then automatically unsubscribe
spark.many('user:login', 3, id => console.log('login:', id));
// Works with priority
spark.many('user:login', 5, handler, { priority: 10 });
// Works with wildcard patterns
spark.many('user:*', 3, fn); // fires up to 3 times across any user:* event
spark.many('user:**', 3, fn); // same, crossing segment boundaries
.off(event, listener)cancels amany()listener early, using the original function reference.
Register a listener that fires for events matching a wildcard pattern. Works with .on(), .once(), .many(), and .off().
*matches a single segment (anything except:)**matches multiple segments (crosses:boundaries)
spark.on('user:*', fn); // matches user:login, user:logout
// does NOT match user:profile:updated
// ** — multi-segment (globstar)
spark.on('user:**', fn); // matches user:login, user:profile:updated
// Mid-level wildcards
spark.on('user:*:deleted', fn); // matches user:profile:deleted, user:account:deleted
spark.on('**:error', fn); // matches db:error, db:conn:error
spark.on('*:error', fn); // matches db:error but NOT db:conn:errorRemove a previously registered listener.
const handler = (id: string) => {};
spark.on('user:login', handler);
spark.off('user:login', handler);Remove all listeners for one event, or every listener when called without an argument.
spark.removeAllListeners('user:login');
spark.removeAllListeners(); // all eventsspark.listenerCount('user:login'); // 0Listener ordering: Listeners with a higher
priorityvalue are called before those with a lower value. Listeners sharing the same priority (or registered without a priority) are called in registration order. Priority listeners are always called before non-priority listeners for the same event.
Emit an event. The call passes through the middleware chain first.
- Returns
trueif at least one listener was notified. - Returns
falseif middleware blocked the emission or there were no listeners. - The event is recorded in history only when the middleware chain completes.
spark.emit('user:login', 'u-42');Async variant of emit. Runs the middleware chain sequentially, awaiting any middleware that returns a Promise. Listeners are still called synchronously via EventEmitter3 after the chain resolves.
- Returns
trueif at least one listener was notified. - Returns
falseif middleware blocked the emission or there were no listeners. - The event is recorded in history only when the middleware chain completes.
spark.use('save', async (args, next) => {
await db.validate(args[0]);
next();
});
const notified = await spark.emitAsync('save', payload);Register middleware for an event. Middleware functions run in registration order before listeners are notified.
// Signature
type Middleware<TArgs extends any[]> = (args: TArgs, next: () => void) => void | Promise<void>;Sync middleware: call
next()and returnvoid.
Async middleware: return aPromise<void>and callnext()inside it — only honored byemitAsync; syncemitignores the returned Promise.
- Call
next()to continue the chain. - Omit
next()to cancel the emission (listener is not called, event is not recorded in history). - Mutate
argsin-place to transform the payload before it reaches listeners.
// Transform
spark.use('user:login', (args, next) => {
args[0] = args[0].trim().toLowerCase();
next();
});
// Guard / block
spark.use('user:login', (args, next) => {
if (!args[0]) return; // swallowed — no next() call
next();
});
// Chain order: mw1 → mw2 → listener
spark.use('user:login', (args, next) => { console.log('mw1'); next(); });
spark.use('user:login', (args, next) => { console.log('mw2'); next(); });Every successfully emitted event is stored in a per-event ring buffer.
Returns all stored records for an event in insertion order (oldest first).
type EventRecord<TArgs> = {
event: string; // event name
args: TArgs; // emitted arguments
timestamp: number; // Date.now() at emission time
};
const records = spark.getHistory('user:login');
// [{ event: 'user:login', args: ['u-42'], timestamp: 1715... }, ...]Clear history for one event, or all history when called without an argument.
spark.clearHistory('user:login');
spark.clearHistory(); // all eventsInvoke a callback with every recorded emission for an event — in order, without re-running middleware or notifying live listeners.
// Catch up a late subscriber
spark.replay('user:login', id => console.log('past login:', id));Register a listener that fires on every successfully emitted event, regardless of name. The listener receives the event name as its first argument followed by all emitted args.
- Fires after normal (named) listeners.
- Does not fire when middleware blocks the emission.
- Works with both
emit()andemitAsync().
// Signature
type AnyListener = (event: string, ...args: any[]) => void;
spark.onAny((event, ...args) => {
console.log('[audit]', event, args);
});
spark.emit('user:login', 'u-42'); // logs: [audit] user:login ["u-42"]
spark.emit('user:logout', 'u-42'); // logs: [audit] user:logout ["u-42"]Remove a catch-all listener previously registered with .onAny(), using the original function reference.
const auditor = (event: string, ...args: any[]) => console.log(event, args);
spark.onAny(auditor);
// ...later
spark.offAny(auditor);
removeAllListeners()(called without arguments) also clears allonAnylisteners.
Creates a lightweight, prefixed view over an existing Spark instance. All event names are stored on the parent as "<prefix>:<event>".
import { Spark, createNamespace } from 'bic-spark';
type AuthEvents = {
login: [userId: string];
logout: [userId: string];
};
// Parent event map must include the prefixed keys
type AppEvents = {
'auth:login': [userId: string];
'auth:logout': [userId: string];
};
const spark = new Spark<AppEvents>();
const auth = createNamespace<AuthEvents, 'auth'>(spark, 'auth');
auth.on('login', id => console.log('login', id));
auth.emit('login', 'u-42'); // → parent emits 'auth:login'
auth.use('login', (args, next) => { // middleware on 'auth:login'
args[0] = args[0].toUpperCase();
next();
});
auth.getHistory('login'); // history of 'auth:login'
auth.replay('login', handler);
auth.prefix; // 'auth'NamespacedSpark exposes the same on, once, many, off, emit, emitAsync, use, getHistory, replay, onAny, and offAny methods — all scoped to the prefix.
The onAny listener on a namespace fires only for events emitted under its prefix, and receives the un-prefixed event name:
auth.onAny((event, ...args) => {
// event is 'login' or 'logout', not 'auth:login' / 'auth:logout'
console.log('[auth audit]', event, args);
});The underlying fixed-capacity circular buffer is also exported for standalone use.
import { RingBuffer } from 'bic-spark';
const buf = new RingBuffer<number>(3);
buf.push(1); buf.push(2); buf.push(3); buf.push(4);
buf.toArray(); // [2, 3, 4]
buf.size(); // 3
buf.clear();Spark has no internal try/catch. All errors propagate to the caller. The table below shows the exact behavior for each failure mode — all verified by the test suite.
| Scenario | Method | Behaviour |
|---|---|---|
| Sync middleware throws | emit() |
Exception propagates synchronously to the emit() caller. History is not recorded (chain never completed). |
| Async middleware rejects | emitAsync() |
The returned Promise rejects with the error. History is not recorded. |
Async middleware used with sync emit() |
emit() |
Does not throw synchronously. The returned Promise is silently ignored by emit(), producing an unhandled Promise rejection in Node.js. Always use emitAsync() with async middleware. |
| Listener throws | emit() / emitAsync() |
Exception propagates synchronously to the caller. History is recorded (written before listeners are called). Subsequent listeners on the same event are not called (EventEmitter3 aborts immediately). |
Wrap emit() when a middleware or listener may throw:
try {
spark.emit('save', payload);
} catch (err) {
console.error('emission failed', err);
}Always await and catch emitAsync():
try {
await spark.emitAsync('save', payload);
} catch (err) {
console.error('async emission failed', err);
}Never register an async middleware and call it with sync emit() — use emitAsync() instead:
// ✗ silent unhandled rejection — the rejected Promise is discarded
spark.use('save', async (args, next) => { await validate(args[0]); next(); });
spark.emit('save', payload);
// ✓ correct
await spark.emitAsync('save', payload);Guard individual listeners to prevent one bad handler from stopping others:
spark.on('notify', (payload) => {
try {
emailClient.send(payload);
} catch (err) {
logger.error('email failed', err);
}
});import type {
AnyListener, // (event: string, ...args: any[]) => void
EventMap, // Record<string, any[]>
EventRecord, // { event, args, timestamp }
Listener, // (...args) => void
ListenerOptions, // { priority }
Middleware, // (args, next) => void
SparkLogger, // { debug, info, warn, error }
SparkOptions, // { historySize?, logger? }
NamespacedSpark,
} from 'bic-spark';- Add a bug report issue template alongside the existing feature request template
- Wildcard subscriptions —
spark.on('user:*', fn)matching all events under a namespace prefix - Debounce / throttle helpers —
spark.onDebounce(event, fn, ms)andspark.onThrottle(event, fn, ms) - Pausable emitter —
spark.pause()queues emissions;spark.resume()flushes them in order - Diagnostics —
spark.onAny((event, ...args) => ...)global catch-all observer - Priority listeners —
spark.on(event, fn, { priority: 10 })where higher priority fires first - TTL listeners —
spark.many(event, n, fn)fires a listener exactlyntimes then auto-removes (generalisesonce) - Typed event schemas — accept a Zod schema per event, validate payloads at emit time in dev, stripped in production
- Scoped history / sessions —
spark.startSession()groups related events by correlation ID for ordered replay
- Plugin system —
spark.use(plugin)bundling middleware + listeners as a reusable instance-level unit - Cross-window / worker adapter — forward events over
postMessageto iframes or Web Workers - Retry middleware factory —
withRetry(n, delayMs)for retrying failed async emissions - Cancellable events — listeners receive a
controlobject;control.cancel(reason)stops the pipeline;emitAsyncreturns{ cancelled, reason } - Event pipeline / chaining —
spark.definePipeline('move', [...])declares a named sequence of events with repeat support and automatic cancellation propagation
MIT