Skip to content

@thuum/event-emitter

Terminal window
npm install @thuum/event-emitter

@thuum/event-emitter provides a unified, type-safe interface for pub/sub communication across different runtime backends:

  • eventTarget — Adapter for the DOM EventTarget API (browsers, Deno, Node 15+)
  • messagePort — Adapter for BroadcastChannel, MessagePort, Worker, and Window.postMessage
  • nodeEvents — Adapter for Node.js EventEmitter
  • rxjs — Adapter for RxJS Subject / Observable
  • connect — Utility to forward events between any listener→emitter pair

Each adapter exposes the same two-function surface — emitter(source) and listener(source) — making it trivial to swap transports without changing application logic.

  • 🎯 Type Safe — Event names and payloads are checked at compile time
  • 🔌 Multi-Backend — One interface, four transports: DOM, MessagePort, Node.js, RxJS
  • 🧩 Composable — Mix and match adapters; connect any listener to any emitter
  • 🪶 Zero Dependencies — No external runtime dependencies
  • ❄️ Frozen Instances — All returned objects are Object.freezed for safety
  • 🛑 Deterministic Cleanup — Every subscription returns a { stop() } handle
AdapterImport PathTransportUse Case
eventTarget@thuum/event-emitter/event-targetDOM EventTargetIn-process pub/sub, custom elements, service workers
messagePort@thuum/event-emitter/message-portpostMessage channelCross-window, cross-worker, BroadcastChannel communication
nodeEvents@thuum/event-emitter/node-eventsNode.js EventEmitterServer-side event buses, streams, CLI tools
rxjs@thuum/event-emitter/rxjsRxJS SubjectReactive pipelines, multiplexed event streams
  • eventTarget — You want in-process pub/sub that interops with native DOM event listeners and CustomEvent.
  • messagePort — You need to send typed events across execution contexts (windows, workers, iframes).
  • nodeEvents — You’re in Node.js and want type safety on top of the built-in EventEmitter.
  • rxjs — You’re already using RxJS and want to multiplex typed events through a single Subject.
  • connect — You want to bridge two systems together, forwarding specific events from one to another.

The library is built around two core interfaces that all adapters implement:

interface Emitter<Map extends EventMap> {
emit<K extends keyof Map>(name: K & string, value: Map[K & string]): void;
}
interface Listener<Map extends EventMap> {
on<K extends keyof Map>(
name: K & string,
handler: (value: Map[K & string]) => void,
): { stop(): void };
}

An EventMap is simply a record mapping string event names to payload types:

type EventMap = { [name: string]: unknown };

This shared contract means you can write transport-agnostic code and swap the underlying backend without changing your event handling logic.


import type { EventMap, EventUnion, Emitter, Listener } from "@thuum/event-emitter";

Base constraint for event maps — a record of string keys to payload types.

type MyEvents = {
message: string;
error: Error;
disconnect: undefined;
};

Converts an event map into a discriminated union of { name, value } pairs. Used internally by the message-port and rxjs adapters to multiplex events through a single channel.

type MyEvents = { message: string; error: Error };
type Union = EventUnion<MyEvents>;
// => { name: "message"; value: string } | { name: "error"; value: Error }

Interface for type-safe event dispatching.

declare const emitter: Emitter<MyEvents>;
emitter.emit("message", "hello"); // ✓
emitter.emit("error", new Error("fail")); // ✓
emitter.emit("message", 42); // ✗ type error

Interface for type-safe event subscription. Returns a { stop() } handle for deterministic cleanup.

declare const listener: Listener<MyEvents>;
const sub = listener.on("message", (value) => {
console.log(value); // typed as `string`
});
sub.stop(); // unsubscribe

import { emitter, listener } from "@thuum/event-emitter/event-target";

Adapts a DOM EventTarget. Events are dispatched as CustomEvent with payloads in the detail property.

