Skip to content

stevekinney/event-emission

Repository files navigation

Event Emission

Event Emission is a high-performance, type-safe event primitive designed to bridge the gap between three worlds: DOM EventTarget, TC39 Observable, and AsyncIterator. While standard event emitters often force you into a single consumption pattern, Event Emission gives you the freedom to dispatch once and consume however your logic demands—whether that's standard callbacks, reactive pipelines via RxJS, or clean for await...of loops. By treating events as a first-class, composable primitive rather than just a side-effect, it eliminates race conditions and shared mutable state, providing a unified, zero-dependency foundation for building resilient, concurrent applications in modern JavaScript and TypeScript environments.

A lightweight, zero-dependency, type-safe event system with DOM EventTarget ergonomics and TC39 Observable interoperability. Use one event source with callbacks, async iterators, and RxJS without losing TypeScript safety.

Tasting notes

  • Typed events - Event maps keep payloads and event names in sync
  • DOM compatible - EmissionEvent is a superset of the built-in Event
  • Familiar API - addEventListener, removeEventListener, dispatchEvent
  • TC39 Observable - Fully compliant Observable implementation (passes all es-observable-tests)
  • Async iteration - for await...of over events with backpressure options
  • Wildcard listeners - Listen to * or namespaced user:* patterns
  • Observable state - Proxy any object and emit change events automatically
  • AbortSignal support - Cleanup with AbortController
  • No dependencies - Framework-agnostic, works in Node, Bun, and browsers

When to use it

  • Typed app-level event buses
  • Bridging DOM events to RxJS pipelines
  • State objects that emit change events for UI updates
  • Component/service emitters without stringly-typed payloads

Installation

npm install event-emission
# or
bun add event-emission
# or
pnpm add event-emission

Quick start

import { createEventTarget } from 'event-emission';

type UserEvents = {
  'user:login': { userId: string; timestamp: Date };
  'user:logout': { userId: string };
  error: Error;
};

const events = createEventTarget<UserEvents>();

events.addEventListener('user:login', (event) => {
  console.log(`User ${event.detail.userId} logged in at ${event.detail.timestamp}`);
});

events.dispatchEvent({
  type: 'user:login',
  detail: { userId: '123', timestamp: new Date() },
});

Core concepts

  • Event map: a TypeScript type that maps event names to payload types.
  • Event shape: { type: string; detail: Payload; bubbles?: boolean; cancelable?: boolean; composed?: boolean } for dispatch.
  • Unsubscribe: addEventListener returns a function to remove the listener.

API overview

Core (event-emission):

  • createEventTarget<E>(options?)
  • createEventTarget(target, { observe: true, ... })
  • EventEmission<E> base class

Optional subpaths:

  • Observable<T> compliant implementation (event-emission/observable)
  • Interoperability: fromEventTarget, forwardToEventTarget, pipe (event-emission/interoperability)
  • Observe utilities: isObserved, getOriginal, setupEventForwarding (event-emission/observe)
  • Types-only exports (event-emission/types)

Subpath exports

To keep the core entrypoint lean, optional features are exposed via subpath exports:

import { Observable } from 'event-emission/observable';
import { getOriginal, isObserved, setupEventForwarding } from 'event-emission/observe';
import {
  forwardToEventTarget,
  fromEventTarget,
  pipe,
} from 'event-emission/interoperability';
import type { EventTargetLike } from 'event-emission/types';

createEventTarget

createEventTarget<E>(options?)

Creates a typed event target.

type Events = {
  message: { text: string };
  error: Error;
};

const target = createEventTarget<Events>();

Options

Option Type Description
onListenerError (type: string, error: unknown) => void Custom error handler for listener exceptions

If a listener throws and no onListenerError is provided, an error event is emitted. If there are no error listeners, the error is re-thrown.

Observable state

Create state objects that emit change events:

const state = createEventTarget({ count: 0, user: { name: 'Ada' } }, { observe: true });

state.addEventListener('update', (event) => {
  console.log('State changed:', event.detail.current);
});

state.addEventListener('update:count', (event) => {
  console.log(
    `Count changed from ${event.detail.previous.count} to ${event.detail.value}`,
  );
});

state.count = 1; // Triggers 'update' and 'update:count'
state.user.name = 'Grace'; // Triggers 'update' and 'update:user.name'

