How a click reaches a database and comes back

Start hereFrom click to row and back again

Press Buy on a vending machine page and the number of snacks left changes a moment later. In this tutorial you build that whole machine, one piece at a time, and you watch a click travel from the page to a database and back.

You will build

  • A database that stores the machine's slots.
  • A server that answers requests about those slots.
  • A page with a Buy button.
  • A short script that connects the page to the server.

You will learn

  • What a database, a server and an API each do.
  • How a click becomes a request, a query and a changed screen.
  • How to watch that traffic in your browser.
The finished vending machine page: four slots, each with a name, a price, a stock count and a Buy button. One slot is sold out and greyed.
The page you build. Three slots have stock and one is sold out.

You need

  • Node.js 22.18 or newer, which includes npm. From that version Node can run a TypeScript file directly.
  • A terminal and a code editor.
  • A browser with developer tools. Chrome, Edge and Firefox all have them.
  • Two packages, installed with npm: the TypeScript compiler and the type descriptions for Node. You use both only while you develop.

In this edition everything is TypeScript: the server, the setup script, the database code and the page's script. TypeScript is JavaScript with types added, which are labels that say what kind of value each variable holds, so that a tool can catch mistakes before the program runs. The page itself is plain HTML and CSS, and the script is about fifty lines.

How to use this page

  • Work through Steps 1 to 6 in order. Each step has a goal, the code to make, a check, and a short explanation.
  • Code appears in small pieces, with the explanation beside each piece.
  • A badge such as Station 4 shows where a piece of code sits in the journey from Step 1, with the station before it and the station after it.
  • A box headed A prediction to make first asks you to guess before you read on. A wrong guess is fine, because the gap between your guess and the answer is where you learn.
  • A box marked Optional holds extra detail that you can skip.
  • The Going deeper sections after Step 6 cover HTTP, experiments and design choices.

Try it firstThe finished machine, running in your browser

The vending machine. The page is the real one from the project, and a simulated server answers its requests. If nothing appears, open the machine on its own page.
Optional:What is real here, and what is simulated

A web page cannot open a network port, so no real server program is running. Five real things are running.

  • The data layer and the route table are copied out of the real server.ts when this page is built, with the types removed.
  • The real setup-db.ts creates the table.
  • sql.js supplies a real SQLite database in the page's memory.
  • The page's own script, compiled from client/app.ts, is the file you will write in Step 5.
  • A service worker, a script that sits between a page and the network, catches each request whose address starts with /api/ and hands it to the code above.

The simulated part is the wire. No bytes travel between two programs, so Stations 3 and 9 of the journey in Step 1 are imitated. Two small files stand in for what a browser lacks: a wrapper, a small layer of code around another tool, that gives sql.js the interface of Node's SQLite module, and a replacement for the HTTP layer. The second is possible because the layers were kept apart.

The Station badges beside the code point to the journey that Step 1 introduces.

ts/node-sqlite-shim.jsthe stand-in for node:sqlite
// Node has a built-in SQLite module called node:sqlite, and a browser does not.
// sql.js is SQLite compiled to run in a browser. This file gives the code from
// server.ts the same small interface that node:sqlite offers, backed by sql.js.

// Each database "file" lives in memory, and opening the same name twice returns
// the same database, so the setup script and the server share one machine.
const openDatabases = new Map();

Node has a built-in SQLite module, node:sqlite, and a browser has none. sql.js is the SQLite engine compiled to WebAssembly, a compact format browsers run, and it gives us a real SQLite database inside the page. Its methods have different names from Node's, so this file wraps sql.js in the small interface that server.ts expects.

The map keeps each database in memory under a file name. Opening the same name twice returns the same database, which is how the setup script and the server share one machine, as they share vending.db on disk.

class StatementSync {
  constructor(database, sqlText) {
    this.database = database;
    this.sqlText = sqlText;
    // Preparing a statement checks the SQL straight away, as node:sqlite does.
    database.prepare(sqlText).free();
  }

A statement object holds one piece of SQL. The constructor, the function that runs when a statement object is created, prepares the SQL at once, so a mistake in the SQL fails when the server starts and not on the first click, as with the real module.

  // Return every matching row as an object.
  all(...values) {
    const statement = this.database.prepare(this.sqlText);
    try {
      statement.bind(values);
      const rows = [];
      while (statement.step()) {
        rows.push(statement.getAsObject());
      }
      return rows;
    } finally {
      statement.free();
    }
  }

  // Return the first matching row, or undefined when nothing matched.
  get(...values) {
    return this.all(...values)[0];
  }

all runs the statement with the values the caller passes and collects the rows into an array of objects. The values travel separately from the SQL, so placeholders protect this demo from injection as they do the real server. The finally block (code that runs whether or not an error happened) frees the statement even if something goes wrong, because sql.js keeps statements in memory until we release them. get is all with only the first row kept.

  // Run a statement that changes data, and report how many rows it changed.
  run(...values) {
    this.database.run(this.sqlText, values);
    return { changes: this.database.getRowsModified(), lastInsertRowid: 0 };
  }
}

run executes a statement that changes data. SQLite counts the rows changed, and we return that count as changes, the name that buySlot in server.ts reads to find out whether the purchase worked.

class DatabaseSync {
  constructor(fileName) {
    if (!openDatabases.has(fileName)) {
      openDatabases.set(fileName, new SQL.Database());
    }
    this.database = openDatabases.get(fileName);
  }

  exec(sqlText) {
    this.database.exec(sqlText);
  }

  prepare(sqlText) {
    return new StatementSync(this.database, sqlText);
  }

  // The data must survive a close, because the browser has no file to reopen.
  close() {}
}

The database class has only the three methods our code calls. exec runs SQL, prepare makes a statement, and close does nothing, because a closed in-memory database could never be reopened.

ts/adapter.tsthe replacement for the HTTP layer
// A stand-in for the HTTP layer of server.ts, for use inside a browser.
//
// A browser cannot open a network port, so the real HTTP layer (the code that
// uses node:http) cannot run here. The data layer and the route layer do not
// need a network, so the build copies them out of server.ts unchanged, and
// this file replaces only the top layer.

// The pieces of server.ts that this file needs.
type Machine = {
  ROUTES: { httpMethod: string; pathPattern: RegExp; handler: (...capturedText: string[]) => unknown }[];
  BadRequest: new (message: string) => Error;
  NoSuchSlot: new (message: string) => Error;
  SoldOut: new (message: string) => Error;
};

type Answer = { status: number; body: string };

This file is typed like the rest of the project. The Machine type lists the four things the adapter takes from server.ts: the route table and the three error classes.

The real HTTP layer uses node:http, which needs a network, so we write a replacement for that layer only.

function createAnswerer(machine: Machine) {
  // Answer one request and return a status code and the body text.
  return function answer(httpMethod: string, urlPath: string): Answer {
    try {

The function receives the machine and returns an answer function, which takes a method and a path and returns a status and a body. That is the whole job of an HTTP layer, once the network part is removed.

      // The same route table that the real server uses. The first match wins.
      for (const route of machine.ROUTES) {
        const pathMatch = route.pathPattern.exec(urlPath);
        if (route.httpMethod === httpMethod && pathMatch !== null) {
          return { status: 200, body: JSON.stringify(route.handler(...pathMatch.slice(1))) };
        }
      }
      return { status: 404, body: JSON.stringify({ error: "No such route." }) };
    } catch (error) {

The loop is the one from the real answerRequest. The loop walks the real ROUTES table, and the first matching row calls the real handler. The data layer then runs real SQL on a real SQLite database, which here lives in the page's memory.

      // The same translation from errors to status codes as the real server.
      if (error instanceof machine.BadRequest) {
        return { status: 400, body: JSON.stringify({ error: error.message }) };
      } else if (error instanceof machine.NoSuchSlot) {
        return { status: 404, body: JSON.stringify({ error: error.message }) };
      } else if (error instanceof machine.SoldOut) {
        return { status: 409, body: JSON.stringify({ error: error.message }) };
      }
      return { status: 500, body: JSON.stringify({ error: "Something went wrong on the server." }) };
    }
  };
}

These lines repeat the translation from errors to status codes, because that translation belonged to the layer we replaced. A request for B2, which has no stock, ends here as a 409 with the same message the real server sends.

The real server's headers include Date and Connection. This reply has neither, because no real server wrote it.

  • Want to change the pages, the SQL or the server and watch each request travel? The workbench shows the browser, the server and the database side by side, with a timeline.
  • When you are ready, start with Step 1. In Step 6 you run a real server on your own computer.

Step 1Follow one click

Press Buy on slot A1. The screen changes after a few milliseconds, and those milliseconds hold twelve stops, which this tutorial calls stations. Ten words appear on the diagram, so here they are first, in plain terms. Each one gets a fuller explanation in the step that builds it.

  • request: A message from the browser that says what it wants.
  • response: The message that the server sends back.
  • fetch: The browser function that sends an HTTP request from a script and returns a promise.
  • route table: A list that pairs each method and path pattern with the function that handles it.
  • handler: The function that answers one kind of request.
  • SQL: Structured Query Language. The language for asking a relational database to find or change rows.
  • connection: An open line of communication between a program and a database.
  • transaction: A group of database steps that either all happen or none happen.
  • JSON: JavaScript Object Notation. A text format for structured data, used by most web APIs.
  • DOM: Document Object Model. The browser's live tree of objects, one for every element on the page.

Use the buttons under the diagram to step through the stations. In the diagram and in the text, a dotted underline marks a key word, and hovering over it shows what it means.

