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.
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
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.tswhen this page is built, with the types removed. - The real
setup-db.tscreates 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.
// 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.
// 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.
In flight at this station
Reference:The twelve stations as a list
- 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.
- 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. - 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- The reply reaches the script. The script receives the JSON and turns it back into an object. Waiting for a reply takes time, so
fetchgives the script a promise, which stands for a value that has not arrived yet. - 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.
- 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.tsneeds 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_id | item_name | price_cents | stock |
|---|---|---|---|
| A1 | Sparkling Water | 175 | 8 |
| A2 | Salted Peanuts | 125 | 5 |
| B1 | Chocolate Bar | 200 | 3 |
| B2 | Oat Biscuits | 150 | 0 |
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.
- Create a folder called
vending-tsand open a terminal in it. - Create
package.jsonwith the code below, then runnpm install. - Create a folder
src, and in it a filesetup-db.tswith the second code block. - Run
npm run setup-db.
{
"name": "vending-machine-ts",
"private": true,
"type": "module",package.jsonis the identity card of a Node project.privatestops 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 useimportandexport, 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.tsworks because Node can run TypeScript directly.tscis the TypeScript compiler.checkonly looks for type errors in the server files, andbuildturns 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
dependencieslist, because the running server uses only what Node already contains. typescriptis the compiler.@types/nodedescribes Node's built-in modules as types, so the compiler knows whatnode:httpandnode:sqliteoffer.- The caret in
^7.0.2allows newer versions with the same first (major) number, so 7.1 is allowed and 8.0 is not.
// 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. pathbuilds file locations.DatabaseSyncis 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. (awaitis how JavaScript waits for something slow. Step 5 explains it.) - The database is one file,
vending.db.import.meta.dirnameis 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 EXISTSdeletes any old table, so running the script again resets the machine. That is handy in a classroom and dangerous anywhere else.slot_idis the primary key. The database refuses a second row with the same value and builds an index for fast lookups.- The two
CHECKlines 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.
BEGINstarts a transaction, andexecruns the schema.- Node's SQLite can undo even a
DROP TABLEand aCREATE TABLEinside 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");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,
COMMITmakes 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 port8000. - 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.
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.
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.
GETreads, andPOSTchanges 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.
| Method | URL | What it does | On success | On failure |
|---|---|---|---|---|
GET | /api/slots | Lists every slot | 200 and a list of slots | None expected |
GET | /api/slots/A1 | Shows one slot | 200 and one slot | 400 for a malformed id, 404 for a missing slot |
POST | /api/slots/A1/buy | Buys one item | 200 and the updated slot | 400, 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.
- In
src, createserver.tsand type or paste the first code block below. - In
src, createtsconfig.jsonwith the second code block. - Run
npm run check. The command prints nothing when the type checker finds no problem. - Run
npm start. The server stays running, and that is correct. - Visit
http://127.0.0.1:8000/api/slotsin a browser.
// 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:
readFileSyncreads a file,createServerbuilds a web server, andDatabaseSynctalks to SQLite. import typebrings in two names that exist only as types, and makes that visible.- The
Slottype 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.
HOSTdecides who may connect.127.0.0.1allows only your own computer, which is the safe choice while you develop.PORTdecides which program receives the request.STATIC_FILESis aMap(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.tsand download our code and our database. That attack is path traversal.SLOT_ID_PATTERNis 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
databaseis 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",
);- 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
UPDATEchecksstock > 0and 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
BEGINandCOMMIT, and callsROLLBACKto undo everything if the work throws. throw errorinsidecatchpasses 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 aSlotgives aSlotback.
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. getreturns a row orundefined, so the type isSlot | undefined. After the=== undefinedtest and thethrow, the compiler knowsslotis 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;
});
}- The work runs inside
runInTransaction. First the singleUPDATEtries to take one item, andchangessays how many rows it changed. - Then we read the slot inside the same transaction, so the numbers we report match the change we made.
getSlotthrowsNoSuchSlotif 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
Routetype 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. ROUTESis 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));
}sendBodywrites the response in the order HTTP demands: the status line and headers (writeHeadwrites both), a blank line, and the body (end).Content-Lengthcounts bytes, and some characters take more than one, so we measure withBuffer.byteLengthand not.length.sendJsonturns the data into text withJSON.stringifyand hands the text tosendBody.- The type
unknownfordatameans 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.urlwith theURLclass, which also separates the path from any query string (the?name=valuepart). ??supplies a default when the left side is missing. A listener onfinishprints 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
catchis 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.
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"lib": ["ES2023"],
"types": ["node"],This file configures the type checker for the server files.
targetnames the version of JavaScript we write.module: NodeNextfollows the rules of Node's module system.liblists the built-in objects the compiler may assume exist. We leave out the browser's objects such asdocument, 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"]
}noEmitmeans the compiler only checks and writes no files, because Node runs the TypeScript directly by stripping out the types.erasableSyntaxOnlyforbids the few TypeScript features that need real translation, such asenum(a named set of constants), so deleting the types always leaves valid JavaScript.verbatimModuleSyntaxasks us to writeimport typefor 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.
- In
vending-ts, create a folder calledpublic. - In that folder, create
index.htmlandstyle.csswith the code below. - With the server still running, open
http://127.0.0.1:8000.
<!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 theviewportline makes the page fit a phone screen.- The title appears on the browser tab, and
linkloads the stylesheet. /style.cssbegins 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
idattributes are names the script uses to find elements. #slotsstarts empty. The script fills#slotswith 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.
/* 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;
}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 database
slot_id | item_name | price_cents | stock A1 | Sparkling Water | 175 | 7
selectOneSlot.get(slotId) returns the row as an object2. A JavaScript object in the server
{ slot_id: 'A1', item_name: 'Sparkling Water', price_cents: 175, stock: 7 }JSON.stringify(...) serialises the object into text3. Text on the network (JSON)
{"slot_id":"A1","item_name":"Sparkling Water","price_cents":175,"stock":7}response.json() parses the text into an object4. 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 textContent5. 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.
- Create a folder
client, and in itapp.tswith the first code block below. - In
client, createtsconfig.jsonwith the second code block. - Run
npm run build. The compiler writespublic/app.js. - Reload
http://127.0.0.1:8000.
// 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
idand keep them in variables, so we search the page once and not on every click. as HTMLTemplateElementtells the compiler a fact about our own page that the compiler cannot see.Intl.NumberFormatis a formatter the browser provides. With the localeen-IEand the currencyEURthe 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
asyncfunction,awaitpauses 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. fetchsends the request at Station 2, and its reply arrives at Station 10.- If no response arrives at all,
fetchrejects, and we turn that into a friendlier message. - If a response arrives with a bad status such as 404 or 409,
fetchstill counts it as success, so we checkresponse.okourselves and pass on the server's own message. <T>lets each caller say what it expects, as inrequestJson<Slot[]>(...). The parsed JSON is typedunknown, the accurate type for data from outside, and the finalas Tis 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. WithinnerHTML, 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;
}
}slots.map(createSlotCard)converts an array of data into an array of cards.replaceChildren(...)swaps the contents of#slotsfor them in one step. That is the moment the DOM changes, and the browser repaints at Station 12.tryandcatchshow any error in the status line. Insidecatchthe error has typeunknown, soas Errorstates 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;
}
});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 thedata-slotattribute that the card carries, sends aPOST, 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.
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"moduleDetection": "force",The page script needs different settings, so it has its own configuration.
- Browsers understand
importandexport, so the module format stays modern. moduleDetection: forcetreats every file as a module, so names such asSlotstay 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
- Open a terminal in the project folder and run the commands below.
- Open
http://127.0.0.1:8000in 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 runningWatch the traffic in the browser
- Press F12 to open the developer tools (Ctrl+Shift+I on Windows and Linux, Option+Command+I on a Mac).
- Choose the Network tab and reload the page. Each row is one message the browser sent: the page,
style.css,app.jsandslots, which is the API request. - Click the
slotsrow. Headers shows the request and the response, and Response shows the JSON. - 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 200The 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-ishows the response headers.-X POSTchooses the method.- In older versions of Windows PowerShell,
curlis an alias (a second name) for another command, so typecurl.exeinstead.
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.
POST /api/slots/a1/buy HTTP/1.1The 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: */*Headers come next, one per line, each as a name, a colon and a value.
Hostnames the server the client believes it is talking to.User-Agentnames the client program.Acceptlists 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.
HTTP/1.1 200 OKThe 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=5The response headers describe the body.
Content-Typesays how to read the body: JSON in UTF-8, the standard way of turning characters into bytes.Content-Lengthsays how many bytes of body follow.Cache-Control: no-storeasks 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}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.
| Code | Name | When we send it |
|---|---|---|
| 200 | OK | The request worked and the body holds the result. |
| 400 | Bad Request | The client sent something the server cannot make sense of, such as the slot id hello. |
| 404 | Not Found | The request was well formed, but nothing exists at that address, such as slot Z9. |
| 409 | Conflict | The request was fine but clashes with the current state. Buying from a sold-out slot is a conflict. |
| 500 | Internal Server Error | Something broke inside the server. The fault is ours, not the client's. |
Methods
GETreads something and is expected to change nothing. A method like that is safe.POSTasks the server to do something, and it may change data.- Browsers, link previews in chat apps and search engine crawlers send
GETrequests freely and may repeat them. If buying were aGET, 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.dbin the free program DB Browser for SQLite, or use thesqlite3command 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
- In the page, press Buy on the Chocolate Bar until it is sold out. The button greys out after the third press.
- 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 - 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: 0Experiment 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
- Leave the API on port 8000. In a second terminal, serve only the
publicfolder on port 9000, for examplenpx http-server public -p 9000. - In the page's script, change the two URLs that begin with
/api/so that they begin withhttp://127.0.0.1:8000/api/. Runnpm run buildafterwards, because the browser runs the compiled file and not your TypeScript. - 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.
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 choice | Why it fits here | When you might choose differently |
|---|---|---|
| SQLite for storage | Nothing 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 framework | Every 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 program | Node 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 API | Both 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 elements | The 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 cents | Whole 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 buying | Buying 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 everywhere | The 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 > 0 | A 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 places | The 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 innerHTML | textContent 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 card | The 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 reply | The 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 send | A 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.
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:httpmodule 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.
- A restock route. Add
POST /api/slots/A1/restockwith 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 withfor await (const chunk of request), join them, and parse the text withJSON.parse. That loop needsawait, soanswerRequestbecomes anasyncfunction. - A sales log in the same transaction. Create a
salestable with an id, a slot id that points to a row ininventory(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. - A summary route. Add
GET /api/sales/summarythat returns, for each item, how many were sold and how much money they earned. This needs aJOIN, which combines rows from two tables that share a value, and aGROUP BY, which gathers rows that share a value so that you can count or add them. The result has to arrive as clean JSON. - 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. - 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.
- One type, shared. The
Slottype 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)