Mini EventEmitter Implementation
A from-scratch event emitter with on, once, off, and emit, including listener return values and once-handler auto-removal, covered by unit tests.
Project
node-event-emitter-mini
Entry point: emitter.mjs
Install
node --versionRun
node emitter.mjsTest
node --test emitter.test.mjsExpected output
The demo emits a greeting event twice, prints the listener payload each time, and reports the remaining listener count.Dependencies
- node >=20
What it does
A from-scratch event emitter with on, once, off, emit, and listenerCount. The unsubscribe function returned by on makes teardown explicit.
export class MiniEmitter {
#listeners = new Map();
on(event, listener) {
if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
this.#listeners.get(event).add(listener);
return () => this.off(event, listener);
}
once(event, listener) {
const wrapped = (...args) => {
this.off(event, wrapped);
return listener(...args);
};
return this.on(event, wrapped);
}
off(event, listener) {
const set = this.#listeners.get(event);
if (set) set.delete(listener);
}
emit(event, ...args) {
const set = this.#listeners.get(event);
if (!set) return 0;
const results = [];
for (const listener of [...set]) results.push(listener(...args));
return results.length;
}
listenerCount(event) {
return this.#listeners.get(event)?.size ?? 0;
}
}
const emitter = new MiniEmitter();
emitter.on('greeting', (name) => console.log('hello ' + name));
const fired = emitter.emit('greeting', 'world');
console.log('listeners=' + emitter.listenerCount('greeting') + ' fired=' + fired);
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MiniEmitter } from './emitter.mjs';
test('emit returns the number of listeners called', () => {
const e = new MiniEmitter();
let hits = 0;
e.on('tick', () => { hits += 1; });
e.on('tick', () => { hits += 10; });
const called = e.emit('tick');
assert.equal(called, 2);
assert.equal(hits, 11);
});
test('once listeners fire exactly one time', () => {
const e = new MiniEmitter();
let count = 0;
e.once('boot', () => { count += 1; });
e.emit('boot');
e.emit('boot');
assert.equal(count, 1);
assert.equal(e.listenerCount('boot'), 0);
});
test('the unsubscribe handle removes the listener', () => {
const e = new MiniEmitter();
const off = e.on('x', () => {});
off();
assert.equal(e.emit('x'), 0);
});
test('removing a listener during emit does not break the loop', () => {
const e = new MiniEmitter();
const seen = [];
e.on('ev', () => seen.push('first'));
const off = e.on('ev', () => { off(); seen.push('second'); });
e.emit('ev');
assert.deepEqual(seen, ['first', 'second']);
});
Run and verify
node --test emitter.test.mjs
node emitter.mjs
Run the tests covering once semantics and mid-emit removal, then the demo emitting a greeting.
Expected Output
The test run should print four passing tests, including the mid-emit removal case that proves the loop over a snapshot of the listener set is safe. The demo’s console.log output should print the greeting payload and then the listener accounting for the single registered listener:
hello world
listeners=1 fired=1
The first line comes from the listener itself (hello plus the emitted argument), and the second line reports that the greeting event still has one listener attached and that the emit call dispatched to exactly one of them — the unsubscribe handle returned by on is what keeps the count honest.
Limitations
No once-with-context, no wildcard or namespace events, and no max-listener guard; it is deliberately minimal.