BrowserHTML, CSS and a scriptHTML: The language that describes what is on a page: headings, paragraphs, buttons. CSS: The language that describes how a page looks: colours, sizes and layout.Serverserver: A program that waits for requests and answers them.A Node.js programNode: A program that runs JavaScript and TypeScript outside a browser.DatabaseSQLite fileSQLite: A database that lives in one ordinary file and needs no separate server.1The browser notices the clickPOST /api/slots/A1/buy2The script builds a requestrequest: A message from the browser that says what it wants.3The request travels to port 8000request: A message from the browser that says what it wants. port: A number that says which program on a computer should receive a message.4The server chooses a handlerserver: A program that waits for requests and answers them. handler: The function that answers one kind of request.5The handler checks the inputhandler: The function that answers one kind of request.UPDATE ... SET stock = stock - 16The handler sends SQL to the databasehandler: The function that answers one kind of request. SQL: Structured Query Language. The language for asking a relational database to find or change rows.7The database changes the rowrow: One record in a table, such as one slot of the machine.1 row changedrow: One record in a table, such as one slot of the machine.8The handler packages the resulthandler: The function that answers one kind of request.200 OK with a JSON bodyJSON: JavaScript Object Notation. A text format for structured data, used by most web APIs.9The server sends a responseserver: A program that waits for requests and answers them. response: The message that the server sends back.10The reply reaches the script11The script changes the DOMDOM: Document Object Model. The browser's live tree of objects, one for every element on the page.12The browser repaints the screen
One click, twelve stations. Stations 1, 2, 10, 11 and 12 happen in the browser, Station 3 is the network between the two programs, Stations 4 to 9 happen in the server, and the database answers at Stations 6 and 7. The buttons step through the stations one at a time and show what is travelling at each one. Download this diagram as SVG
Reference:The twelve stations as a list
  1. The browser notices the click. The page has a script that listens for clicks. The script wakes up when you press Buy. A click is one kind of event, a message the browser creates whenever something happens on the page.
  2. The script builds a request. The script sends a message to the server, called a request. Here the request says please buy one item from slot A1. The script sends it with fetch, a function that the browser provides.
  3. The request travels. The message goes to an address with two parts: the host, which is the computer, and the port, which is the program on that computer. Here both are on your own machine.
  4. The server chooses a handler. The server is a program that waits for requests. The server reads the method and the path of the request, and picks the function that deals with that request. That function is called the handler.
  5. The handler checks the input. The handler checks that the slot id looks right, such as A1. Anything else is turned away with a message. Checking input like this is called validation.
  6. The handler sends SQL to the database. SQL is the language for asking a database to find or change rows. The handler sends one SQL statement through a connection, which is an open line between the program and the database.
  7. The database changes the row. The database takes one away from the stock and says how many rows it changed. The database does this as a transaction, which means the change either happens completely or not at all.
  8. The handler packages the result. The result becomes JSON, a text format that other programs can read. Only bytes can cross a network, so structured data has to become text first. This step is called serialising.
  9. The server sends a response. The response holds a status code that says how it went (200 means it worked), some headers that describe the message, and a body that carries the JSON.
  10. The reply reaches the script. The script receives the JSON and turns it back into an object. Waiting for a reply takes time, so fetch gives the script a promise, which stands for a value that has not arrived yet.
  11. The script changes the DOM. The DOM is the browser's live tree of the elements on the page. The script edits that tree: new text, or a button that becomes disabled.
  12. The browser repaints the screen. The browser notices that the tree has changed and redraws. You write no code for this step, and that is why the screen updates.

Step 2Build the database

Two ideas about TypeScript

  • A type says what kind of value a variable holds, such as a number or a text. A tool called the type checker reads these labels and reports mistakes before the program runs.
  • The labels exist only for the checker. Before the program runs they are erased, and ordinary JavaScript remains.
  • Node 22.18 and newer can run a TypeScript file directly by erasing the labels as it loads the file. That is type stripping, and it is why node src/server.ts needs no build step.
  • A browser cannot do this, so the page script goes through the compiler tsc, which writes a JavaScript file. The server uses stripping, and the page script uses the compiler.

Why a database?

A page forgets everything when it reloads, and a server forgets everything when it stops. The stock count has to live somewhere that outlasts both, and every visitor has to see the same count. A text file could hold the numbers, but then you would solve several hard problems yourself. A database has already solved them. A relational database keeps its data in tables of rows and columns.

A database gives you five things:

  • Persistence. The data survives when the programs stop.
  • Structure. A schema declares the tables, the columns and the type of each column.
  • Queries. SQL asks for exactly the rows you want, and the database finds them quickly.
  • Safe changes. A transaction makes a group of changes all-or-nothing, and keeps simultaneous users from spoiling each other's work.
  • Rules. A constraint such as CHECK (stock >= 0) makes the database refuse bad data, even when a bug in your code tries to write it.

The machine needs one table. Its primary key, slot_id, is a column whose value differs in every row, so it identifies exactly one row.

slot_iditem_nameprice_centsstock
A1Sparkling Water1758
A2Salted Peanuts1255
B1Chocolate Bar2003
B2Oat Biscuits1500
Optional:Why the price is a whole number of cents

Computers store fractions in binary, and many decimal fractions cannot be written exactly in binary. In most languages 0.1 + 0.2 gives 0.30000000000000004. A shop that adds up prices like that will be wrong by a cent sooner or later. Whole numbers are exact, so the table stores 175 cents and the page converts to euros only when it shows the price.

Optional:Where SQLite fits

SQLite is a database that lives inside a library that your program loads. SQLite keeps everything in one ordinary file and needs no installation and no separate server. Larger systems such as PostgreSQL and MySQL run as their own server programs. All of them speak SQL, so what you learn here carries over. SQLite suits a first lesson the way a quiet car park suits a first driving lesson: nothing gets in the way.

  1. Create a folder called vending-ts and open a terminal in it.
  2. Create package.json with the code below, then run npm install.
  3. Create a folder src, and in it a file setup-db.ts with the second code block.
  4. Run npm run setup-db.
package.jsonin vending-ts/
{
  "name": "vending-machine-ts",
  "private": true,
  "type": "module",
  • package.json is the identity card of a Node project.
  • private stops you from publishing the project to the public npm registry (the shared online store of packages) by accident.
  • "type": "module" tells Node that our files use import and export, the modern module system.
  "scripts": {
    "setup-db": "node src/setup-db.ts",
    "build": "tsc -p client",
    "check": "tsc -p src",
    "start": "node src/server.ts"
  },

Scripts are named shortcuts, run with npm run name (and npm start for the one called start). They record the exact commands the project needs, so nobody has to remember them.

  • node src/server.ts works because Node can run TypeScript directly.
  • tsc is the TypeScript compiler. check only looks for type errors in the server files, and build turns the page script into JavaScript for the browser.
  "devDependencies": {
    "@types/node": "^22.20.5",
    "typescript": "^7.0.2"
  }
}
  • These packages are needed only while you develop. There is no dependencies list, because the running server uses only what Node already contains.
  • typescript is the compiler.
  • @types/node describes Node's built-in modules as types, so the compiler knows what node:http and node:sqlite offer.
  • The caret in ^7.0.2 allows newer versions with the same first (major) number, so 7.1 is allowed and 8.0 is not.
src/setup-db.tsin vending-ts/src/
// Create the vending machine database and fill it with a few snacks.
//
// Run this file once before you start the server.
// Running it again resets the machine to its starting state.

import path from "node:path";
import { DatabaseSync } from "node:sqlite";

// The database is one ordinary file in the project folder.
// import.meta.dirname is the folder that holds this file, which is src,
// so ".." leads to the project folder above it.
const DATABASE_FILE = path.join(import.meta.dirname, "..", "vending.db");

The top of the file says what it is for and imports two tools.

  • Modules named node:... are built into Node, so nothing needs installing.
  • path builds file locations. DatabaseSync is Node's driver for SQLite, a library that knows how to talk to one kind of database.
  • Sync means its calls wait for the database and return the answer directly, with no await. (await is how JavaScript waits for something slow. Step 5 explains it.)
  • The database is one file, vending.db. import.meta.dirname is the folder that holds this script, so the file lands in the project folder wherever you run the command from.
// The schema describes the shape of our data: one table with four columns.
// The comments after the double dashes are SQL comments.
const SCHEMA = `
DROP TABLE IF EXISTS inventory;

CREATE TABLE inventory (
    slot_id     TEXT    PRIMARY KEY,                           -- identifies one slot, for example A1
    item_name   TEXT    NOT NULL,                              -- every slot must have a name
    price_cents INTEGER NOT NULL CHECK (price_cents >= 0),     -- whole cents, never decimals
    stock       INTEGER NOT NULL CHECK (stock >= 0)            -- the database refuses negative stock
);
`;

This text is the schema, written in SQL inside a template literal, a string in backticks that may run over several lines.

  • DROP TABLE IF EXISTS deletes any old table, so running the script again resets the machine. That is handy in a classroom and dangerous anywhere else.
  • slot_id is the primary key. The database refuses a second row with the same value and builds an index for fast lookups.
  • The two CHECK lines are constraints. The database enforces them even when our code has a bug.
  • Money is stored as whole cents.
// Each entry is one row: [slot_id, item_name, price_cents, stock].
// The type after the colon says "a list of lists, each holding two strings
// and two numbers". The Oat Biscuits start with no stock so that we can test
// a sold-out slot.
const STARTING_STOCK: [string, string, number, number][] = [
  ["A1", "Sparkling Water", 175, 8],
  ["A2", "Salted Peanuts", 125, 5],
  ["B1", "Chocolate Bar", 200, 3],
  ["B2", "Oat Biscuits", 150, 0],
];

This is our first TypeScript type. [string, string, number, number][] reads as a list of rows, each a group of two strings followed by two numbers. If we wrote a price as text or left out the stock, the compiler would mark the line before we ran anything.

The Oat Biscuits start with zero stock on purpose, so that you can see a sold-out slot without buying everything first.

// Opening the database opens the file, and creates it if it does not exist.
const database = new DatabaseSync(DATABASE_FILE);

// A transaction groups the work: either all of it happens or none of it does.
database.exec("BEGIN");
database.exec(SCHEMA);
  • We open a connection. For SQLite this opens the file, and creates it if it does not exist yet.
  • BEGIN starts a transaction, and exec runs the schema.
  • Node's SQLite can undo even a DROP TABLE and a CREATE TABLE inside a transaction, so the table and the inserts below stand or fall together.
// A prepared statement is SQL that the database reads once and can run many
// times. The question marks are placeholders, and the values travel to the
// database separately from the SQL text.
const insertSlot = database.prepare(
  "INSERT INTO inventory (slot_id, item_name, price_cents, stock) VALUES (?, ?, ?, ?)",
);
for (const slotRow of STARTING_STOCK) {
  // The three dots spread the four values of the row into four arguments.
  insertSlot.run(...slotRow);
}
database.exec("COMMIT");
Stations 6 to 7 The handler sends SQL to the database, then the database changes the row