Observe options:

Deep observation is enabled by default.

Option Type Default Description
observe boolean false Enable property change observation
deep boolean true Observe nested objects
cloneStrategy 'shallow' | 'deep' | 'path' 'path' How to clone previous state
deepClone <T>(value: T) => T - Optional deep clone fallback when structuredClone is unavailable

Note: cloneStrategy: 'deep' uses structuredClone by default, or deepClone if provided.

Example fallback:

const state = createEventTarget(
  { count: 0, user: { name: 'Ada' } },
  {
    observe: true,
    cloneStrategy: 'deep',
    deepClone: (value) => JSON.parse(JSON.stringify(value)),
  },
);

Update event details:

  • update and update:path events include { value, current, previous }.
  • Array mutators emit method events like update:items.push with { method, args, added, removed, current, previous }.

Event listeners

on(type, options?)

Creates an Observable for a specific event type. This follows the ObservableEventTarget proposal, allowing for powerful composition.

const clicks = button.on('click', { passive: true });

clicks.subscribe((event) => {
  console.log('Clicked!', event.detail);
});

Options:

Option Type Default Description
capture boolean false If true, listen during the capture phase
receiveError boolean false If true, listen for "error" events and forward them to the observer's error method
handler Function null Optional function to run stateful actions (like preventDefault()) before dispatching
once boolean false If true, the observable completes after the first event is dispatched
passive boolean false Indicates that the callback will not cancel the event
signal AbortSignal - Abort signal to remove the listener when aborted

addEventListener(type, listener, options?)

Adds a listener and returns an unsubscribe function.

const unsubscribe = events.addEventListener('message', (event) => {
  console.log(event.detail.text);
});

unsubscribe();

Options: (or pass true/false for capture)

Option Type Description
capture boolean Listen during the capture phase
once boolean Remove listener after first invocation
passive boolean Listener will not call preventDefault()
signal AbortSignal Abort signal to remove listener when aborted

Note: preventDefault() only affects events dispatched with cancelable: true. dispatchEvent returns false when a cancelable event is prevented.

once(type, listener, options?)

Adds a one-time listener.

removeEventListener(type, listener, options?)

Removes a specific listener.

removeAllListeners(type?)

Removes all listeners, or all listeners for a type.

Wildcard listeners

events.addWildcardListener('*', (event) => {
  console.log(`Got ${event.originalType}:`, event.detail);
});

events.addWildcardListener('user:*', (event) => {
  console.log(`User event: ${event.originalType}`);
});

Wildcard events include { type: pattern, originalType, detail }.

Async iteration

You can use for await...of to consume events. This is great for stream-processing events with backpressure.

// Simple iteration
for await (const event of events.events('message')) {
  console.log('Received:', event.detail.text);
}

// With options for backpressure and cleanup
const iterator = events.events('message', {
  bufferSize: 16,
  overflowStrategy: 'drop-oldest',
  signal: abortController.signal,
});

for await (const event of iterator) {
  // ...
}

Iterator options:

Option Type Default Description
signal AbortSignal - Abort signal to stop iteration
bufferSize number Infinity Maximum buffered events
overflowStrategy 'drop-oldest' | 'drop-latest' | 'throw' 'drop-oldest' Behavior when buffer is full

When overflowStrategy is throw, the iterator throws BufferOverflowError.

Observable interoperability

subscribe(type, observer)

const subscription = events.subscribe('message', {
  next: (event) => console.log(event.detail),
  error: (err) => console.error(err),
  complete: () => console.log('Done'),
});

subscription.unsubscribe();

toObservable()

Returns an Observable that emits all events.

import { from } from 'rxjs';
import { filter, map } from 'rxjs/operators';

const observable = from(events);

observable
  .pipe(
    filter((event) => event.type === 'message'),
    map((event) => event.detail.text),
  )
  .subscribe(console.log);

Observable class

A fully compliant implementation of the TC39 Observable proposal.

import { Observable } from 'event-emission/observable';

// Create from items
const numbers = Observable.of(1, 2, 3);

// Create from any iterable or observable-like
const fromArray = Observable.from([10, 20, 30]);

// Manual creation
const custom = new Observable((observer) => {
  observer.next('Hello');
  observer.complete();
});

Lifecycle

complete()

Marks the event target as complete, clears listeners, and ends iterators.

