Skip to content

BurningIceCube/bic-spark

Repository files navigation

bic-spark

npm version npm downloads Coverage

A typed EventEmitter3 wrapper that adds history, replay, middleware, namespacing, and logging — with full TypeScript inference.


Installation

pnpm add bic-spark eventemitter3

eventemitter3 is a peer dependency — install it alongside bic-spark.


Quick start

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');

API

new Spark<TEvents>(options?)

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:login

Subscribing

.on(event, listener, options?) → this

Register 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)

.once(event, listener, options?) → this

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 });

.many(event, n, listener, options?) → this

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 a many() listener early, using the original function reference.

Wildcard subscriptions

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:error

.off(event, listener) → this

Remove a previously registered listener.

const handler = (id: string) => {};
spark.on('user:login', handler);
spark.off('user:login', handler);

.removeAllListeners(event?) → this

Remove all listeners for one event, or every listener when called without an argument.

spark.removeAllListeners('user:login');
spark.removeAllListeners(); // all events

.listenerCount(event) → number

spark.listenerCount('user:login'); // 0

Listener ordering: Listeners with a higher priority value 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.


Emitting

.emit(event, ...args) → boolean

Emit an event. The call passes through the middleware chain first.

  • Returns true if at least one listener was notified.
  • Returns false if 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 emitting

.emitAsync(event, ...args) → Promise<boolean>

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 true if at least one listener was notified.
  • Returns false if 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);

Middleware

.use(event, middleware) → this

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 return void.
Async middleware: return a Promise<void> and call next() inside it — only honored by emitAsync; sync emit ignores 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 args in-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(); });

History

Every successfully emitted event is stored in a per-event ring buffer.

.getHistory(event) → EventRecord<TArgs>[]

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... }, ...]

.clearHistory(event?) → this

Clear history for one event, or all history when called without an argument.

spark.clearHistory('user:login');
spark.clearHistory(); // all events

Replay

.replay(event, listener) → this

Invoke 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));

Global catch-all

.onAny(listener) → this

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() and emitAsync().
// 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"]

.offAny(listener) → this

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 all onAny listeners.


Namespaces

createNamespace<TEvents, TPrefix>(spark, prefix) → NamespacedSpark<TEvents>

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);
});

RingBuffer<T>

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();

Error handling

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).

Recommended patterns

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);
  }
});

Type exports

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';

Bounty board / TODO

🟢 Easy

  • Add a bug report issue template alongside the existing feature request template

🟡 Medium

  • Wildcard subscriptions — spark.on('user:*', fn) matching all events under a namespace prefix
  • Debounce / throttle helpers — spark.onDebounce(event, fn, ms) and spark.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 exactly n times then auto-removes (generalises once)
  • 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

🔴 Hard

  • Plugin system — spark.use(plugin) bundling middleware + listeners as a reusable instance-level unit
  • Cross-window / worker adapter — forward events over postMessage to iframes or Web Workers
  • Retry middleware factory — withRetry(n, delayMs) for retrying failed async emissions
  • Cancellable events — listeners receive a control object; control.cancel(reason) stops the pipeline; emitAsync returns { cancelled, reason }
  • Event pipeline / chaining — spark.definePipeline('move', [...]) declares a named sequence of events with repeat support and automatic cancellation propagation

License

MIT

About

A TS eventemitter wrapper adding history, namespacing, replay, logging, and more convenient enhancements.

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors