Skip to the content.

Changelog

All notable changes to this project are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[strictDestroy]

Added

Removed

Fixed

[3.0.0]

v3 tightens the subscription API around a smaller, more explicit set of methods and makes next and scan easier to type. The behavioral core (emitting, piping, scanning, memory-leak detection) is largely unchanged; lifecycle hook ordering and downstream piping behavior are tightened (see Fixed and Changed below).

See the Migration guide for step-by-step changes.

Added

Changed

Deprecated

Removed

Fixed

Internal


Migrating from v2 to v3

v2 (removed or changed) v3 equivalent
bus.on('*', handler) bus.tap(handler)
allowUnhandledEvents: false options.onUnhandledEvent: 'throw'
allowUnhandledEvents: true (default) options.onUnhandledEvent: 'ignore' (default) or omit
subclass handleUnexpectedEvent options.onUnhandledEvent: (event, payload) => { ... }
feeder.on('*', hub.emit) feeder.pipe(hub)
bus.on([...events], handler) bus.any([...events], handler)
bus.proxy(handler) bus.tap(handler)
bus.every(handler) bus.tap(handler)
const payload = await bus.next('foo') → payload const {payload} = await bus.next('foo')
await bus.next([...]) → undefined const {event, payload} = await bus.next([...])
bus.next('*', ...) bus.next([...events]) or bus.scan('*', evaluator, options?) — see Wildcard ('*') triggers on next
bus.scan({evaluator, trigger, ...}) bus.scan(trigger, evaluator, options?) — object form deprecated
bus.scan<typeof evaluator>(...) bus.scan<ResolvedType>(...)
generateSubscription(dispose) subscriptionWrapper(dispose)
EventHandler<Map, 'foo'> EventHandler<Map, 'foo'> (unchanged)
MultiEventHandler<Map> EventSink<Map>
WildcardEventHandler<Map> TapHandler<Map>
GenericHandler (exported) (internal; not part of the public API)
bus.listeners bus.forEach((event, handlers) => ...) or bus.forEach((event, handlers) => ..., {scope: ListenerScope.ANY})
bus.listeners.get('foo') bus.getListenersFor('foo') or bus.getListenersFor('foo', {scope: ListenerScope.ANY})
bus.ownListeners bus.forEach((event, handlers) => ..., {scope: ListenerScope.OWN})
bus.hasListeners (property) bus.hasListeners() or bus.hasListeners({scope: ListenerScope.ANY})
bus.hasOwnListeners bus.hasListeners({scope: ListenerScope.OWN})
bus.hasDownstreamListeners bus.hasListeners({scope: ListenerScope.DOWNSTREAM})
bus.listenerCount bus.getListenerCount() or bus.getListenerCount({scope: ListenerScope.ANY})
bus.listenerEventCount bus.getEventCount() or bus.getEventCount({scope: ListenerScope.ANY})
bus.ownListenerEventCount bus.getEventCount({scope: ListenerScope.OWN})
bus.downstreamListenerEventCount bus.getEventCount({scope: ListenerScope.DOWNSTREAM})
bus.hasListenersFor('foo') bus.hasListenersFor('foo') or bus.hasListenersFor('foo', {scope: ListenerScope.ANY})
bus.hasOwnListenersFor('foo') bus.hasListenersFor('foo', {scope: ListenerScope.OWN})
bus.hasDownstreamListenersFor('foo') bus.hasListenersFor('foo', {scope: ListenerScope.DOWNSTREAM})
bus.getListenerCountFor('foo') bus.getListenerCountFor('foo') or bus.getListenerCountFor('foo', {scope: ListenerScope.ANY})
bus.getOwnListenerCountFor('foo') bus.getListenerCountFor('foo', {scope: ListenerScope.OWN})
bus.getDownstreamListenerCountFor('foo') bus.getListenerCountFor('foo', {scope: ListenerScope.DOWNSTREAM})
bus.getListener('foo') bus.getListenersFor('foo') or bus.getListenersFor('foo', {scope: ListenerScope.ANY})
bus.getOwnListener('foo') bus.getListenersFor('foo', {scope: ListenerScope.OWN})
bus.getDownstreamListener('foo') bus.getListenersFor('foo', {scope: ListenerScope.DOWNSTREAM})
bus.getListenerCount('foo') bus.getListenerCountFor('foo') or bus.getListenerCountFor('foo', {scope: ListenerScope.ANY})
bus.getOwnListenerCount('foo') bus.getListenerCountFor('foo', {scope: ListenerScope.OWN})
bus.getDownstreamListenerCount('foo') bus.getListenerCountFor('foo', {scope: ListenerScope.DOWNSTREAM})
bus.forEachListener((handlers, event) => ...) bus.forEach((event, handlers) => ...) or bus.forEach((event, handlers) => ..., {scope: ListenerScope.ANY})
bus.forEachOwnListener((handlers, event) => ...) bus.forEach((event, handlers) => ..., {scope: ListenerScope.OWN})
bus.forEachDownstreamListener((handlers, event) => ...) bus.forEach((event, handlers) => ..., {scope: ListenerScope.DOWNSTREAM})
custom Logger with info/warn/error/debug as (...args: any[]) => void accept (record: StrongbusLogRecord) => void; add debug if missing
options.logger: console (or console-shaped rest-arg sink) omit logger (falls back to built-in console adapter), or wrap record.message / record.context

Import ListenerScope from 'strongbus' wherever the v3 column uses it. ListenerScope.ANY is equivalent to ListenerScope.OWN | ListenerScope.DOWNSTREAM. ListenerScope.DOWNSTREAM covers listeners on buses attached with pipe(bus) only, not tap handlers (those are ListenerScope.OWN).

Custom Logger: record argument + required debug

Logger methods no longer take rest args. Each level receives one StrongbusLogRecord:

// v2
const logger: Logger = {
  info: (...args) => console.info(...args),
  warn: (...args) => console.warn(...args),
  error: (...args) => console.error(...args),
  // debug often omitted
};

// v3
import {
  StrongbusLogCode,
  type Logger,
  type StrongbusLogRecord
} from 'strongbus';

const logger: Logger = {
  info(record) {
    console.info(record.message, record.context);
  },
  warn(record) {
    console.warn(record.message, record.context);
  },
  error(record) {
    console.error(record.message, record.context);
  },
  debug(record) {
    console.debug(record.message, record.context);
  }
};

// Discriminate without parsing message text:
function onLog(record: StrongbusLogRecord): void {
  if (record.code === StrongbusLogCode.DuplicateSubscription) {
    // ...
  }
}

// Omit `logger` to use the built-in console adapter.
new Bus();

debug is required alongside info / warn / error. Strongbus calls it when options.duplicateSubscriptionStrategy.logLevel is 'debug'. A no-op debug is fine if you do not care about that level.

on with arrays or the wildcard

on is now single-event only. Move array and wildcard subscriptions to any and tap.

// v2
bus.on('foo', onFoo);
bus.on(['foo', 'bar'], (event, payload) => { /* ... */ });
bus.on('*', (event, payload) => { /* ... */ });

// v3
bus.on('foo', onFoo);                                       // unchanged
bus.any(['foo', 'bar'], (event, payload) => { /* ... */ }); // arrays -> any
bus.tap(({event, payload}) => { /* ... */ });               // '*' -> tap

proxy / every → tap

Both are removed; tap covers them.

// v2
const sub = bus.proxy((event, payload) => { /* ... */ });
const sub2 = bus.every((event, payload) => { /* ... */ });

// v3 — the handler receives one correlated { event, payload } message
const sub = bus.tap(({event, payload}) => { /* ... */ });

Hubs: feeder.pipe(hub)

v2 often used feeder.on('*', hub.emit). v3 removes the '*' subscription; pipe feeders into the hub directly:

feeder.pipe(hub);

For multi-hop bridges with disagreeing maps, use mid.pipe(pred).pipe(leaf) — see docs/pipe_limitations.md.

next resolves with {event, payload}