A prepared statement is SQL that the database has read and checked once, and that we can then run many times.

  • The question marks are placeholders. The SQL text and the values reach the database separately, and the experiments after Step 6 show why that matters.
  • The three dots in insertSlot.run(...slotRow) spread the four values of a row into four arguments.
  • After the loop, COMMIT makes all four rows permanent.
// Read the rows back so that we can see what was stored.
const storedRows = database
  .prepare("SELECT slot_id, item_name, price_cents, stock FROM inventory ORDER BY slot_id")
  .all();
for (const storedRow of storedRows) {
  console.log(Object.values(storedRow));
}

database.close();

Finally we read the rows back to see what was stored, and close the connection. all() returns every matching row as an array of objects, and Object.values turns one object into the list of its values.

Expected output

[ 'A1', 'Sparkling Water', 175, 8 ]
[ 'A2', 'Salted Peanuts', 125, 5 ]
[ 'B1', 'Chocolate Bar', 200, 3 ]
[ 'B2', 'Oat Biscuits', 150, 0 ]

Step 3Build the server and its API

Two programs, one conversation

  • A client starts a conversation. Your browser is the client.
  • A server waits for conversations to start. Your TypeScript program is the server.
  • The client needs an address. The host says which computer, and the port says which program on that computer. We use host 127.0.0.1, which always means this computer, and port 8000.
  • Host, port and path together make a URL, such as http://127.0.0.1:8000/api/slots/A1/buy. The server compares the path with its route table, a list with one row for each kind of request. The matching row names the handler, the function that does the work.
http://127.0.0.1:8000/api/slots/A1/buyschemeThe protocol. Here it is plain HTTP, the agreement that browsers and servers use.HTTP: The agreement that browsers and servers use for requests and responses. Going deeper has the details. server: A program that waits for requests and answers them.hostWhich computer. This address always means this computer.portport: A number that says which program on a computer should receive a message.Which program on that computer. Our server listens here.server: A program that waits for requests and answers them.pathWhich thing, and what to do with it. The route table reads this part.route table: A list that pairs each method and path pattern with the function that handles it.How the server reads the pathserver: A program that waits for requests and answers them.The path/api/slots/A1/buyA row of the route tablerow: One record in a table, such as one slot of the machine. route table: A list that pairs each method and path pattern with the function that handles it./api/slots/([^/]+)/buyThe handler callhandler: The function that answers one kind of request.handleBuySlot("A1")is tried againstcaptured text "A1"
A URL is an address with four parts, and the server reads the path part with its route table. The text captured from the path becomes the argument of the handler. Download this diagram as SVG

Why the page never talks to the database

The visitor owns the browser. They can read every line of the page, change it, run any script in the console, or skip the page and send requests by hand. A check that exists only in the browser can always be skipped. So the rules of the machine live on the server, and the server is the only program with a route to the database.

The visitor's browserNot under our controlThe visitor can edit the page,run any script in the console,console: The part of the developer tools where messages appear and where you can type JavaScript.or send requests by hand.request: A message from the browser that says what it wants.Our serverserver: A program that waits for requests and answers them.Under our controlThe server checks every request,server: A program that waits for requests and answers them. request: A message from the browser that says what it wants.owns every SQL statement,SQL: Structured Query Language. The language for asking a relational database to find or change rows.and decides what is allowed.The databaseReachable only from the serverserver: A program that waits for requests and answers them.The database holds the data and the rules(constraints) that protect it,constraint: A rule that the database enforces on its own, such as NOT NULL or CHECK (stock >= 0).and never faces the visitor.requestsrequest: A message from the browser that says what it wants.our SQLSQL: Structured Query Language. The language for asking a relational database to find or change rows.No direct route from the browser to the database
The browser is outside your control, so the server treats every request as untrusted. The database is reachable only through the server. Download this diagram as SVG

Look at how Buy is designed. The browser names a slot and nothing else. The browser does not say what the price is or which SQL to run. Those decisions belong to the server.

Design the API before you write it

An API (Application Programming Interface) is the part of a program that other programs may use. A web API makes that part available over HTTP, and usually replies in JSON. Designing one means four decisions:

  • Which things does the API offer? Each thing gets a URL, called an endpoint. Ours are all the slots and each single slot.
  • Which actions are allowed? The method names the action. GET reads, and POST changes something.
  • What shape does the data have? The contract lists the fields and their types.
  • How are outcomes reported? A status code gives the category, and a JSON message gives the detail. 200 means success, 400 means the client asked badly, 404 means nothing is there, and 409 means the request clashes with the current state.
MethodURLWhat it doesOn successOn failure
GET/api/slotsLists every slot200 and a list of slotsNone expected
GET/api/slots/A1Shows one slot200 and one slot400 for a malformed id, 404 for a missing slot
POST/api/slots/A1/buyBuys one item200 and the updated slot400, 404, or 409 when sold out

Two shapes of JSON cross the network, a slot and an error:

A slot, followed by an error

{
  "slot_id": "A1",
  "item_name": "Sparkling Water",
  "price_cents": 175,
  "stock": 7
}

{ "error": "Oat Biscuits is sold out." }

The page and the server are written at different times. If the server renames stock to quantity, the page breaks without any warning. In this edition the contract appears twice in code, as a Slot type in the server and another in the page script. Each copy is checked only against its own program, so if they drift apart nothing warns you. A table like the one above gives both a single reference.

Build it in three layers

  • The data layer talks to the database and knows nothing about HTTP.
  • The route layer connects URLs to data functions and checks input.
  • The HTTP layer reads requests and writes responses, and the last lines start the server.
  • Each layer uses only the one beneath it, so you can change one without disturbing the others. The file lists the layers in this order.
  • The diagram below names the functions that you will write in each layer.
The browserHTTP layerHTTP layer: One of the three parts of the server: the data layer talks to the database, the route layer matches URLs, and the HTTP layer reads and writes messages. Step 3 builds them.answerRequest, sendJson, sendBodyKnows methods, paths, status codes and headers.status code: The three digit number at the start of a response that says how the request went. header: A name and a value that describe a request or a response, such as Content-Type.Never touches SQL.SQL: Structured Query Language. The language for asking a relational database to find or change rows.Route layerroute layer: One of the three parts of the server: the data layer talks to the database, the route layer matches URLs, and the HTTP layer reads and writes messages. Step 3 builds them.ROUTES, handleBuySlot, validateSlotIdKnows which URL means which action,URL: Uniform Resource Locator. The address of something on the web, made of scheme, host, port and path.and what a valid slot id looks like.Data layerdata layer: One of the three parts of the server: the data layer talks to the database, the route layer matches URLs, and the HTTP layer reads and writes messages. Step 3 builds them.getAllSlots, getSlot, buySlotKnows SQL, transactions and the rules of the machine.SQL: Structured Query Language. The language for asking a relational database to find or change rows. transaction: A group of database steps that either all happen or none happen.Never touches HTTP or status codes.HTTP: The agreement that browsers and servers use for requests and responses. Going deeper has the details. status code: The three digit number at the start of a response that says how the request went.The SQLite databaseSQLite: A database that lives in one ordinary file and needs no separate server.requestrequest: A message from the browser that says what it wants.responseresponse: The message that the server sends back.calls a handlerhandler: The function that answers one kind of request.data, or an errorcalls a data functiondata, or an errorSQL and valuesSQL: Structured Query Language. The language for asking a relational database to find or change rows.rowsrow: One record in a table, such as one slot of the machine.
The server is three layers. Each one calls only the layer below it, so the SQL stays in the data layer, the URLs stay in the route layer, and the status codes stay in the HTTP layer. Download this diagram as SVG
  1. In src, create server.ts and type or paste the first code block below.
  2. In src, create tsconfig.json with the second code block.
  3. Run npm run check. The command prints nothing when the type checker finds no problem.
  4. Run npm start. The server stays running, and that is correct.
  5. Visit http://127.0.0.1:8000/api/slots in a browser.
src/server.tsin vending-ts/src/
// A small web server that sells snacks from a SQLite database.
//
// The file has four layers, from the bottom of the program to the top:
//   1. The data layer talks to the database. It knows nothing about HTTP.
//   2. The route layer connects URLs to data functions and checks the input.
//   3. The HTTP layer reads requests and writes responses.
//   4. The last lines start the server.

// Modules whose names start with "node:" are built into Node, so nothing needs installing.
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";

// The shape of one slot. It matches one row of the inventory table and the
// JSON that we send to the browser. A type exists only while we write and
// check the program. Node removes it before the program runs.
type Slot = {
  slot_id: string;
  item_name: string;
  price_cents: number;
  stock: number;
};

The comment at the top lists the four layers of the file, and the explanations follow the same order.

  • The imports bring in only what Node already contains: readFileSync reads a file, createServer builds a web server, and DatabaseSync talks to SQLite.
  • import type brings in two names that exist only as types, and makes that visible.
  • The Slot type describes one slot. The type matches one row of the table and the JSON we send.
  • A type exists only while you write and check the program, and Node removes the type before the program runs. So a type protects you from your own typing mistakes and does nothing about data that arrives from outside.
// --- Settings ----------------------------------------------------------------

// 127.0.0.1 means "this computer", so only programs on your own machine can connect.
const HOST = "127.0.0.1";

// The port decides which program on this computer receives the request.
const PORT = 8000;

// import.meta.dirname is the folder that holds this file, which is src,
// so ".." leads to the project folder above it.
const PROJECT_FOLDER = path.join(import.meta.dirname, "..");
const DATABASE_FILE = path.join(PROJECT_FOLDER, "vending.db");
const PUBLIC_FOLDER = path.join(PROJECT_FOLDER, "public");

// The only files that the server will send. Each URL path maps to a file name
// and a Content-Type. A fixed list stops visitors from asking for other files,
// such as server.ts or vending.db, by typing a path like /../src/server.ts.
const STATIC_FILES = new Map([
  ["/", { fileName: "index.html", contentType: "text/html; charset=utf-8" }],
  ["/style.css", { fileName: "style.css", contentType: "text/css; charset=utf-8" }],
  ["/app.js", { fileName: "app.js", contentType: "text/javascript; charset=utf-8" }],
]);

