Skip to content
aviral gupta

// 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

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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 --test

package.json tells Node.js that the .js files are modules; keep it in the folder.

Download learnrun.js

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>)";

// 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.