Warm-up · Activity 1 of 7
Warm-up from B4.2: you wrote process.on('unhandledRejection', listener). What does this program print?
import {EventEmitter} from 'node:events';
console.log(process instanceof EventEmitter, typeof process.on);// B5.1 · ~31 min · Beginner
After this lesson you can create and extend an EventEmitter, add and remove listeners, predict when they run, handle the 'error' event and wait for an event with a promise.
Lesson 1 of 5 in B5 Events, input and first tests
You will be able to
Warm-up · Activity 1 of 7
import {EventEmitter} from 'node:events';
console.log(process instanceof EventEmitter, typeof process.on);Predict · Activity 2 of 7
import {EventEmitter} from 'node:events';
const door = new EventEmitter();
door.on('open', () => console.log('listener 1'));
door.on('open', () => console.log('listener 2'));
console.log('before emit');
door.emit('open');
console.log('after emit');Practice · Activity 3 of 7
door.____('open', () => console.log('first visitor'));Practice · Activity 4 of 7
Practice · Activity 5 of 7
import {EventEmitter} from 'node:events';
const shop = new EventEmitter();
shop.on('sale', (item, price) => console.log(item, price));
console.log(shop.emit('sale', 'tea', 3));
console.log(shop.emit('refund', 'tea'));Brain teaser · Activity 6 of 7
import {EventEmitter} from 'node:events';
let calls = 0;
const count = () => calls++;
const emitter = new EventEmitter();
emitter.on('ping', count);
emitter.on('ping', count);
emitter.emit('ping');
const target = new EventTarget();
target.addEventListener('ping', count);
target.addEventListener('ping', count);
target.dispatchEvent(new Event('ping'));
console.log(calls);Apply · Activity 7 of 7
Check your work against this list
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
Job extends EventEmitter and emits progress after each step, then done, or error when it fails. The program listens with on and once, waits for done with once from node:events, handles the error event of a broken job, and finally removes a listener with off. Watch which listeners are left, and what emit returns before and after off.
main.js
import {EventEmitter, once} from 'node:events';
// A job that reports its progress as events.
class Job extends EventEmitter {
constructor(name, steps) {
super(); // sets up the emitter; must come before this
this.name = name;
this.steps = steps;
}
async run() {
for (let step = 1; step <= this.steps; step++) {
await new Promise((resolve) => setTimeout(resolve, 10));
this.emit('progress', step, this.steps);
}
if (this.name === 'broken') this.emit('error', new Error(this.name + ' failed'));
else this.emit('done', this.name);
}
}
const backup = new Job('backup', 3);
backup.on('progress', (step, total) => console.log('progress ' + step + '/' + total));
backup.once('progress', () => console.log('(only after the first progress event)'));
backup.run();
const [name] = await once(backup, 'done'); // a promise for the next 'done'
console.log('done:', name);
console.log('progress listeners left:', backup.listenerCount('progress'));
const broken = new Job('broken', 1);
broken.on('error', (error) => console.log('error event:', error.message));
await broken.run();
// Remove a listener you no longer need, with the same function.
const bus = new EventEmitter();
const onStop = () => console.log('stopping');
bus.on('stop', onStop);
console.log('emit returns', bus.emit('stop'));
bus.off('stop', onStop);
console.log('emit returns', bus.emit('stop'));
Run it with
node main.jsOutput
progress 1/3
(only after the first progress event)
progress 2/3
progress 3/3
done: backup
progress listeners left: 1
error event: broken failed
stopping
emit returns true
emit returns falseExercise 1 of 2
node:events is Node-only, so write the core of it yourself; it runs in the browser too. createEmitter() returns {on, once, off, emit}. emit(name, ...args) calls each listener of name with args, in the order they were added, and returns true if there were any, false otherwise. off removes one instance of the function. once's listener runs on the next emit only. A listener that removes itself while emit runs must not make the next one be skipped.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
In off, find the function with list.lastIndexOf(fn) and remove it with list.splice(index, 1), only when the index is not -1.
In emit, loop over a copy, [...(listeners.get(name) ?? [])], call fn(...args), and return copy.length > 0.
For once, add a wrapper (...args) => { off(name, wrapper); fn(...args); } with on, so the wrapper removes itself first.
One way to solve it. Yours can look different and still pass the checks.
// A small emitter with the rules of EventEmitter's on, once, off and emit.
export function createEmitter() {
const listeners = new Map(); // event name -> array of functions
function on(name, fn) {
if (!listeners.has(name)) listeners.set(name, []);
listeners.get(name).push(fn);
}
function off(name, fn) {
const list = listeners.get(name) ?? [];
const index = list.lastIndexOf(fn); // the most recently added instance
if (index !== -1) list.splice(index, 1);
}
function once(name, fn) {
const wrapper = (...args) => {
off(name, wrapper);
fn(...args);
};
on(name, wrapper);
}
function emit(name, ...args) {
const list = [...(listeners.get(name) ?? [])]; // a copy: changes wait for the next emit
for (const fn of list) fn(...args);
return list.length > 0;
}
return {on, once, off, emit};
}
const door = createEmitter();
door.on('open', (who) => console.log('hello', who));
door.once('open', () => console.log('first visitor'));
console.log(door.emit('open', 'Ada'), door.emit('open', 'Lin'), door.emit('close'));
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
// A small emitter with the rules of EventEmitter's on, once, off and emit.
export function createEmitter() {
const listeners = new Map(); // event name -> array of functions
function on(name, fn) {
if (!listeners.has(name)) listeners.set(name, []);
listeners.get(name).push(fn);
}
function off(name, fn) {}
function once(name, fn) {
on(name, fn);
}
function emit(name, ...args) {
for (const fn of listeners.get(name) ?? []) fn();
}
return {on, once, off, emit};
}
const door = createEmitter();
door.on('open', (who) => console.log('hello', who));
door.once('open', () => console.log('first visitor'));
console.log(door.emit('open', 'Ada'), door.emit('open', 'Lin'), door.emit('close'));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createEmitter} from './main.js';
test('listeners run in the order they were added, with the arguments of emit', () => {
const e = createEmitter();
const seen = [];
e.on('sale', (item, price) => seen.push('first ' + item + ' ' + price));
e.on('sale', (item) => seen.push('second ' + item));
e.emit('sale', 'tea', 3);
assert.deepEqual(seen, ['first tea 3', 'second tea'], `the listeners saw ${JSON.stringify(seen)}`);
});
test('emit returns true with listeners and false without', () => {
const e = createEmitter();
e.on('sale', () => {});
assert.equal(e.emit('sale'), true, 'emit for an event with a listener should return true');
assert.equal(e.emit('refund'), false, 'emit for an event without listeners should return false');
});
test('off removes one instance of the function', () => {
const e = createEmitter();
let calls = 0;
const count = () => calls++;
e.on('ping', count);
e.on('ping', count);
e.off('ping', count);
e.emit('ping');
assert.equal(calls, 1, `count ran ${calls} times after two on and one off; expected 1`);
e.off('ping', count);
assert.equal(e.emit('ping'), false, 'after removing both instances, emit should return false');
});
test('a once listener runs on the next emit only', () => {
const e = createEmitter();
let calls = 0;
e.once('ring', (n) => (calls += n));
assert.equal(e.emit('ring', 5), true, 'the first emit should find the once listener');
assert.equal(e.emit('ring', 5), false, 'the second emit should find no listener');
assert.equal(calls, 5, `the once listener added up to ${calls}; expected 5`);
});
test('a listener that removes itself does not make the next one be skipped', () => {
const e = createEmitter();
const seen = [];
const first = () => {
seen.push('first');
e.off('open', first);
};
e.on('open', first);
e.on('open', () => seen.push('second'));
e.emit('open');
assert.deepEqual(seen, ['first', 'second'], `the listeners that ran were ${JSON.stringify(seen)}`);
});
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
node main.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 2 of 2
Kettle extends EventEmitter and starts at 20 degrees. Write heat(target): raise the temperature in steps of 20, never past target, and emit 'temperature' with each new value; when it reaches 100, emit 'boiled'. A target above 100 emits 'error' with a RangeError and does not heat. Without an error listener, that error is thrown. Run node main.js, then node --test.
This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.
Check target > 100 first: this.emit("error", new RangeError(...)), then return.
Loop while (this.temperature < target), set this.temperature = Math.min(this.temperature + 20, target), then this.emit("temperature", this.temperature).
After the loop, if this.temperature === 100, this.emit("boiled"). An emit("error") with no listener throws by itself; you write no throw.
One way to solve it. Yours can look different and still pass the checks.
import {EventEmitter} from 'node:events';
// A kettle that reports what it does as events.
export class Kettle extends EventEmitter {
constructor() {
super();
this.temperature = 20;
}
// Heats in steps of 20 degrees up to target, with a 'temperature' event for each
// new value, then 'boiled' at 100. Above 100: an 'error' event, and no heating.
heat(target) {
if (target > 100) {
this.emit('error', new RangeError('cannot heat above 100, asked for ' + target));
return;
}
while (this.temperature < target) {
this.temperature = Math.min(this.temperature + 20, target);
this.emit('temperature', this.temperature);
}
if (this.temperature === 100) this.emit('boiled');
}
}
const kettle = new Kettle();
kettle.on('temperature', (degrees) => console.log(degrees + ' degrees'));
kettle.once('boiled', () => console.log('boiled'));
kettle.on('error', (error) => console.log('error:', error.message));
kettle.heat(100);
kettle.heat(120);
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
import {EventEmitter} from 'node:events';
// A kettle that reports what it does as events.
export class Kettle extends EventEmitter {
constructor() {
super();
this.temperature = 20;
}
// Heats in steps of 20 degrees up to target, with a 'temperature' event for each
// new value, then 'boiled' at 100. Above 100: an 'error' event, and no heating.
heat(target) {
this.temperature = target;
}
}
const kettle = new Kettle();
kettle.on('temperature', (degrees) => console.log(degrees + ' degrees'));
kettle.once('boiled', () => console.log('boiled'));
kettle.on('error', (error) => console.log('error:', error.message));
kettle.heat(100);
kettle.heat(120);
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {Kettle} from './main.js';
test('heat(80) emits temperature 40, 60 and 80', () => {
const kettle = new Kettle();
const seen = [];
kettle.on('temperature', (degrees) => seen.push(degrees));
kettle.heat(80);
assert.deepEqual(seen, [40, 60, 80], `the temperature events were ${JSON.stringify(seen)}`);
});
test('steps never go past the target', () => {
const kettle = new Kettle();
const seen = [];
kettle.on('temperature', (degrees) => seen.push(degrees));
kettle.heat(50);
assert.deepEqual(seen, [40, 50], `the temperature events were ${JSON.stringify(seen)}`);
});
test('boiled is emitted once, when 100 is reached', () => {
const kettle = new Kettle();
let boiled = 0;
kettle.on('boiled', () => boiled++);
kettle.heat(60);
assert.equal(boiled, 0, 'boiled should not be emitted at 60 degrees');
kettle.heat(100);
assert.equal(boiled, 1, `boiled was emitted ${boiled} times; expected 1`);
});
test('above 100, an error event with a RangeError, and no heating', () => {
const kettle = new Kettle();
const errors = [];
kettle.on('error', (error) => errors.push(error));
kettle.heat(120);
assert.equal(errors.length, 1, `there were ${errors.length} error events; expected 1`);
assert.ok(errors[0] instanceof RangeError, 'the error event should carry a RangeError');
assert.equal(kettle.temperature, 20, `the temperature is ${kettle.temperature}; it should stay 20`);
});
test('without an error listener, heat(120) throws the RangeError', () => {
assert.throws(() => new Kettle().heat(120), RangeError, 'heat(120) with no error listener should throw a RangeError');
});
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
node main.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsimport {EventEmitter} from 'node:events';
const job = new EventEmitter();
job.on('done', () => console.log('done'));
job.emit('error', new Error('disk full'));
console.log('still running');
What Node.js prints
Error: disk fullWhy, and the fix
An error event without an error listener is thrown: Node.js prints the error with its stack and the process ends, so still running never appears. Add a listener before anything can fail: job.on('error', (error) => console.error('job failed:', error.message)). Then the error is just an event, and the program carries on.
import {EventEmitter} from 'node:events';
class Job extends EventEmitter {
constructor(name) {
this.name = name;
}
}
const job = new Job('backup');
job.emit('start');
What Node.js prints
ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructorWhy, and the fix
A class that extends another must call super() in its constructor before it touches this; super() is what sets up the emitter. Write super(); as the first line, then this.name = name.
import {EventEmitter} from 'node:events';
const door = new EventEmitter();
door.on('open', () => {
console.log('listeners:', this.listenerCount('open'));
});
door.emit('open');
What Node.js prints
TypeError: Cannot read properties of undefined (reading 'listenerCount')Why, and the fix
In an ordinary function listener, this is the emitter. An arrow function takes this from where it was written, here the top level of a module, where this is undefined. Use function () { … } when you need this, or simply name the emitter: door.listenerCount("open").
JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.