// A valid slot id is one capital letter followed by one digit, such as A1 or B2.
const SLOT_ID_PATTERN = /^[A-Z][0-9]$/;
  • Names in capitals are constants, a convention for values that never change while the program runs.
  • HOST decides who may connect. 127.0.0.1 allows only your own computer, which is the safe choice while you develop.
  • PORT decides which program receives the request.
  • STATIC_FILES is a Map (a collection of key and value pairs) of the only files the server will send. If the server sent whatever file the URL named, a visitor could ask for /../src/server.ts and download our code and our database. That attack is path traversal.
  • SLOT_ID_PATTERN is a regular expression, a compact pattern for text. This one means one capital letter followed by one digit.
// --- Errors ------------------------------------------------------------------
// The data layer reports problems by throwing these errors. Each name
// describes a problem in the language of the vending machine, and the HTTP
// layer decides which status code each problem deserves.

class BadRequest extends Error {}
class NoSuchSlot extends Error {}
class SoldOut extends Error {}

The program expects three kinds of trouble, and each gets its own error class. The classes have no body, because only their names matter.

The data layer never mentions HTTP. When something goes wrong it throws an error named after what went wrong in the machine, such as SoldOut, and one place near the end of the file turns those names into status codes. So we could reuse the data functions in a program that has no web server at all.

// --- Data layer --------------------------------------------------------------
// These functions are the only place where the program talks to the database.

// We open one connection for the whole program. Node runs our code on a single
// thread, so only one request touches the connection at a time. The Python
// edition opens a new connection for every request because its server uses
// several threads.
const database = new DatabaseSync(DATABASE_FILE);
  • We open one connection for the whole program. Node runs our code on a single thread, so only one request touches the connection at a time. (The Python edition opens one per request because its server uses several threads.)
  • Every call on database is synchronous: the program waits for the answer and moves on.
  • That would be a poor idea with a database on another computer, because the whole server would stall during each network trip. SQLite lives inside our own process and answers in microseconds, so waiting costs almost nothing.
// A prepared statement is SQL that the database has already read and checked.
// We prepare each statement once, when the server starts, and run it many
// times. If a table or column name were wrong, the error would appear here at
// startup and not on the first click. The ? is a placeholder for a value that
// travels separately from the SQL text, so a visitor can never change what the
// SQL means (SQL injection).
const selectAllSlots = database.prepare(
  "SELECT slot_id, item_name, price_cents, stock FROM inventory ORDER BY slot_id",
);
const selectOneSlot = database.prepare(
  "SELECT slot_id, item_name, price_cents, stock FROM inventory WHERE slot_id = ?",
);
// One statement checks the stock and changes it. If two buyers arrive at the
// same moment, the database handles them one after the other, so they cannot
// both take the last item.
const decrementStockIfAvailable = database.prepare(
  "UPDATE inventory SET stock = stock - 1 WHERE slot_id = ? AND stock > 0",
);
Stations 6 to 7 The handler sends SQL to the database, then the database changes the row
  • We prepare each statement once, when the server starts, and run it many times. A wrong table or column name shows up at startup and not on the first click.
  • The ? is a placeholder for a value that travels separately from the SQL, so a visitor can never change what the SQL means. That attack is SQL injection.
  • The third statement is the most important line in the file. We could read the stock, test it in JavaScript and write the new value, but that leaves a gap in which two buyers could both take the last item.
  • The single UPDATE checks stock > 0 and subtracts in one indivisible step. The section on design choices compares the two plans in a diagram.
// Run some work as a transaction: all of it happens, or none of it does.
// The <T> means "whatever type the work returns", and the function returns the
// same type.
function runInTransaction<T>(work: () => T): T {
  database.exec("BEGIN");
  try {
    const result = work();
    database.exec("COMMIT");
    return result;
  } catch (error) {
    // Something went wrong, so undo every change made since BEGIN.
    database.exec("ROLLBACK");
    throw error;
  }
}
  • This helper wraps any piece of work in BEGIN and COMMIT, and calls ROLLBACK to undo everything if the work throws.
  • throw error inside catch passes the problem on, because the caller still needs to learn that the work failed.
  • The Python edition gets the same behaviour from with connection:.
  • <T> is a generic: whatever type the work returns. Work that returns a Slot gives a Slot back.
function getAllSlots(): Slot[] {
  // "as Slot[]" is a promise to the compiler. The compiler cannot see inside
  // the database, so it takes our word for the shape of the rows.
  return selectAllSlots.all() as Slot[];
}

function getSlot(slotId: string): Slot {
  const slot = selectOneSlot.get(slotId) as Slot | undefined;
  if (slot === undefined) {
    throw new NoSuchSlot(`No slot called ${slotId}.`);
  }
  return slot;
}

Two functions read data.

  • The database cannot tell the compiler what its rows look like, so as Slot[] is our claim: these rows have the shape of a Slot. The compiler accepts that claim without checking.
  • get returns a row or undefined, so the type is Slot | undefined. After the === undefined test and the throw, the compiler knows slot is a real slot. TypeScript calls this narrowing, and it removes a whole family of cannot read property of undefined errors.
// Take one item from a slot and return the updated slot.
function buySlot(slotId: string): Slot {
  return runInTransaction(() => {
    const updateResult = decrementStockIfAvailable.run(slotId);

    // Read the slot inside the same transaction, so that the numbers we report
    // agree with the change we just made. This line throws NoSuchSlot when the
    // slot does not exist.
    const slot = getSlot(slotId);

    // "changes" is the number of rows that the UPDATE changed.
    if (updateResult.changes === 0) {
      throw new SoldOut(`${slot.item_name} is sold out.`);
    }
    return slot;
  });
}
Stations 6 to 7 The handler sends SQL to the database, then the database changes the row
  • The work runs inside runInTransaction. First the single UPDATE tries to take one item, and changes says how many rows it changed.
  • Then we read the slot inside the same transaction, so the numbers we report match the change we made. getSlot throws NoSuchSlot if the slot does not exist.
  • Zero changes with an existing slot can only mean the stock was already zero, so we throw SoldOut. The rule stays simple: any error undoes the work.
// --- Route layer -------------------------------------------------------------
// A handler connects one URL to one data function. It checks the input, calls
// the data layer and returns data. It contains no SQL and no HTTP details.

// Tidy up a slot id from the URL and check that it has the right shape.
function validateSlotId(rawSlotId: string): string {
  // toUpperCase lets a visitor type a1 and mean A1.
  const slotId = rawSlotId.toUpperCase();
  if (!SLOT_ID_PATTERN.test(slotId)) {
    throw new BadRequest("Slot ids look like A1 or B2.");
  }
  return slotId;
}

Everything that comes from the URL is untrusted text. validateSlotId cleans up that text (turning a1 into A1), checks that the whole text has the expected shape, and throws BadRequest if not.

The queries are already safe from SQL injection because they use parameters. This check is a second layer that gives a clear error early and keeps odd text out of our logs.

function handleListSlots(): Slot[] {
  return getAllSlots();
}

function handleShowSlot(rawSlotId: string): Slot {
  return getSlot(validateSlotId(rawSlotId));
}

function handleBuySlot(rawSlotId: string): Slot {
  return buySlot(validateSlotId(rawSlotId));
}

// One row of the route table: an HTTP method, a pattern for the URL path, and
// the handler for that route.
type Route = {
  httpMethod: string;
  pathPattern: RegExp;
  handler: (...capturedText: string[]) => unknown;
};

// The route table is the API written as data. In a pattern, ([^/]+) captures
// the text between two slashes, and the captured text is passed to the
// handler as an argument.
const ROUTES: Route[] = [
  { httpMethod: "GET", pathPattern: /^\/api\/slots$/, handler: handleListSlots },
  { httpMethod: "GET", pathPattern: /^\/api\/slots\/([^/]+)$/, handler: handleShowSlot },
  { httpMethod: "POST", pathPattern: /^\/api\/slots\/([^/]+)\/buy$/, handler: handleBuySlot },
];

The three handlers are thin. Each connects a URL to a data function and validates the input on the way.

  • The Route type describes one row of the table: a method, a pattern and a handler. A handler is a function that takes any number of text arguments and returns something.
  • ROUTES is the API written as data.
  • In a pattern, ([^/]+) means one or more characters that are not a slash. The parentheses capture them, so the text between the slashes becomes an argument to the handler.
  • Every web framework builds a table like this for you.
// --- HTTP layer --------------------------------------------------------------

function sendBody(
  response: ServerResponse,
  statusCode: number,
  contentType: string,
  body: string | Buffer,
): void {
  // HTTP requires this order: status line, headers, a blank line, body.
  // writeHead writes the status line and the headers, and end writes the body.
  response.writeHead(statusCode, {
    "Content-Type": contentType, // how to read the body
    // Content-Length counts bytes, and some characters take more than one
    // byte, so we measure the bytes and not the characters.
    "Content-Length": Buffer.byteLength(body),
    "Cache-Control": "no-store", // always ask again while we develop
  });
  response.end(body);
}

function sendJson(response: ServerResponse, statusCode: number, data: unknown): void {
  // JSON.stringify turns the data into text, and the network carries it as bytes.
  sendBody(response, statusCode, "application/json; charset=utf-8", JSON.stringify(data));
}
  • sendBody writes the response in the order HTTP demands: the status line and headers (writeHead writes both), a blank line, and the body (end).
  • Content-Length counts bytes, and some characters take more than one, so we measure with Buffer.byteLength and not .length.
  • sendJson turns the data into text with JSON.stringify and hands the text to sendBody.
  • The type unknown for data means we accept any value without making claims about it.