import { emitter } from "@thuum/event-emitter/event-target";
type Events = {
login: { userId: string };
logout: undefined;
};
const target = new EventTarget();
const emit = emitter<Events>(target);
emit.emit("login", { userId: "abc-123" });
emit.emit("logout", undefined);
import { emitter, listener } from "@thuum/event-emitter/event-target";
type Events = {
message: { text: string; from: string };
disconnect: undefined;
};
const target = new EventTarget();
const emit = emitter<Events>(target);
const listen = listener<Events>(target);
// Handler receives the typed payload directly:
const sub = listen.on("message", (value) => {
console.log(value.text); // string
console.log(value.from); // string
});
emit.emit("message", { text: "hello", from: "server" });
// Unsubscribe when done:
sub.stop();
const target = new EventTarget();
const listen = listener<{ data: string }>(target);
const sub1 = listen.on("data", (v) => console.log("A:", v));
const sub2 = listen.on("data", (v) => console.log("B:", v));
// Stop one without affecting the other:
sub1.stop();

import { emitter, listener } from "@thuum/event-emitter/message-port";

Adapts BroadcastChannel, MessagePort, Worker, or Window. Events are sent as { name, value } objects via postMessage and filtered by name on the receiving side.

import { emitter, listener } from "@thuum/event-emitter/message-port";
type Events = {
"user:login": { id: string; role: string };
"user:logout": { id: string };
};
// In Tab A — send events:
const channel = new BroadcastChannel("auth");
const emit = emitter<Events>(channel);
emit.emit("user:login", { id: "abc", role: "admin" });
// In Tab B — receive events:
const channel = new BroadcastChannel("auth");
const listen = listener<Events>(channel);
const sub = listen.on("user:login", (payload) => {
console.log(payload.id); // "abc"
console.log(payload.role); // "admin"
});
// Cleanup:
sub.stop();
import { emitter, listener } from "@thuum/event-emitter/message-port";
type WorkerEvents = {
"task:start": { taskId: string };
"task:done": { taskId: string; result: unknown };
};
// Main thread → Worker
const worker = new Worker("./worker.js");
const emit = emitter<WorkerEvents>(worker);
emit.emit("task:start", { taskId: "t1" });
// Worker → Main thread (inside worker.js)
const listen = listener<WorkerEvents>(self);
listen.on("task:start", (task) => {
// process task...
});

import { emitter, listener } from "@thuum/event-emitter/node-events";

Wraps a Node.js EventEmitter with type-safe emit and on methods.

import { EventEmitter } from "node:events";
import { emitter, listener } from "@thuum/event-emitter/node-events";
type Events = { message: string; error: Error };
const ee = new EventEmitter();
const emit = emitter<Events>(ee);
const listen = listener<Events>(ee);
const sub = listen.on("message", (msg) => {
console.log(msg); // typed as string
});
emit.emit("message", "hello");
// Unsubscribe:
sub.stop();
import { EventEmitter } from "node:events";
import { emitter, listener } from "@thuum/event-emitter/node-events";
type Events = {
request: { method: string; url: string };
response: { status: number; body: string };
error: Error;
};
const bus = new EventEmitter();
const emit = emitter<Events>(bus);
const listen = listener<Events>(bus);
listen.on("request", (req) => {
console.log(`${req.method} ${req.url}`);
});
listen.on("error", (err) => {
console.error(err.message);
});
emit.emit("request", { method: "GET", url: "/api/users" });
emit.emit("error", new Error("timeout"));

import { emitter, listener } from "@thuum/event-emitter/rxjs";

Multiplexes typed events as { name, value } objects through a single RxJS-compatible Subject. Useful for reactive pipelines where multiple event types share one stream.

import { Subject } from "rxjs";
import { emitter, listener } from "@thuum/event-emitter/rxjs";
import type { EventUnion } from "@thuum/event-emitter";
type Events = {
message: string;
disconnect: { reason: string };
};
const subject = new Subject<EventUnion<Events>>();
const emit = emitter<Events>(subject);
const listen = listener<Events>(subject);
const sub = listen.on("message", (text) => {
console.log("received:", text);
});
emit.emit("message", "hello");
// Unsubscribe (calls subscription.unsubscribe() internally):
sub.stop();
import { Subject } from "rxjs";
import { emitter, listener } from "@thuum/event-emitter/rxjs";
import type { EventUnion } from "@thuum/event-emitter";
type Events = { ping: undefined; pong: undefined };
const subject = new Subject<EventUnion<Events>>();
const emit = emitter<Events>(subject);
const listen = listener<Events>(subject);
// Only fires for "pong" events:
listen.on("pong", () => {
console.log("pong received");
});
emit.emit("ping", undefined); // handler not called
emit.emit("pong", undefined); // logs: "pong received"

