// Intermediate project · about 5 hours of work
Reading list
You build a reading list in two halves that share one module. On the command line, node main.js add Dune --author "Frank Herbert", read 1 and list --unread keep your books in a JSON file. Against a small local JSON server (given), client.js adds and marks books with fetch, and a list view always says what is happening: Loading…, the list, a server problem, or a request that took too long or was replaced. A suite of node --test tests tells you when each part works.
What the finished program does
- books.js exports BookError, a subclass of Error with the name BookError and a field property, and ReadingList, a class that keeps its books private.
- new ReadingList(books) starts from a copy of an array of books {id, title, author, read}. add(title, author) trims both, gives the book the next id (one more than the highest so far), stores it unread and returns a copy of it.
- add throws a BookError for the field title with the message title is required when the title is empty or only spaces; markRead(id) throws a BookError for the field id with the message no book with id <id> for an unknown id.
- markRead(id) sets read to true and returns a copy of the book; list() returns copies of every book in the order added, list({unread: true}) only the unread ones; JSON.stringify(list) writes the array of books.
- store.js: loadList(file) reads the JSON file into a ReadingList and returns an empty one when the file does not exist (ENOENT); any other error is thrown on. saveList(file, list) writes the books as indented JSON.
- main.js reads add <title words> [--author <name>], read <id> and list [--unread] with parseArgs; --file <path> picks the data file (default reading-list.json). It prints Added <book>, Read <book>, or one line per book in the form [x] 1. Dune by Frank Herbert, or No books yet.
- A BookError or an unknown option is printed as reading-list: <message> on stderr, with nothing on stdout and exit code 1; an unknown command prints the usage on stderr with exit code 2.
- client.js: request(url, {method, body, signal}) sends body as JSON with Content-Type: application/json, returns the parsed answer, and throws an ApiError (name ApiError, a status field, the server’s error text as message) for a status that is not ok. addBook(api, title, author) and markRead(api, id) use it.
- createListView(api, render, timeoutMs = 1000) returns load(path = "/books"): it renders Loading…, then the books (one per line) or No books yet.; for an ApiError with status 500 or more it renders The server has a problem. Please try again later.; after timeoutMs it renders The server is too slow. Please try again.
- Each load aborts the load before it, which then renders nothing more, so an older answer never replaces a newer one. Any other error renders Could not load the list: <message>.
Starter layout
- books.js
- Your work: BookError, the ReadingList class and formatBook (given). No files, no network, so it is easy to test.
- store.js
- Your work: loadList and saveList, the JSON file between runs.
- main.js
- Your work: the command line, with parseArgs, on top of books.js and store.js.
- client.js
- Your work: ApiError, request and createListView, the fetch client. addBook and markRead are given.
- server.js
- The local JSON server for the client (node:http, port 0 on 127.0.0.1): GET and POST /books, PATCH /books/<id>, /broken (500) and /slow (300 ms). Given complete.
- main.test.js
- The acceptance tests (node:test). Run them with node --test.
- package.json
- {"type": "module"}, so .js files are ES modules. In the starter download.
- learnrun.js
- The course helper for running main.js; this project’s tests do not need it. In the starter download.
Milestones
Milestone 1
The list and its errors
Write BookError and ReadingList in books.js: a private array, add with the next id, markRead, list with the unread option, and a BookError with its field for bad input.
Checks that pass once this milestone is done:
- add stores a book with the next id
- list returns every book, or only the unread ones
- markRead marks one book as read
- invalid input throws a BookError with its field
Milestone 2
Keep it in a file
Write loadList and saveList in store.js; a missing file is an empty list on the first run.
Checks that pass once this milestone is done:
- the list survives a save and a load, and a missing file is an empty list
Milestone 3
The command line
Parse add, read and list with their options in main.js, save after each change, and report a BookError on stderr with exit code 1.
Checks that pass once this milestone is done:
- node main.js adds, marks and lists books
- node main.js reports a bad id on stderr with exit code 1
Milestone 4
Talk to the server
Write ApiError and request in client.js: a JSON body with its header, and an ApiError for a status that is not ok.
Checks that pass once this milestone is done:
- the client sends JSON and turns an error status into an ApiError
Milestone 5
Show every state
Write createListView: Loading…, the list or No books yet., and the message for a 5xx status.
Checks that pass once this milestone is done:
- the client shows Loading… and then the list
- the client handles a 500 response
Milestone 6
Cancel and give up
Combine a controller per load with AbortSignal.timeout using AbortSignal.any; ignore the AbortError of a replaced load and render the message for a TimeoutError.
Checks that pass once this milestone is done:
- the client cancels a slow request
This project uses parts of Node.js that do not run in the browser, so you build it on your computer.
Build it on your computer
Make a folder with these starter files and ECMAScript 2026, then work through the milestones. Run the acceptance tests at any point with:
Download the starter as one .zip (starter files, main.test.js, package.json and learnrun.js)node --testpackage.json tells Node.js that the .js files are modules; keep it in the folder.
Download learnrun.jsmain.js
// The command line: node main.js add <title> [--author <name>] | read <id> | list [--unread]
import {parseArgs} from "node:util";
import {BookError, formatBook} from "./books.js";
import {loadList, saveList} from "./store.js";
const USAGE = "Usage: node main.js add <title> [--author <name>] | read <id> | list [--unread] (--file <path>)";
// TODO: parse --file, --author and --unread with parseArgs, run the command,
// print BookErrors as "reading-list: <message>" on stderr with exit code 1.
console.error(USAGE);
process.exitCode = 2;
books.js
// The reading list itself: pure logic, no files, no network.
// A typed error for bad input: field names what was wrong ("title" or "id").
export class BookError extends Error {
// TODO: constructor(field, message): message, name "BookError" and field
}
export class ReadingList {
constructor(books = []) {
// TODO: keep a copy of the books
}
add(title, author = "") {
// TODO: refuse an empty title, give the next id, return the new book
throw new Error("add is not written yet");
}
markRead(id) {
// TODO: set read to true, or throw a BookError for an unknown id
throw new Error("markRead is not written yet");
}
list({unread = false} = {}) {
// TODO: every book, or only the unread ones
return [];
}
toJSON() {
return this.list();
}
}
export const formatBook = (book) =>
`${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;
store.js
// Keeps the list in a JSON file between runs.
import {readFile, writeFile} from "node:fs/promises";
import {ReadingList} from "./books.js";
export async function loadList(file) {
// TODO: read and parse the file; a missing file (ENOENT) is an empty list
throw new Error("loadList is not written yet");
}
export async function saveList(file, list) {
// TODO: write the list as JSON
throw new Error("saveList is not written yet");
}
client.js
// The fetch client for the reading-list server.
import {formatBook} from "./books.js";
export class ApiError extends Error {
// TODO: constructor(status, message): message, name "ApiError" and status
}
// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
// TODO
throw new Error("request is not written yet");
}
export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});
export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});
// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
return async function load(path = "/books") {
// TODO: Loading…, the list or No books yet., a message for a 5xx and for a timeout,
// and cancel the load before this one.
};
}
server.js
// A small local JSON server for the reading list, made with node:http. You do not need to change it.
import {createServer} from "node:http";
export async function startServer(books = []) {
let nextId = books.length + 1;
const server = createServer(async (request, response) => {
const json = (status, value) => {
response.writeHead(status, {"Content-Type": "application/json"});
response.end(JSON.stringify(value));
};
let body = "";
for await (const chunk of request) body += chunk;
const {method, url} = request;
const match = url.match(/^\/books\/(\d+)$/);
if (method === "GET" && url === "/books") json(200, books);
else if (method === "POST" && url === "/books") {
const {title, author} = JSON.parse(body || "{}");
if (typeof title !== "string" || title.trim() === "") return json(400, {error: "title is required"});
const book = {id: nextId++, title: title.trim(), author: author ?? "", read: false};
books.push(book);
json(201, book);
} else if (method === "PATCH" && match) {
const book = books.find((b) => b.id === Number(match[1]));
if (!book) return json(404, {error: "no such book"});
book.read = JSON.parse(body || "{}").read === true;
json(200, book);
} else if (url === "/broken") json(500, {error: "database is down"});
else if (url === "/slow") {
// answers after 300 ms, unless the client gave up
const timer = setTimeout(() => json(200, books), 300);
response.on("close", () => clearTimeout(timer));
} else json(404, {error: "not found"});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
return {
url: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((resolve) => server.close(resolve))
};
}
package.json
{
"type": "module"
}
Acceptance tests
The project is done when every check in main.test.js passes. Read them before you start: they are the spec, written as code.
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {spawnSync} from 'node:child_process';
import {mkdtempSync, readFileSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {ReadingList, BookError} from './books.js';
import {loadList, saveList} from './store.js';
import {request, addBook, markRead, createListView, ApiError} from './client.js';
import {startServer} from './server.js';
// A fresh data file per test, in its own temporary folder.
const dataFile = () => join(mkdtempSync(join(tmpdir(), 'reading-list-')), 'list.json');
const cli = (...args) => spawnSync(process.execPath, ['main.js', ...args], {encoding: 'utf8'});
const DUNE = {id: 1, title: 'Dune', author: 'Frank Herbert', read: false};
/** The error that fn throws (or its promise rejects with); the test fails when there is none. */
async function caught(fn) {
try {
await fn();
} catch (error) {
return error;
}
assert.fail('nothing was thrown');
}
/** Starts the server with books, gives its address to body, and always closes it. */
async function withServer(books, body) {
const server = await startServer(books);
try {
await body(server.url);
} finally {
await server.close();
}
}
test('add stores a book with the next id', () => {
const list = new ReadingList([DUNE]);
const book = list.add(' Emma ', 'Jane Austen');
assert.deepEqual(book, {id: 2, title: 'Emma', author: 'Jane Austen', read: false});
assert.equal(list.list().length, 2, 'the list should now hold two books');
});
test('list returns every book, or only the unread ones', () => {
const list = new ReadingList([DUNE, {id: 2, title: 'Emma', author: '', read: true}]);
assert.deepEqual(list.list().map((b) => b.id), [1, 2]);
assert.deepEqual(list.list({unread: true}).map((b) => b.id), [1]);
});
test('markRead marks one book as read', () => {
const list = new ReadingList([DUNE]);
assert.equal(list.markRead(1).read, true, 'markRead should return the updated book');
assert.equal(list.list()[0].read, true, 'the book in the list should be read');
});
test('invalid input throws a BookError with its field', async () => {
const list = new ReadingList([DUNE]);
const empty = await caught(() => list.add(' '));
assert.ok(empty instanceof BookError, `add(' ') threw ${empty.name}, not a BookError`);
assert.equal(empty.field, 'title');
assert.equal(empty.message, 'title is required');
const missing = await caught(() => list.markRead(9));
assert.ok(missing instanceof BookError, `markRead(9) threw ${missing.name}, not a BookError`);
assert.equal(missing.field, 'id');
assert.equal(missing.message, 'no book with id 9');
});
test('the list survives a save and a load, and a missing file is an empty list', async () => {
const file = dataFile();
assert.deepEqual((await loadList(file)).list(), [], 'a missing file should give an empty list');
const list = new ReadingList();
list.add('Dune', 'Frank Herbert');
await saveList(file, list);
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')), [DUNE], 'the file should hold the books as JSON');
assert.deepEqual((await loadList(file)).list(), [DUNE]);
});
test('node main.js adds, marks and lists books', () => {
const file = dataFile();
assert.equal(cli('add', 'Dune', '--author', 'Frank Herbert', '--file', file).stdout, 'Added [ ] 1. Dune by Frank Herbert\n');
cli('add', 'The', 'Dispossessed', '--file', file);
assert.equal(cli('read', '1', '--file', file).stdout, 'Read [x] 1. Dune by Frank Herbert\n');
const r = cli('list', '--unread', '--file', file);
assert.equal(r.status, 0, `the exit code was ${r.status}; stderr: ${r.stderr}`);
assert.equal(r.stdout, '[ ] 2. The Dispossessed\n');
});
test('node main.js reports a bad id on stderr with exit code 1', () => {
const r = cli('read', '9', '--file', dataFile());
assert.equal(r.status, 1, `the exit code was ${r.status}`);
assert.equal(r.stderr, 'reading-list: no book with id 9\n');
assert.equal(r.stdout, '', 'nothing should be printed on stdout');
});
test('the client sends JSON and turns an error status into an ApiError', async () => {
await withServer([DUNE], async (api) => {
assert.deepEqual(await addBook(api, 'Emma', 'Jane Austen'), {id: 2, title: 'Emma', author: 'Jane Austen', read: false});
assert.equal((await markRead(api, 2)).read, true);
const error = await caught(() => request(api + '/books/9', {method: 'PATCH', body: {read: true}}));
assert.ok(error instanceof ApiError, `request threw ${error.name}, not an ApiError`);
assert.equal(error.status, 404);
assert.equal(error.message, 'no such book');
});
});
test('the client shows Loading… and then the list', async () => {
await withServer([DUNE], async (api) => {
const shown = [];
await createListView(api, (text) => shown.push(text))();
assert.deepEqual(shown, ['Loading…', '[ ] 1. Dune by Frank Herbert']);
});
});
test('the client handles a 500 response', async () => {
await withServer([], async (api) => {
const shown = [];
await createListView(api, (text) => shown.push(text))('/broken');
assert.deepEqual(shown, ['Loading…', 'The server has a problem. Please try again later.']);
});
});
test('the client cancels a slow request', async () => {
await withServer([DUNE], async (api) => {
const shown = [];
await createListView(api, (text) => shown.push(text), 50)('/slow');
assert.deepEqual(shown, ['Loading…', 'The server is too slow. Please try again.'], 'a slow answer should time out');
shown.length = 0;
const load = createListView(api, (text) => shown.push(text));
await Promise.all([load('/slow'), load('/books')]);
await new Promise((resolve) => setTimeout(resolve, 400)); // longer than /slow takes
assert.deepEqual(shown, ['Loading…', 'Loading…', '[ ] 1. Dune by Frank Herbert'], 'a newer load should cancel the older one');
});
});
Run the finished program
node main.js add Dune --author "Frank Herbert"Reference solution
Try the milestones first. This solution passes every acceptance test and the type checker.
main.js
// The command line: node main.js add <title> [--author <name>] | read <id> | list [--unread]
import {parseArgs} from "node:util";
import {BookError, formatBook} from "./books.js";
import {loadList, saveList} from "./store.js";
const USAGE = "Usage: node main.js add <title> [--author <name>] | read <id> | list [--unread] (--file <path>)";
try {
const {values, positionals} = parseArgs({
options: {
file: {type: "string", default: "reading-list.json"},
author: {type: "string", default: ""},
unread: {type: "boolean", default: false}
},
allowPositionals: true
});
const [command, ...words] = positionals;
const list = await loadList(values.file);
if (command === "add") {
const book = list.add(words.join(" "), values.author);
await saveList(values.file, list);
console.log(`Added ${formatBook(book)}`);
} else if (command === "read") {
const book = list.markRead(Number(words[0]));
await saveList(values.file, list);
console.log(`Read ${formatBook(book)}`);
} else if (command === "list") {
const books = list.list({unread: values.unread});
console.log(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
} else {
console.error(USAGE);
process.exitCode = 2;
}
} catch (error) {
if (!(error instanceof BookError) && error.code !== "ERR_PARSE_ARGS_UNKNOWN_OPTION") throw error;
console.error(`reading-list: ${error.message}`);
process.exitCode = 1;
}
books.js
// The reading list itself: pure logic, no files, no network.
export class BookError extends Error {
constructor(field, message) {
super(message);
this.name = "BookError";
this.field = field;
}
}
export class ReadingList {
#books;
constructor(books = []) {
this.#books = books.map((book) => ({...book}));
}
add(title, author = "") {
if (typeof title !== "string" || title.trim() === "") {
throw new BookError("title", "title is required");
}
const id = Math.max(0, ...this.#books.map((book) => book.id)) + 1;
const book = {id, title: title.trim(), author: author.trim(), read: false};
this.#books.push(book);
return {...book};
}
markRead(id) {
const book = this.#books.find((b) => b.id === id);
if (!book) throw new BookError("id", `no book with id ${id}`);
book.read = true;
return {...book};
}
list({unread = false} = {}) {
return this.#books.filter((book) => !unread || !book.read).map((book) => ({...book}));
}
toJSON() {
return this.list();
}
}
export const formatBook = (book) =>
`${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;
store.js
// Keeps the list in a JSON file between runs.
import {readFile, writeFile} from "node:fs/promises";
import {ReadingList} from "./books.js";
export async function loadList(file) {
try {
return new ReadingList(JSON.parse(await readFile(file, "utf8")));
} catch (error) {
if (error.code === "ENOENT") return new ReadingList(); // the first run: no file yet
throw error;
}
}
export async function saveList(file, list) {
await writeFile(file, JSON.stringify(list, null, 2) + "\n");
}
client.js
// The fetch client for the reading-list server.
import {formatBook} from "./books.js";
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
const response = await fetch(url, {
method,
signal,
headers: body === undefined ? {} : {"Content-Type": "application/json"},
body: body === undefined ? undefined : JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
return data;
}
export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});
export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});
// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
let controller = null;
return async function load(path = "/books") {
controller?.abort();
controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
render("Loading…");
try {
const books = await request(api + path, {signal});
render(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
} catch (error) {
if (error.name === "AbortError") return; // a newer load replaced this one
if (error.name === "TimeoutError") render("The server is too slow. Please try again.");
else if (error instanceof ApiError && error.status >= 500) render("The server has a problem. Please try again later.");
else render(`Could not load the list: ${error.message}`);
}
};
}
server.js
// A small local JSON server for the reading list, made with node:http. You do not need to change it.
import {createServer} from "node:http";
export async function startServer(books = []) {
let nextId = books.length + 1;
const server = createServer(async (request, response) => {
const json = (status, value) => {
response.writeHead(status, {"Content-Type": "application/json"});
response.end(JSON.stringify(value));
};
let body = "";
for await (const chunk of request) body += chunk;
const {method, url} = request;
const match = url.match(/^\/books\/(\d+)$/);
if (method === "GET" && url === "/books") json(200, books);
else if (method === "POST" && url === "/books") {
const {title, author} = JSON.parse(body || "{}");
if (typeof title !== "string" || title.trim() === "") return json(400, {error: "title is required"});
const book = {id: nextId++, title: title.trim(), author: author ?? "", read: false};
books.push(book);
json(201, book);
} else if (method === "PATCH" && match) {
const book = books.find((b) => b.id === Number(match[1]));
if (!book) return json(404, {error: "no such book"});
book.read = JSON.parse(body || "{}").read === true;
json(200, book);
} else if (url === "/broken") json(500, {error: "database is down"});
else if (url === "/slow") {
// answers after 300 ms, unless the client gave up
const timer = setTimeout(() => json(200, books), 300);
response.on("close", () => clearTimeout(timer));
} else json(404, {error: "not found"});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
return {
url: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((resolve) => server.close(resolve))
};
}
Take it further
- Add a remove <id> command, and a matching DELETE /books/<id> route to server.js with a removeBook function in client.js.
- Put the client on a page: an index.html with a list and a Reload button, where render writes into an element instead of printing.
- Retry a 503 answer up to three times with a growing wait, as in lesson I6.2, but never retry a 4xx.
- Write the data file atomically: write to a temporary file in the same folder, then rename it over the old one.
Projects are practice: your checks run in your browser or on your computer and never count toward a certificate.
Sources
- Test runner (Node.js v24 documentation)
- util.parseArgs([config]) (Node.js v24 documentation)
- File system: fsPromises.readFile and writeFile (Node.js v24 documentation)
- Error (MDN)
- Using the Fetch API (MDN)
- AbortSignal: any() static method (MDN)
- AbortSignal: timeout() static method (MDN)
Last reviewed October 5, 2026
Back to the JavaScript course