// Node calls this function once for every request that arrives.
function answerRequest(request: IncomingMessage, response: ServerResponse): void {
  // "??" means "use the value on the right if the one on the left is missing".
  const httpMethod = request.method ?? "GET";

  // request.url holds the path and any query string together. The URL class
  // takes them apart. It needs a base address for a path with no host.
  const urlPath = new URL(request.url ?? "/", `http://${HOST}:${PORT}`).pathname;

  // Print one log line when the response has been sent.
  response.on("finish", () => console.log(`${httpMethod} ${urlPath} ${response.statusCode}`));

  try {
    // First, is the visitor asking for one of our page files?
    const staticFile = STATIC_FILES.get(urlPath);
    if (httpMethod === "GET" && staticFile !== undefined) {
      const body = readFileSync(path.join(PUBLIC_FOLDER, staticFile.fileName));
      sendBody(response, 200, staticFile.contentType, body);
      return;
    }

    // Otherwise look for a matching route. The first match wins.
    for (const route of ROUTES) {
      const pathMatch = route.pathPattern.exec(urlPath);
      if (route.httpMethod === httpMethod && pathMatch !== null) {
        // slice(1) drops the whole match and keeps the captured text, such as "A1".
        sendJson(response, 200, route.handler(...pathMatch.slice(1)));
        return;
      }
    }

    // Nothing matched.
    sendJson(response, 404, { error: "No such route." });
  } catch (error) {
    // These lines turn the errors of the data layer into status codes.
    if (error instanceof BadRequest) {
      sendJson(response, 400, { error: error.message });
    } else if (error instanceof NoSuchSlot) {
      sendJson(response, 404, { error: error.message });
    } else if (error instanceof SoldOut) {
      sendJson(response, 409, { error: error.message });
    } else {
      // Anything else is a bug in our program. We print the details for the
      // developer and tell the visitor very little.
      console.error("Unexpected error:", error);
      sendJson(response, 500, { error: "Something went wrong on the server." });
    }
  }
}
  • Node calls this function once for every request. The function reads the method and takes the path out of request.url with the URL class, which also separates the path from any query string (the ?name=value part).
  • ?? supplies a default when the left side is missing. A listener on finish prints one log line after the response has gone out.
  • The function first checks whether the visitor asked for one of our three files. If not, the function walks through ROUTES, and the first row whose method and pattern both match wins.
  • route.handler(...pathMatch.slice(1)) drops the whole match, keeps the captured text such as "A1", and spreads it into arguments. Whatever the handler returns goes out as JSON with status 200.
  • The catch is the one place where errors become status codes: 400, 404 and 409.
  • Any other error is our own bug. We print the error for the developer and send a vague 500, because error details help attackers more than visitors.
// Create the server and tell it to call answerRequest for every request.
const webServer = createServer(answerRequest);

// Start listening on the host and port. The function runs once the server is ready.
webServer.listen(PORT, HOST, () => {
  console.log(`Vending machine running at http://${HOST}:${PORT}  (press Ctrl+C to stop)`);
});

createServer(answerRequest) builds a server that calls our function for each request. listen asks the operating system to deliver requests for that host and port.

The function passed to listen runs once, when the server is ready. After that Node keeps the program alive until you press Ctrl+C.

src/tsconfig.jsonin vending-ts/src/
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "lib": ["ES2023"],
    "types": ["node"],

This file configures the type checker for the server files.

  • target names the version of JavaScript we write.
  • module: NodeNext follows the rules of Node's module system.
  • lib lists the built-in objects the compiler may assume exist. We leave out the browser's objects such as document, because a server has none.
  • types: ["node"] brings in the descriptions from @types/node.
    "strict": true,
    "noUncheckedIndexedAccess": true,

strict switches on the checks that catch the most mistakes. Use that setting in every new project.

noUncheckedIndexedAccess adds one more: reading list[3] gives a value that might be undefined, because the list might be shorter than you think.

    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true
  },
  "include": ["*.ts"]
}
  • noEmit means the compiler only checks and writes no files, because Node runs the TypeScript directly by stripping out the types.
  • erasableSyntaxOnly forbids the few TypeScript features that need real translation, such as enum (a named set of constants), so deleting the types always leaves valid JavaScript.
  • verbatimModuleSyntax asks us to write import type for imports that exist only as types.

Step 4Build the page

One idea first: the DOM

  • When a browser loads an HTML file it builds the DOM (Document Object Model), a tree with one object per element.
  • The screen shows a drawing of that tree.
  • A script can change the tree, and that is how a server's answer reaches the screen.
  • The page itself has no logic, and it is identical in both editions. The HTML says what exists and the CSS says how it looks.
  1. In vending-ts, create a folder called public.
  2. In that folder, create index.html and style.css with the code below.
  3. With the server still running, open http://127.0.0.1:8000.
public/index.htmlin vending-ts/public/
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <!-- Makes the page fit a phone screen instead of shrinking a desktop layout. -->
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Vending Machine</title>
  <link rel="stylesheet" href="/style.css">
</head>
  • The first line tells the browser this is an HTML5 document.
  • charset="utf-8" says how to turn bytes into characters, and the viewport line makes the page fit a phone screen.
  • The title appears on the browser tab, and link loads the stylesheet.
  • /style.css begins with a slash, so the browser asks the server that delivered this page. That request goes through the same stations as an API request.
<body>
  <main class="machine">
    <h1>Vending Machine</h1>

    <!-- role="status" makes screen readers announce changes to this text. -->
    <p id="status" class="screen" role="status">Loading...</p>

    <!-- Empty at first. The script adds one card here for each slot. -->
    <section id="slots" class="slots"></section>
  </main>

The browser reads this file and builds the DOM, a tree with one object per element. What you see is a drawing of that tree, and the script will change the tree later.

  • The id attributes are names the script uses to find elements.
  • #slots starts empty. The script fills #slots with one card per row in the database.
  <!-- A template is HTML that the browser keeps but does not show. The script
       copies it once for each slot and fills in the copy. -->
  <template id="slot-template">
    <article class="slot">
      <h2 class="item-name"></h2>
      <p class="price"></p>
      <p class="stock"></p>
      <button type="button" class="buy">Buy</button>
    </article>
  </template>

A <template> holds HTML that the browser reads but neither shows nor runs. The script copies the template once per slot and fills in the copy.

  • HTML is the clearest way to describe structure.
  • Pieces of HTML joined together inside script strings are hard to read and easy to get wrong.
  • Each element has a class so that the script and the stylesheet can refer to it.
  <!-- The script comes last, so every element it looks for already exists. -->
  <script type="module" src="/app.js"></script>
</body>
</html>

The script comes last, so every element that the script looks for already exists when the script runs. type="module" opts in to the modern script mode, in which the names inside the file stay private to the file.

public/style.cssin vending-ts/public/
/* Named values that the rest of the file reuses. Change a colour here once. */
:root {
  --page: #eceff1;
  --body: #37474f;
  --screen-bg: #102a12;
  --screen-text: #39ff14;
  --card: #455a64;
  --accent: #ffb300;
  --text: #ffffff;
}

/* Centre the machine horizontally. */
body {
  font-family: system-ui, sans-serif;
  background: var(--page);
  display: flex;
  justify-content: center;
  padding: 24px 16px;
}

The :root block defines custom properties, named values such as --accent that the rest of the stylesheet reads with var(--accent). Changing a colour means editing one line.

The body is a flex container, a layout mode that lines up its children in a row or a column. Here it centres the machine horizontally.

/* min() means "480 pixels wide, but never wider than the screen". */
.machine {
  background: var(--body);
  color: var(--text);
  padding: 24px;
  border-radius: 12px;
  width: min(100%, 480px);
}

h1 {
  margin: 0 0 16px;
  font-size: 1.4rem;
}

/* The status line looks like the green display of an old machine. */
.screen {
  background: var(--screen-bg);
  color: var(--screen-text);
  font-family: ui-monospace, Menlo, Consolas, monospace;
  padding: 12px 16px;
  border-radius: 6px;
  margin: 0 0 16px;
}

width: min(100%, 480px) means as wide as 480 pixels but never wider than the screen, so the machine fits on a phone.

/* A grid with two equal columns. */
.slots {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

.slot {
  background: var(--card);
  padding: 12px;
  border-radius: 8px;
}

.slot h2 {
  font-size: 1rem;
  margin: 0 0 8px;
}

.slot p {
  margin: 0 0 4px;
}

The slots form a grid of two equal columns, and each card is a slightly lighter box. Nothing here depends on the data, so the same styles work for one slot or twenty.

/* The script only adds or removes the class "sold-out". This stylesheet
   decides what a sold-out card looks like. */
.slot.sold-out {
  opacity: 0.55;
}

button {
  margin-top: 8px;
  width: 100%;
  padding: 10px;
  font-weight: bold;
  background: var(--accent);
  border: none;
  border-radius: 6px;
  cursor: pointer;
}

/* The script sets the disabled property of a button when stock is zero. */
button:disabled {
  background: #90a4ae;
  cursor: not-allowed;
}
Stations 11 to 12 The script changes the DOM, then the browser repaints the screen

The script never sets colours. When a slot has no stock, the script adds the class sold-out and sets the button's disabled property. This stylesheet decides what those states look like.

The script says what state the card is in, and the stylesheet says how that state looks. A designer can change the appearance without touching the logic.

Step 5Build the script

The script has one job: ask the server for data and change the page to match the reply. Everything with rules in it, such as the stock count and the validation, lives in the server. The script is TypeScript too, so the compiler checks it against the browser's own descriptions of the page.

The same fact in five shapes

The fact that slot A1 has seven items left takes five shapes on its way from the disk to the screen. The script you are about to write handles the last three.

1. A row in the databaserow: One record in a table, such as one slot of the machine.A1 | Sparkling Water | 175 | 7On the server's disk, inside the SQLite fileserver: A program that waits for requests and answers them. SQLite: A database that lives in one ordinary file and needs no separate server.selectOneSlot.get(slotId)2. A JavaScript object in the serverJavaScript: The language that browsers run. Scripts in a page are written in it. server: A program that waits for requests and answers them.{ slot_id: 'A1', item_name: 'Sparkling Water', price_cents: 175, stock: 7 }In the memory of the server programserver: A program that waits for requests and answers them.JSON.stringify(slot)JSON: JavaScript Object Notation. A text format for structured data, used by most web APIs.3. Text on the network (JSON)JSON: JavaScript Object Notation. A text format for structured data, used by most web APIs.{"slot_id":"A1","item_name":"Sparkling Water","price_cents":175,"stock":7}On the network, as bytesbytes: The numbers that a computer stores and sends. Text has to become bytes to cross a network.response.json()response: The message that the server sends back.4. An object in the page's script{ slot_id: "A1", item_name: "Sparkling Water", price_cents: 175, stock: 7 }In the memory of the page's scriptcreateSlotCard(slot)5. Elements in the DOMDOM: Document Object Model. The browser's live tree of objects, one for every element on the page.<p class="stock">7 left</p>In the browser's DOM, then on the screenDOM: Document Object Model. The browser's live tree of objects, one for every element on the page.
The same fact in five shapes, with the place where each one lives and the step that converts one shape into the next. Download this diagram as SVG

1. A row in the database

slot_id | item_name       | price_cents | stock
A1      | Sparkling Water | 175         | 7
↓ selectOneSlot.get(slotId) returns the row as an object

2. A JavaScript object in the server

{ slot_id: 'A1', item_name: 'Sparkling Water', price_cents: 175, stock: 7 }
↓ JSON.stringify(...) serialises the object into text

3. Text on the network (JSON)

{"slot_id":"A1","item_name":"Sparkling Water","price_cents":175,"stock":7}
↓ response.json() parses the text into an object

4. An object in the browser's script

{ slot_id: "A1", item_name: "Sparkling Water", price_cents: 175, stock: 7 }
↓ createSlotCard copies the values into elements with textContent

5. Elements in the DOM

<p class="stock">7 left</p>
  • Only the third shape can cross the network, because a network carries text and bytes.
  • Serialising turns a structure into text. Parsing turns text back into a structure.
  • Every arrow is a place where something can go wrong: a field can be missing, a number can arrive as text, or a value can be empty.
  1. Create a folder client, and in it app.ts with the first code block below.
  2. In client, create tsconfig.json with the second code block.
  3. Run npm run build. The compiler writes public/app.js.
  4. Reload http://127.0.0.1:8000.
client/app.tsin vending-ts/client/
// This script runs in the browser. It asks the server for data and changes
// the page to match what the server says.

// The shape of one slot, as the server sends it. This type is a promise about
// the JSON, and nothing checks it while the program runs.
type Slot = {
  slot_id: string;
  item_name: string;
  price_cents: number;
  stock: number;
};

// The shape of an error message from the server.
type ApiError = { error: string };

The two types describe the JSON the server sends. They are claims and not checks: the compiler uses them to help us write the rest of the script, and nothing compares them with the real reply while the page runs.

If the server renamed stock, this file would still compile and the page would show undefined left. That is why the contract between page and server needs to be written down, as in Step 3.

// Find an element by its id, or stop with a clear message if it is missing.
// getElementById returns "an element or null", and TypeScript makes us deal
// with the null before we use the result.
function findElementById(id: string): HTMLElement {
  const element = document.getElementById(id);
  if (element === null) {
    throw new Error(`The page has no element with id "${id}".`);
  }
  return element;
}

The browser's getElementById returns an element or null. TypeScript makes us deal with the null before we use the result, so this helper stops with a clear message when the page lacks the element. After the check the compiler knows a real element comes back.

// Find the elements that we need, once, when the script loads.
const slotListElement = findElementById("slots");
const statusElement = findElementById("status");
// "as HTMLTemplateElement" tells the compiler something that we know about our own page.
const slotTemplate = findElementById("slot-template") as HTMLTemplateElement;

// A formatter from the browser that writes prices in euros, so that 1.75
// becomes €1.75.
const euroFormatter = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" });
  • The three constants look up elements by id and keep them in variables, so we search the page once and not on every click.
  • as HTMLTemplateElement tells the compiler a fact about our own page that the compiler cannot see.
  • Intl.NumberFormat is a formatter the browser provides. With the locale en-IE and the currency EUR the formatter writes 1.75 as €1.75.
  • Writing "€" + (cents / 100).toFixed(2) works until you meet a place with different conventions, so we let the formatter decide.
// Send a request to our API and return the JSON from the reply. The function
// is async, so it can wait for the network without freezing the page.
// <T> is the type that the caller expects the JSON to have.
async function requestJson<T>(url: string, options?: RequestInit): Promise<T> {
  let response: Response;
  try {
    // fetch sends the request and returns a promise of a response.
    // "await" pauses this function until the response arrives.
    response = await fetch(url, options);
  } catch {
    // fetch fails like this only when no reply arrives at all, for example
    // when the server is not running.
    throw new Error("Cannot reach the server. Is it running?");
  }

  // The body of the reply is JSON text. json() parses it into an object.
  // The compiler cannot know what the server sent, so the type is "unknown".
  const data: unknown = await response.json();

  // A reply can arrive with a bad status such as 404 or 409.
  // response.ok is false for those, so we pass the server's message on.
  if (!response.ok) {
    throw new Error((data as ApiError).error);
  }
  return data as T;
}

This function wraps every API call, so the rest of the script never repeats the checks.

  • Inside an async function, await pauses that function, and only that one, until a promise, an object standing for a value that has not arrived yet, settles. The browser stays responsive meanwhile.
  • fetch sends the request at Station 2, and its reply arrives at Station 10.
  • If no response arrives at all, fetch rejects, and we turn that into a friendlier message.
  • If a response arrives with a bad status such as 404 or 409, fetch still counts it as success, so we check response.ok ourselves and pass on the server's own message.
  • <T> lets each caller say what it expects, as in requestJson<Slot[]>(...). The parsed JSON is typed unknown, the accurate type for data from outside, and the final as T is our claim that the server keeps its side of the contract.
// Build one card for one slot. The card is not on the page until we add it.
function createSlotCard(slot: Slot): HTMLElement {
  // Copy the card layout from the template in index.html. The ! says "this is
  // never null", because we wrote the template ourselves.
  const slotCard = slotTemplate.content.firstElementChild!.cloneNode(true) as HTMLElement;
  const isSoldOut = slot.stock === 0;

  // data-slot remembers which slot this card belongs to, for the click handler.
  slotCard.dataset.slot = slot.slot_id;

  // The sold-out class lets the stylesheet decide how a sold-out card looks.
  slotCard.classList.toggle("sold-out", isSoldOut);

  // textContent treats a value as plain text, never as HTML, so data from the
  // server can never run as code in the page.
  slotCard.querySelector(".item-name")!.textContent = `${slot.slot_id}: ${slot.item_name}`;

  // Prices are stored in cents. We divide by 100 only when we show them.
  slotCard.querySelector(".price")!.textContent = euroFormatter.format(slot.price_cents / 100);

  slotCard.querySelector(".stock")!.textContent = isSoldOut ? "Sold out" : `${slot.stock} left`;
  slotCard.querySelector("button")!.disabled = isSoldOut;
  return slotCard;
}

This function turns one slot (the fourth shape from Step 5) into one card (the fifth). cloneNode(true) makes a deep copy of the template, so the template stays unchanged for the next card.

  • The exclamation marks say this is never null, because we wrote the template ourselves. Each one overrides the compiler's caution, so use them only where you know the page.
  • Every line that shows data uses textContent, which treats the value as plain text. With innerHTML, an item name containing a script tag would run. That attack is cross-site scripting.
  • The function returns a card that is not on the screen yet, because nothing has attached it to the page.
// Ask the server for every slot and show them all on the page.
async function loadAllSlots(): Promise<void> {
  try {
    const slots = await requestJson<Slot[]>("/api/slots");

    // map turns a list of slot data into a list of cards.
    // replaceChildren puts the cards on the page. This changes the DOM, and
    // the browser then redraws the screen.
    slotListElement.replaceChildren(...slots.map(createSlotCard));
    statusElement.textContent = "Choose a snack.";
  } catch (error) {
    // Inside catch, TypeScript treats "error" as unknown, so we say what it is.
    statusElement.textContent = (error as Error).message;
  }
}
Stations 10 to 12 The reply reaches the script, then the browser repaints the screen
  • slots.map(createSlotCard) converts an array of data into an array of cards.
  • replaceChildren(...) swaps the contents of #slots for them in one step. That is the moment the DOM changes, and the browser repaints at Station 12.
  • try and catch show any error in the status line. Inside catch the error has type unknown, so as Error states what it is.
// One listener on the whole list handles clicks on every Buy button. This
// technique is called event delegation, and it also covers buttons that the
// script adds later.
slotListElement.addEventListener("click", async (event) => {
  // closest() climbs from the clicked element up to the nearest Buy button.
  // It returns null when the click landed somewhere else.
  const buyButton = (event.target as HTMLElement).closest("button.buy");
  if (buyButton === null) {
    return;
  }

  const slotCard = buyButton.closest("article")!;
  try {
    // POST asks the server to change something. The reply is the updated slot.
    const updatedSlot = await requestJson<Slot>(`/api/slots/${slotCard.dataset.slot}/buy`, {
      method: "POST",
    });

    // Replace only this card, using what the server says and not what we guess.
    slotCard.replaceWith(createSlotCard(updatedSlot));
    statusElement.textContent = `Enjoy your ${updatedSlot.item_name}!`;
  } catch (error) {
    statusElement.textContent = (error as Error).message;
  }
});
Stations 1 to 2 The browser notices the click, then the script builds a request
← Before: the click has not happened yetNext: Station 3, The request travels →
Stations 10 to 11 The reply reaches the script, then the script changes the DOM

We attach one listener to the container and not one to each button, because the buttons do not exist yet when this line runs. A click on a button bubbles up through its parents, so the container hears it. This is event delegation, and closest climbs from the clicked element to the nearest Buy button, or returns null when the click landed elsewhere.

  • This function is Station 1.
  • The function reads the slot id from slotCard.dataset.slot, which is the data-slot attribute that the card carries, sends a POST, and swaps in a new card built from the server's reply.
  • We draw what the server said and not what we guess, so if someone bought the last item a moment earlier, the card tells the truth.
// Show the slots as soon as the page opens.
loadAllSlots();

The last line runs once when the script loads and sends the first request.

client/tsconfig.jsonin vending-ts/client/
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "Bundler",
    "moduleDetection": "force",

The page script needs different settings, so it has its own configuration.

  • Browsers understand import and export, so the module format stays modern.
  • moduleDetection: force treats every file as a module, so names such as Slot stay private to the file and do not clash with the server's.
    "lib": ["ES2022", "DOM"],
    "types": [],

Here the built-in objects include DOM, which describes document, fetch and every element type. types is empty on purpose, so Node's descriptions stay out and the compiler flags any Node-only function used in browser code.

    "strict": true,
    "verbatimModuleSyntax": true,
    "outDir": "../public"
  },
  "include": ["*.ts"]
}