// v2
const payload = await bus.next('foo');        // payload: TEventMap['foo']
await bus.next(['foo', 'bar']);               // resolved with undefined

// v3
const {payload} = await bus.next('foo');      // payload: TEventMap['foo']
const {event, payload} = await bus.next(['foo', 'bar']);
// event is narrowed to 'foo' | 'bar'; payload is the matching payload type

Wildcard ('*') triggers on next

next no longer accepts '*' as a resolution or rejection trigger. next always resolves on the first matching event. In the type system, the wider events are transparent to the narrower bus, however at runtime they do get piped into the narrower bus, so a wildcard handler could unintentionally resolve/reject a next on an event the narrow bus is oblivious to.

scan still accepts trigger: '*' because the evaluator can discriminate on resolve.trigger before resolving.

// before — compile-time allowed on next, payload typing was unsound
await bus.next('*');

// v3 — list events when using next
await bus.next(['foo', 'bar', 'baz']);

// v3 — wildcard with conditional resolve via scan
await bus.scan('*', (resolve) => {
  if (resolve.trigger.type === 'event' && shouldAccept(resolve.trigger)) {
    resolve(resolve.trigger);
  }
});

scan signature and type argument

The positional form is preferred. The object form scan({evaluator, trigger, ...options}) is deprecated but still supported. The type argument is the resolved value type (not the evaluator type). Inference from a typed evaluator is unchanged.

// v2
bus.scan<typeof myEvaluator>({evaluator: myEvaluator, trigger: 'foo'});

// v3 (preferred)
bus.scan<boolean>('foo', myEvaluator);
bus.scan<boolean>('foo', myEvaluator, {eager: false, pool: false, timeout: 1000});

// v3 (deprecated object form — still supported)
bus.scan<boolean>({evaluator: myEvaluator, trigger: 'foo'});

// inference is unchanged in both versions
const ready = await bus.scan('foo', myEvaluator);

Renamed handler types

// v2
import type {EventHandler, MultiEventHandler, WildcardEventHandler} from 'strongbus';

// v3 — single-event handlers, any sinks, and tap observers
import type {EventHandler, EventSink, TapHandler, PipedMessage} from 'strongbus';

MultiEventHandler maps to EventSink (the (event, payload) handler used by any). WildcardEventHandler maps to TapHandler, which receives a single correlated {event, payload} PipedMessage (tap((message) => …)). A single-event handler that was typed via EventHandler<Map, 'foo'> in v2 is still EventHandler<Map, 'foo'> in v3.

Renamed subscription helper

// v2
import {generateSubscription} from 'strongbus';

// v3
import {subscriptionWrapper} from 'strongbus';

Narrower consumer views

Prefer SubscriptionSurface<Narrow> when a component should subscribe but not emit, or IntrospectionSurface<Narrow> when it only inspects listeners. A Bus<Wide> remains assignable to either narrower view.

// v3
import type {SubscriptionSurface, IntrospectionSurface} from 'strongbus';

function consume(source: SubscriptionSurface<Pick<MyEvents, 'foo' | 'bar'>>) {
  source.on('foo', handler);
}

function inspect(source: IntrospectionSurface<Pick<MyEvents, 'foo' | 'bar'>>) {
  source.getListenerCountFor('foo');
}

Listener introspection

import {ListenerScope} from 'strongbus';

// v2
if (bus.hasListenersFor('foo')) { /* ... */ }
const count = bus.getListenerCountFor('foo');
const handlers = bus.listeners.get('foo');
for (const [event, set] of bus.listeners) { /* ... */ }

// v3 (scope / includeIncognito are optional)
if (bus.hasListenersFor('foo')) { /* ... */ }
const count = bus.getListenerCountFor('foo');
const handlers = bus.getListenersFor('foo');
bus.forEach((event, set) => { /* ... */ });
bus.forEach((event, set) => { /* ... */ }, {scope: ListenerScope.OWN});
bus.forEach((event, set) => { /* ... */ }, {scope: ListenerScope.DOWNSTREAM});
bus.hasListeners({includeIncognito: true});