Bridges a listener (source) to an emitter (sink), forwarding all specified events from one side to the other.

import { connect } from "@thuum/event-emitter";
import { connect } from "@thuum/event-emitter";
import { emitter, listener } from "@thuum/event-emitter/event-target";
type Events = {
message: { text: string };
error: { code: number };
};
const source = new EventTarget();
const dest = new EventTarget();
const connection = connect(
listener<Events>(source),
emitter<Events>(dest),
"message", "error",
);
// Events dispatched on `source` are now forwarded to `dest`.
// Stop forwarding:
connection.stop();
import { connect } from "@thuum/event-emitter";
import { emitter, listener } from "@thuum/event-emitter/event-target";
type Events = { ping: undefined; pong: undefined };
const a = new EventTarget();
const b = new EventTarget();
// Wire A → B and B → A:
const ab = connect(listener<Events>(a), emitter<Events>(b), "ping");
const ba = connect(listener<Events>(b), emitter<Events>(a), "pong");
// Cleanup both directions:
ab.stop();
ba.stop();

Because all adapters share the same Emitter/Listener interfaces, you can bridge different transports:

import { connect } from "@thuum/event-emitter";
import { listener } from "@thuum/event-emitter/node-events";
import { emitter } from "@thuum/event-emitter/message-port";
import { EventEmitter } from "node:events";
type Events = { update: { id: string; data: unknown } };
const ee = new EventEmitter();
const channel = new BroadcastChannel("updates");
// Forward Node.js events → BroadcastChannel:
const bridge = connect(
listener<Events>(ee),
emitter<Events>(channel),
"update",
);
bridge.stop();

Only forward a subset of events between systems:

import { connect } from "@thuum/event-emitter";
import { emitter, listener } from "@thuum/event-emitter/event-target";
type Events = {
mousedown: { x: number; y: number };
mousemove: { x: number; y: number };
mouseup: { x: number; y: number };
};
const input = new EventTarget();
const renderer = new EventTarget();
// Only forward mousedown and mouseup, skip the noisy mousemove:
connect(
listener<Events>(input),
emitter<Events>(renderer),
"mousedown", "mouseup",
);

Create a centralized, type-safe event bus:

import { emitter, listener } from "@thuum/event-emitter/event-target";
type AppEvents = {
"auth:login": { userId: string };
"auth:logout": undefined;
"nav:change": { route: string };
"theme:toggle": undefined;
};
const bus = new EventTarget();
export const publish = emitter<AppEvents>(bus);
export const subscribe = listener<AppEvents>(bus);
// In any module:
import { publish, subscribe } from "./event-bus";
subscribe.on("auth:login", ({ userId }) => {
console.log(`User ${userId} logged in`);
});
publish.emit("auth:login", { userId: "user-42" });

Write modules that accept the abstract interfaces, making them testable and transport-independent:

import type { Emitter, Listener } from "@thuum/event-emitter";
type NotificationEvents = {
notify: { title: string; body: string };
dismiss: { id: string };
};
function createNotificationService(
emit: Emitter<NotificationEvents>,
listen: Listener<NotificationEvents>,
) {
listen.on("dismiss", ({ id }) => {
console.log(`Dismissed notification ${id}`);
});
return {
show(title: string, body: string) {
emit.emit("notify", { title, body });
},
};
}

PathContents
@thuum/event-emitterCore types + connect + all adapter namespaces
@thuum/event-emitter/event-targetemitter, listener for DOM EventTarget
@thuum/event-emitter/message-portemitter, listener for MessagePort/BroadcastChannel
@thuum/event-emitter/node-eventsemitter, listener for Node.js EventEmitter
@thuum/event-emitter/rxjsemitter, listener for RxJS Subject/Observable

ISC