The same strict checking applies. outDir says where the compiled JavaScript goes: into public, beside the HTML, where the server can send it.

A browser cannot run TypeScript, so npm run build is required for this file, and the server sends only the compiled app.js.

Optional:What the compiler did to the file

Open public/app.js and compare it with the TypeScript. This is the start of the request function, before and after:

Before: client/app.ts

async function requestJson<T>(url: string, options?: RequestInit): Promise<T> {
  let response: Response;

After: public/app.js

async function requestJson(url, options) {
    let response;

Every label has gone and the logic is untouched. The file in public is generated, so never edit it by hand: change the TypeScript, run npm run build, and reload.

Step 6Run it and watch the traffic

  1. Open a terminal in the project folder and run the commands below.
  2. Open http://127.0.0.1:8000 in a browser.

Start the project

cd vending-ts
npm install            # downloads the TypeScript compiler and Node's type descriptions
npm run setup-db       # creates the database file
npm run build          # compiles client/app.ts into public/app.js for the browser
npm start              # starts the server and leaves it running

Watch the traffic in the browser

  1. Press F12 to open the developer tools (Ctrl+Shift+I on Windows and Linux, Option+Command+I on a Mac).
  2. Choose the Network tab and reload the page. Each row is one message the browser sent: the page, style.css, app.js and slots, which is the API request.
  3. Click the slots row. Headers shows the request and the response, and Response shows the JSON.
  4. Press Buy. A new row appears, this time a POST. The Status column shows the status code, and the Time column shows how long the round trip took, from sending the request to receiving the reply.

Watch the traffic in the terminal

The server prints one line for each request it answers. Put the terminal beside the browser and press Buy a few times.

What the terminal shows

GET / 200
GET /style.css 200
GET /app.js 200
GET /api/slots 200
POST /api/slots/A1/buy 200

The browser may also ask for /favicon.ico, the tab's little icon, without being told to. The server has no route for that file and answers 404, which is harmless and shows that browsers send requests you never wrote.

Call the API without the page

The address bar is a client too. Visit http://127.0.0.1:8000/api/slots and you see the raw JSON. A command line tool called curl is a third client:

Three requests from a terminal

curl http://127.0.0.1:8000/api/slots
curl -i http://127.0.0.1:8000/api/slots/B2
curl -i -X POST http://127.0.0.1:8000/api/slots/A1/buy
  • -i shows the response headers.
  • -X POST chooses the method.
  • In older versions of Windows PowerShell, curl is an alias (a second name) for another command, so type curl.exe instead.

Going deeperHTTP messages and status codes

You can build the whole project without reading this section. This section is here for when you want to know exactly what the browser and the server say to each other.

  • A protocol is an agreement about the order and shape of messages.
  • HTTP (HyperText Transfer Protocol) is the agreement that browsers and web servers use.
  • Every exchange is two plain-text messages, a request and a response. Below is a real pair from the running project.
The request the client sends
POST /api/slots/a1/buy HTTP/1.1

The request line holds three things: the method (POST, the kind of action), the path (which thing it is done to) and the protocol version.

Host: 127.0.0.1:8000
User-Agent: curl/8.5.0
Accept: */*
Stations 2 to 3 The script builds a request, then the request travels

Headers come next, one per line, each as a name, a colon and a value.

  • Host names the server the client believes it is talking to.
  • User-Agent names the client program.
  • Accept lists the formats the client can read.

A blank line ends the headers. A POST may carry a body after that line, such as a form or a JSON document. This one has none, because the path already says everything the server needs.

The response the server sends back
HTTP/1.1 200 OK

The status line holds the protocol version, the status code and a short reason. Node answers in HTTP/1.1, the version browsers use for most plain HTTP traffic. The code 200 means success.

Content-Type: application/json; charset=utf-8
Content-Length: 74
Cache-Control: no-store
Date: Sat, 17 Oct 2026 21:55:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5

The response headers describe the body.

  • Content-Type says how to read the body: JSON in UTF-8, the standard way of turning characters into bytes.
  • Content-Length says how many bytes of body follow.
  • Cache-Control: no-store asks the browser never to reuse an old copy, so an edit shows up on the next reload.

Node added the Date, Connection and Keep-Alive headers without being asked. They tell the browser it may reuse the same connection for its next request.

{"slot_id":"A1","item_name":"Sparkling Water","price_cents":175,"stock":7}
Stations 8 to 9 The handler packages the result, then the server sends a response

After the blank line comes the body. This is the JSON that the script parses at Station 10.

Status codes

The status code is a three digit number that tells the client how things went before the client reads the body. The first digit gives the family: 2 means success, 4 means the client made a mistake, and 5 means the server did.

CodeNameWhen we send it
200OKThe request worked and the body holds the result.
400Bad RequestThe client sent something the server cannot make sense of, such as the slot id hello.
404Not FoundThe request was well formed, but nothing exists at that address, such as slot Z9.
409ConflictThe request was fine but clashes with the current state. Buying from a sold-out slot is a conflict.
500Internal Server ErrorSomething broke inside the server. The fault is ours, not the client's.

Methods

  • GET reads something and is expected to change nothing. A method like that is safe.
  • POST asks the server to do something, and it may change data.
  • Browsers, link previews in chat apps and search engine crawlers send GET requests freely and may repeat them. If buying were a GET, a chat app that previews a link could buy a snack.
  • Buying is also not idempotent, because doing it twice does not leave the same result as doing it once. Reading is idempotent, so you can refresh a page as often as you like.

A server with no memory

HTTP is stateless: the server treats every request as new and remembers nothing between them. So the stock count lives in the database and never in a variable inside the server.

REST in one paragraph

Many web APIs follow a style called REST (Representational State Transfer). URLs name things with nouns, the method chooses the action, and each request is complete in itself. Our API borrows that style without following every rule of it.

Going deeperBreak it on purpose

  • Open vending.db in the free program DB Browser for SQLite, or use the sqlite3 command line tool if it is installed. Node does not ship a SQLite shell, so these tools are the easiest route.
  • To reset the machine after an experiment, run the setup script again.

Experiment 1: three kinds of sold out

  1. In the page, press Buy on the Chocolate Bar until it is sold out. The button greys out after the third press.
  2. Send the same purchase from a terminal, where no disabled button can stop you: curl -i -X POST http://127.0.0.1:8000/api/slots/B1/buy
  3. Try to break the rule inside the database: UPDATE inventory SET stock = -1 WHERE slot_id = 'A1';

Experiment 2: two kinds of wrong address

Visit http://127.0.0.1:8000/api/slots/hello and then http://127.0.0.1:8000/api/slots/Z9. Guess each status code first.

Experiment 3: joining strings versus passing parameters

Run node src/injection-demo.ts. The script looks up a slot twice, once with a normal id and once with the text x' OR '1'='1, using two ways of building the query.

Output

The user typed: A1
Joined into the string:
  SQL sent to the database: SELECT item_name, stock FROM inventory WHERE slot_id = 'A1'
  rows returned: 1
Passed as a parameter:
  SQL sent to the database: SELECT item_name, stock FROM inventory WHERE slot_id = ? with value "A1"
  rows returned: 1

The user typed: x' OR '1'='1
Joined into the string:
  SQL sent to the database: SELECT item_name, stock FROM inventory WHERE slot_id = 'x' OR '1'='1'
  rows returned: 4
Passed as a parameter:
  SQL sent to the database: SELECT item_name, stock FROM inventory WHERE slot_id = ? with value "x' OR '1'='1"
  rows returned: 0

Experiment 4: opening the page straight from disk

With the server still running, double-click public/index.html so that the browser opens it from disk. The page stays on Loading.... Read the errors in the developer console.

Experiment 5: the page on one port and the API on another

  1. Leave the API on port 8000. In a second terminal, serve only the public folder on port 9000, for example npx http-server public -p 9000.
  2. In the page's script, change the two URLs that begin with /api/ so that they begin with http://127.0.0.1:8000/api/. Run npm run build afterwards, because the browser runs the compiled file and not your TypeScript.
  3. Open http://127.0.0.1:9000.

The page says Cannot reach the server. Is it running?, yet the server is running. Check the console and the server's terminal before you read on.

Script in the pagepage came from port 9000port: A number that says which program on a computer should receive a message.The browserenforces the same-origin policysame-origin policy: The browser rule that stops a page from reading responses that come from another origin.The API serverAPI: Application Programming Interface. The part of a program that other programs may use, and the ways they may ask it to do things. server: A program that waits for requests and answers them.listens on port 8000port: A number that says which program on a computer should receive a message.fetch("http://127.0.0.1:8000/api/slots")fetch: The browser function that sends an HTTP request from a script and returns a promise.GET /api/slots200 OK and JSON, with no permission headerJSON: JavaScript Object Notation. A text format for structured data, used by most web APIs. header: A name and a value that describe a request or a response, such as Content-Type.server log: GET /api/slots 200server: A program that waits for requests and answers them. log: A printed record of what the program did.The page is from port 9000, the answer is from port 8000,port: A number that says which program on a computer should receive a message.and no header gives permission, so the answer is withheld.header: A name and a value that describe a request or a response, such as Content-Type.network error: the script never sees the answer
The server received the request and answered it. The browser, not the server, refused to give the answer to the script. Download this diagram as SVG

Experiment 6: stopping the server

Press Ctrl+C to stop the server, return to the page and press Buy. Look at the status line and the console.

Experiment 7: changing data without touching any code

With the server running, change a price with UPDATE inventory SET price_cents = 99 WHERE slot_id = 'A1'; and reload the page.

Going deeperDesign choices and alternatives

Almost every line in this project was a choice, and most had a respectable alternative. For each choice the table gives the reason we chose as we did and the situation where you would choose differently. When you build your own project, ask yourself those same two questions about each decision.

The choiceWhy it fits hereWhen you might choose differently
SQLite for storageNothing to install, one file to copy, and the same SQL that bigger systems use.When many programs or computers write at once, or you need accounts, backups and monitoring at scale, a server database such as PostgreSQL fits better.
Node's built-in http module for the server, not a frameworkEvery mechanism stays visible: reading the request, routing and writing the response. Nothing needs installing.In real projects, because a framework such as Express, Fastify or Hono supplies routing, validation, safe static files and many conveniences. The next section describes what they add.
One database connection for the whole programNode runs our code on a single thread, so only one request touches the connection at a time, and the synchronous SQLite calls finish in microseconds. Sharing one connection also lets us prepare each statement once, at startup.With a client-server database, every call crosses a network and takes time, so the calls are asynchronous (they use await) and the program keeps a pool of connections.
The same server delivers the page and the APIBoth share one origin, so no CORS settings are needed, and only one program has to start.When the page lives on a static host such as GitHub Pages and the API lives elsewhere, the origins differ. The API then needs CORS headers and HTTPS.
JSON replies, with the page building its own elementsThe page and the API stay independent, and the same API can serve a phone app.When a page must work without scripts, the server can send finished HTML, and a form with a full page reload replaces the script.
Prices stored as whole centsWhole numbers are exact, so sums never drift.Some databases have an exact decimal type, which is also fine. A floating point column (numbers that carry tiny rounding errors) is never a good choice for money.
POST for buyingBuying changes data and is not idempotent, so a link preview must not be able to trigger that action.Other changes use other methods: PUT to replace something, PATCH to adjust part of it, DELETE to remove it.
Parameterised queries everywhereThe database receives SQL and values separately, so a value can never turn into SQL.There is no good reason to choose differently.
One UPDATE with AND stock > 0A single statement checks and changes together. Reading first and writing afterwards leaves a gap in which two buyers could both take the last item.When a purchase needs several steps, group them in one explicit transaction, and think about what the database does when two transactions collide.
Rules in three placesThe page is a convenience, the server holds the rules and explains them, and the database is the last line of defence.You may drop the check in the page, because a visitor can skip it anyway. The server and database checks stay.
textContent rather than innerHTMLtextContent treats a value as plain text. innerHTML parses the value as markup, so an item name containing a script tag could run code in every visitor's browser. That attack is cross-site scripting (XSS).innerHTML is fine for markup you wrote yourself and never for data from elsewhere.
A <template> element for the cardThe structure of a card stays in HTML, where it is easy to read and restyle, and not inside strings in the script.Front end frameworks such as React and Svelte generate this part for you and become worthwhile as pages grow.
Redrawing the card from the server's replyThe page shows what the server says, so it cannot drift from the truth. If somebody bought the last item a second ago, the reply says so.An optimistic update shows the change first and corrects it if the server disagrees. That style feels faster on a slow network and costs more code.
A fixed list of files that the server will sendA server that sent whatever file the URL named could be asked for its own source code or the database file. That attack is path traversal.A framework's static file helper handles this safely, so use it instead of writing your own.

Why one UPDATE beats read-then-write

The diagram shows the race that a single UPDATE prevents. The same pattern of a check followed by a change appears in booking systems, bank transfers and ticket sales.

Read, then writetwo steps with a gap between themBuyer ABuyer BDatabaseSELECT stock1SELECT stock1write stock = 0write stock = 0Two buyers took the one item that was left.One statementcheck and change happen togetherBuyer ABuyer BDatabaseUPDATE ... WHERE stock > 01 row changedrow: One record in a table, such as one slot of the machine.UPDATE ... WHERE stock > 00 rows changedrow: One record in a table, such as one slot of the machine.The second buyer is told that the slot is sold out.
Two buyers reach a slot with one item left. When the program reads and then writes, the database can serve the second buyer between those steps. A single UPDATE with the condition inside it leaves no gap. Download this diagram as SVG

Where frameworks fit

  • Real projects rarely write server plumbing by hand. A framework provides routing, validation, safe static files, error handling and many conveniences.
  • We wrote the plumbing ourselves so that each mechanism is visible once.
  • The usual names in the Node world are Express (the oldest and most widely taught), Fastify (validation from a schema, built for speed) and Hono (small, runs on many platforms).
  • In each of them our route table shrinks to three lines such as on GET /api/slots, call this function, and the framework reads the request, sets the headers and sends the JSON.
  • All of them sit on the same node:http module that we used, so what you learned here is what they do underneath.

Going deeperExtend it yourself

Each extension below is small and practises one idea from this tutorial. They are problems to approach your own way, and the hints say where to start and no more.

  1. A restock route. Add POST /api/slots/A1/restock with a JSON body such as {"amount": 5}. Reject a missing body, invalid JSON and an amount that is not a whole number from 1 to 100, each with a 400. Node does not read the body for you. Collect the chunks with for await (const chunk of request), join them, and parse the text with JSON.parse. That loop needs await, so answerRequest becomes an async function.
  2. A sales log in the same transaction. Create a sales table with an id, a slot id that points to a row in inventory (a foreign key), a timestamp and the price paid. Make a purchase insert a sale and decrement the stock inside one transaction. Why should the sale store the price again? Then prove the transaction works: raise an error between the two statements and check that the stock did not change.
  3. A summary route. Add GET /api/sales/summary that returns, for each item, how many were sold and how much money they earned. This needs a JOIN, which combines rows from two tables that share a value, and a GROUP BY, which gathers rows that share a value so that you can count or add them. The result has to arrive as clean JSON.
  4. Automatic refresh. Make the page ask for the slots every five seconds with setInterval, a browser function that repeats another function at a fixed interval, and open it in two windows. When you buy in one, how long until the other notices? Server-sent events and WebSockets let the server push changes instead.
  5. Separate hosting. Serve the page from its own port and make it work with CORS, as in Experiment 5. Then think about hosting only the page on GitHub Pages. You would need an absolute API address, a CORS header for your Pages origin, and an API reachable over HTTPS, because a page served over HTTPS may not call an insecure address.
  6. One type, shared. The Slot type is written twice, once in the server and once in the page script. Move the type into a file that both can import, and discover what makes that harder than it sounds. Think about which program reads each import, and what a browser needs an import path to look like.

ReferenceGlossary

Terms in alphabetical order. The name in brackets shows where the term is explained.

API
Application Programming Interface. The part of a program that other programs may use, and the ways they may ask it to do things. (Step 3)
Body
The data that follows the headers in a request or a response, such as the JSON of a slot. (HTTP messages)
Client
A program that starts a conversation with a server. Our browser is the client. (Step 3)
Connection
An open line of communication between a program and a database. (Step 1)
Constraint
A rule that the database enforces on its own, such as NOT NULL or CHECK (stock >= 0). (Step 2)
Contract
The agreed shape of requests and replies in an API: which fields exist and what type each has. (Step 3)
CORS
Cross-Origin Resource Sharing. Headers with which a server allows a page from another origin to read its responses. (Break it on purpose)
Database management system
A program that stores data safely and answers queries about it, for example SQLite or PostgreSQL. (Step 2)
DOM
Document Object Model. The browser's live tree of objects, one for every element on the page. (Step 1)
Endpoint
One address of an API, such as /api/slots/A1. (Step 3)
Event
A message that the browser creates when something happens on the page, such as a click. (Step 1)
Event delegation
Listening for events on a parent element instead of on each child, which also covers children added later. (Step 5)
fetch
The browser function that sends an HTTP request from a script and returns a promise. (Step 5)
Handler
The function that answers one kind of request. (Step 1)
Header
A name and value that describe a request or a response, such as Content-Type. (HTTP messages)
Host
The name or number that says which computer a request goes to. (Step 3)
HTTP
HyperText Transfer Protocol. The agreed text format of requests and responses between browsers and servers. (HTTP messages)
Idempotent
Describes an action that leaves the same result whether you do it once or many times. (HTTP messages)
JSON
JavaScript Object Notation. A text format for structured data, used by most web APIs. (Step 1)
Method
The word in a request that says what kind of action is wanted, such as GET or POST. (HTTP messages)
Origin
The combination of scheme, host and port. Pages may read responses only from their own origin unless the server allows more. (Break it on purpose)
Parameterised query
A query in which values travel separately from the SQL text, marked by placeholders such as ?. (Break it on purpose)
Parsing
Turning text into a structure that a program can use, such as JSON text into an object. (Step 5)
Path
The part of a URL after the port, such as /api/slots. (Step 3)
Path traversal
An attack that uses parts such as /../ in a URL to reach files that were never meant to be served. (Design choices)
Placeholder
A mark such as ? in SQL that shows where a value goes. The value travels separately from the SQL text. (Step 2)
Port
A number that tells the operating system which waiting program should receive a message. (Step 3)
Prepared statement
SQL that the database has read and checked once, so that it can be run many times. (Step 3)
Primary key
A column whose value is different in every row, so it identifies exactly one row. (Step 2)
Promise
An object that stands for a value that has not arrived yet. (Step 1)
Protocol
An agreement about the order and shape of messages. (HTTP messages)
Relational database
A database that keeps its data in tables of rows and columns. (Step 2)
Request
A message from a client that says what it wants. (Step 1)
Response
The message a server sends back, made of a status code, headers and a body. (Step 1)
REST
Representational State Transfer. A style of web API in which URLs name things and methods choose the action. (HTTP messages)
Route
A pairing of a method and a path pattern with the function that handles it. (Step 1)
Route table
A list that pairs each method and path pattern with the function that handles it. (Step 3)
Safe method
A method that changes nothing on the server, such as GET. (HTTP messages)
Same-origin policy
The browser rule that stops a page from reading responses that come from another origin. (Break it on purpose)
Schema
The declared structure of a database: its tables, columns and types. (Step 2)
Serialising
Turning a structure in memory into text or bytes so that it can be stored or sent. (Step 5)
Server
A program that waits for requests and answers them. (Step 3)
Service worker
A script that sits between a page and the network and can answer requests itself. The in-page demos use one to stand in for a server. (Try it first)
SQL
Structured Query Language. The language for asking a relational database to find or change rows. (Step 1)
SQL injection
An attack in which text typed by a user becomes part of an SQL statement. (Break it on purpose)
Stateless
Describes a server that treats every request as new and remembers nothing between them. (HTTP messages)
Status code
The three digit number at the start of a response that says how the request went. (HTTP messages)
Transaction
A group of database steps that either all happen or none happen. (Step 1)
URL
Uniform Resource Locator. The address of something on the web, made of scheme, host, port and path. (Step 3)
Validation
Testing that input is well formed before using it. (Step 1)
XSS
Cross-site scripting. An attack that gets a page to run a script that arrived as data. (Design choices)