clear()

Removes all listeners without marking as complete.

EventEmission base class

Extend EventEmission to build typed emitters:

import { EventEmission } from 'event-emission';

class UserService extends EventEmission<{
  'user:created': { id: string; name: string };
  'user:deleted': { id: string };
  error: Error;
}> {
  createUser(name: string) {
    const id = crypto.randomUUID();
    this.dispatchEvent({ type: 'user:created', detail: { id, name } });
    return id;
  }
}

DOM interoperability

fromEventTarget(domTarget, eventTypes, options?)

import { fromEventTarget } from 'event-emission/interoperability';

type ButtonEvents = {
  click: MouseEvent;
  focus: FocusEvent;
};

const button = document.getElementById('my-button');
const events = fromEventTarget<ButtonEvents>(button, ['click', 'focus']);

events.addEventListener('click', (event) => {
  console.log('Button clicked!', event.detail);
});

events.destroy();

forwardToEventTarget(source, domTarget, options?)

import { createEventTarget } from 'event-emission';
import { forwardToEventTarget } from 'event-emission/interoperability';

const events = createEventTarget<{ custom: { value: number } }>();
const element = document.getElementById('target');

const unsubscribe = forwardToEventTarget(events, element);

events.dispatchEvent({ type: 'custom', detail: { value: 42 } });

unsubscribe();

pipe(source, target, options?)

import { createEventTarget } from 'event-emission';
import { pipe } from 'event-emission/interoperability';

const componentEvents = createEventTarget<{ ready: void }>();
const appBus = createEventTarget<{ ready: void }>();

const unsubscribe = pipe(componentEvents, appBus);

unsubscribe();

Note: Both pipe(source, target) and events.pipe(target) forward all events via a wildcard listener. Use a map function to transform events or return null to filter.

React integration

Event Emission works beautifully with React's useSyncExternalStore for predictable, race-condition-free state synchronization.

Observing an event emitter

import { useSyncExternalStore } from 'react';
import { createEventTarget } from 'event-emission';

const bus = createEventTarget<{ log: string }>();

function useBusEvent() {
  return useSyncExternalStore(
    (callback) => bus.addEventListener('log', callback),
    () => getLatestLogValue(), // implementation depends on your needs
  );
}

Syncing with Observable State

The observe feature is perfect for building high-performance global stores or local controllers that live outside the React render cycle.

import { useSyncExternalStore } from 'react';
import { createEventTarget } from 'event-emission';

// 1. Create your store outside of React
const store = createEventTarget(
  { count: 0, lastUpdated: new Date() },
  { observe: true }
);

// 2. Create a generic hook to sync with any observed target
export function useObservable<T extends object>(target: T) {
  return useSyncExternalStore(
    (onStoreChange) => (target as any).addEventListener('update', onStoreChange),
    () => target
  );
}

// 3. Components re-render only when the store actually mutates
function Counter() {
  const state = useObservable(store);
  return (
    <button onClick={() => state.count++}>
      Count is {state.count}
    </button>
  );
}

Svelte integration

Event Emission works naturally with Svelte 5 Runes to create reactive stores that live outside your component tree.

Creating a Reactive Rune

import { createEventTarget } from 'event-emission';

// 1. Define your external state
const store = createEventTarget({ count: 0 }, { observe: true });

// 2. Create a generic Rune to sync with any observed target
export function useObservable<T extends object>(target: T) {
  let state = $state(target);

  $effect(() => {
    return (target as any).addEventListener('update', () => {
      state = target; // Trigger Svelte reactivity
    });
  });

  return state;
}

// 3. Use it in your components
const state = useObservable(store);

Utilities

isObserved(obj)

Checks if an object is an observed proxy.

import { isObserved } from 'event-emission/observe';

getOriginal(proxy)

Returns the original unproxied object.

import { getOriginal } from 'event-emission/observe';

TypeScript types

import type {
  EmissionEvent,
  EventTargetLike,
  ObservableLike,
  Observer,
  Subscription,
  WildcardEvent,
  AddEventListenerOptionsLike,
} from 'event-emission/types';

import type {
  ObservableEventMap,
  PropertyChangeDetail,
  ArrayMutationDetail,
} from 'event-emission/observe';

import type { Subscriber, SubscriptionObserver } from 'event-emission/observable';

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors