---
title: 'Server-Side Fundamentals'
source: 'https://academia.sh/en/courses/server-fundamentals'
language: en
updated: '2026-08-19T05:19:36+00:00'
license: 'CC BY-SA 4.0'
lessons: 16
outcomes:
    - 'Breaks the server-side processing chain of a request into its components'
    - 'Separates the roles of the web server, the application server, and the runtime'
    - 'Builds an application scaffold that reads its configuration from the environment'
---

# Server-Side Fundamentals

The path a request takes on the server side: the division of labor between the web server, the runtime, and the application; the request lifecycle; the middleware chain; and a portable application scaffold configured from the environment.

## The Backend's Role

The responsibilities the server side takes on: the responsibility boundary with the client, the roles of the web server and the runtime, a request's lifecycle from accept to response, and the middleware chain that orders cross-cutting concerns.

### What the Backend Does

Source: https://academia.sh/en/courses/server-fundamentals/what-the-backend-does

The three responsibilities the server side takes on: keeping state in a single place, the point where business rules are enforced, and integration with systems the client cannot reach.

The Application Architecture course in the Frontend Development curriculum established that
an application running in the browser fetches data through a request layer: the application
sends a request to an address, parses the response it receives, maps the status code to an
error contract, and updates the screen. In that course, the side the request went to was a
fixed assumption. The How the Internet Works course defined the shape of the request — the
request line, headers, body — but not what the program running on the other side does.

This lesson fills that gap and answers a single question: what does the side facing the
client take on, and why can this work not be left to the client? The answer splits into
three responsibilities — **persistence**, **business rules**, and **integration**. All three
rest on the same reasoning, and that reasoning is demonstrated through measurement in this
lesson. A single application will run through the curriculum: a library loan service. The
concepts of book, member, loan transaction, and branch will carry the same meaning across
every lesson.

#### State Cannot Stay in a Single Client's Memory

The loan service's core data is a list: which member holds which book. What happens if this
list is kept on the client? The program below represents a single client; the catalog is
fixed, and the loan record lives in the process's own memory.

```js
// client-side-state.mjs — a client that keeps its loan record only in its own memory
const CATALOG = new Map([
  ["978-0201896831", { title: "The Art of Computer Programming", copies: 2 }],
  ["978-0262033848", { title: "Introduction to Algorithms", copies: 1 }],
]);

const loans = []; // specific to this process: another run never sees it

const borrowBook = (isbn, member) => {
  const book = CATALOG.get(isbn);
  const held = loans.filter((o) => o.isbn === isbn).length;
  if (held >= book.copies) return "rejected: no copies";
  loans.push({ isbn, member });
  return "given";
};

const [member, isbn] = process.argv.slice(2);
console.log(`${member} -> ${borrowBook(isbn, member)}`);
console.log(`${member} sees total loans: ${loans.length}`);
```

Two separate members request the same book, which has only one copy. Each member runs their
own client:

```bash
node client-side-state.mjs U-1001 978-0262033848
node client-side-state.mjs U-1002 978-0262033848
```

```
U-1001 -> given
U-1001 sees total loans: 1
U-1002 -> given
U-1002 sees total loans: 1
```

The code is not wrong: the copy check exists and is written correctly. The flaw is that the
check has no data to check against. Each run starts with its own empty list, counts its own
list correctly, and reaches the correct result. But the system has one copy, and it has been
given to two people. This is a conflict born not of concurrency but of **isolation**.

The server side's first task is to break this isolation: the loan record is kept in a single
place shared by all clients. The server below applies the same rule in the same way, with one
difference — the list sits outside the processes, in the place where requests meet.

```js
// loan-server.mjs — server that keeps the loan record and the rules in one place
import { createServer } from "node:http";

const CATALOG = new Map([
  ["978-0201896831", { title: "The Art of Computer Programming", copies: 2 }],
  ["978-0262033848", { title: "Introduction to Algorithms", copies: 1 }],
]);
const MEMBER_LIMIT = 2;
const loans = []; // shared record across all clients

const readBody = (req) =>
  new Promise((resolve, reject) => {
    let data = "";
    req.on("data", (chunk) => (data += chunk));
    req.on("end", () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); } });
  });

const respond = (res, code, obj) => {
  res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
  res.end(JSON.stringify(obj));
};

createServer(async (req, res) => {
  res.sendDate = false;
  if (req.method === "GET" && req.url === "/loans") return respond(res, 200, loans);
  if (req.method !== "POST" || req.url !== "/loans") return respond(res, 404, { error: "route_not_found" });

  const body = await readBody(req);          // body.eligible field is not read
  const book = CATALOG.get(body.isbn);
  if (!book) return respond(res, 404, { error: "book_not_found" });

  const held = loans.filter((o) => o.isbn === body.isbn).length;
  if (held >= book.copies) return respond(res, 409, { error: "no_copies", remaining: 0 });

  const open = loans.filter((o) => o.member === body.member).length;
  if (open >= MEMBER_LIMIT) return respond(res, 409, { error: "member_limit", limit: MEMBER_LIMIT, open });

  loans.push({ member: body.member, isbn: body.isbn });
  respond(res, 201, { status: "given", remaining: book.copies - held - 1 });
}).listen(8421, "127.0.0.1", () => console.log("listening 127.0.0.1:8421"));
```

The server is a long-lived process; once started from the command line, it keeps listening
until it is stopped. For that reason, the example ships with a script that starts the server
and stops it at the end. The port used, 8421, is arbitrary and must be free; if it is taken,
it is changed in both files.

```bash
#!/usr/bin/env bash
# Starts loan-server.mjs, sends five requests in sequence, reads the record, stops it.
node loan-server.mjs & server=$!
sleep 0.5

send() {
  printf '%-54s -> ' "$1"
  curl -sS -w ' [%{http_code}]\n' -X POST -H 'content-type: application/json' \
    -d "$1" http://127.0.0.1:8421/loans
}

send '{"member":"U-1001","isbn":"978-0262033848"}'
send '{"member":"U-1002","isbn":"978-0262033848"}'
send '{"member":"U-1002","isbn":"978-0262033848","eligible":true}'
send '{"member":"U-1001","isbn":"978-0201896831"}'
send '{"member":"U-1001","isbn":"978-0201896831"}'

echo "--- record on the server ---"
curl -sS http://127.0.0.1:8421/loans; echo
kill "$server"
```

```
listening 127.0.0.1:8421
{"member":"U-1001","isbn":"978-0262033848"}            -> {"status":"given","remaining":0} [201]
{"member":"U-1002","isbn":"978-0262033848"}            -> {"error":"no_copies","remaining":0} [409]
{"member":"U-1002","isbn":"978-0262033848","eligible":true} -> {"error":"no_copies","remaining":0} [409]
{"member":"U-1001","isbn":"978-0201896831"}            -> {"status":"given","remaining":1} [201]
{"member":"U-1001","isbn":"978-0201896831"}            -> {"error":"member_limit","limit":2,"open":2} [409]
--- record on the server ---
[{"member":"U-1001","isbn":"978-0262033848"},{"member":"U-1001","isbn":"978-0201896831"}]
```

The second request has been rejected. Same rule, same code, different result: the only thing
that changed is that the list the rule looks at is now the list both requests write to. The
record on the server has two lines, and the single-copy book has been given out once.

This is called **persistence**. Here the word does not narrowly mean "writing to disk"; in
later lessons the record will move to a database. Its meaning here is that state **outlives
the lifetime and visibility of a single client**. The loan record continues to exist after the
request that created it; another client can arrive and read it. The question of which data in
a system must live on the server side reduces to this criterion: is it enough for a single
client to see this data?

#### Where a Rule Is Enforced and Where It Is Displayed

The third line of the output above deserves a closer look. That request added an
`"eligible": true` field to its body — the client's own calculation saying "this loan is
eligible." The server gave the same response: `no_copies`. Because the server never reads
that field; it recomputes the decision from the list it holds.

This distinction is the essence of the business rule responsibility. The library's rule — "a
member can hold at most two books at a time" — can appear in two places at once:

- **Where it is displayed.** The client shows the borrow button as disabled to a member who
  has hit the limit, writes out the reason, and avoids an unnecessary network round trip. This
  is a user experience decision.
- **Where it is enforced.** The server re-evaluates the rule on every incoming request and
  rejects the request if it is not satisfied. This is a correctness decision.

Because the two are implementations of the same rule, they are assumed to substitute for each
other. They do not. The check on the client **reduces** requests; the check on the server
**binds** them. Which client a request reaching the server actually came from, what code that
client ran, and whether its check even ran are all outside the server's knowledge — the
`curl` calls above are the concrete form of this. The rule's only binding implementation is
the one on the side the request reaches.

This yields a design criterion: **if enforcing a rule incorrectly leaves the data
inconsistent, that rule belongs on the server side.** Exceeding the limit corrupts the loan
record, so it belongs on the server. The format in which a date is written on screen does not
corrupt data, so it can stay on the client.

#### Systems the Client Cannot Reach

The third responsibility rests on a different rationale than the first two. The library's
penalty records live in a separate service run by another team, and access to that service is
protected by a credential.

```js
// penalty-service.mjs — penalty record service run by a separate team
import { createServer } from "node:http";

const KEY = process.env.PENALTY_KEY ?? "";
const PENALTIES = new Map([["U-1001", 0], ["U-1002", 12.5]]);

createServer((req, res) => {
  res.sendDate = false;
  res.setHeader("content-type", "application/json; charset=utf-8");
  if (req.headers.authorization !== `Bearer ${KEY}`) {
    return res.writeHead(401).end(JSON.stringify({ error: "unauthorized" }));
  }
  const member = new URL(req.url, "http://local").searchParams.get("member");
  res.writeHead(200).end(JSON.stringify({ member, penalty: PENALTIES.get(member) ?? 0 }));
}).listen(8422, "127.0.0.1", () => console.log("penalty service 127.0.0.1:8422"));
```

When producing a member's status, the loan application merges its own loan record with the
penalty service's response. The application is the side that carries the credential.

```js
// application.mjs — application facing the client; it goes to the penalty service with the credential itself
import { createServer } from "node:http";

const KEY = process.env.PENALTY_KEY ?? "";
const LOANS = [{ member: "U-1001", isbn: "978-0262033848" }, { member: "U-1002", isbn: "978-0201896831" }];

createServer(async (req, res) => {
  res.sendDate = false;
  res.setHeader("content-type", "application/json; charset=utf-8");
  const member = new URL(req.url, "http://local").searchParams.get("member");

  const reply = await fetch(`http://127.0.0.1:8422/penalty?member=${member}`, {
    headers: { authorization: `Bearer ${KEY}` },
  });
  if (!reply.ok) return res.writeHead(502).end(JSON.stringify({ error: "penalty_service_unreachable" }));

  const { penalty } = await reply.json();
  const open = LOANS.filter((o) => o.member === member).length;
  res.writeHead(200).end(JSON.stringify({ member, openLoans: open, penalty, canBorrow: penalty === 0 }));
}).listen(8423, "127.0.0.1", () => console.log("application 127.0.0.1:8423"));
```

```bash
#!/usr/bin/env bash
# Starts both servers with the same shared key, measures the surface visible to the client.
export PENALTY_KEY="k-9f3a-example"
node penalty-service.mjs & penalty=$!
sleep 0.5
node application.mjs & app=$!
sleep 0.5

echo "--- client goes directly to the penalty service ---"
curl -sS -w ' [%{http_code}]\n' 'http://127.0.0.1:8422/penalty?member=U-1002'

echo "--- client goes to the application ---"
curl -sS -w ' [%{http_code}]\n' 'http://127.0.0.1:8423/member?member=U-1002'
curl -sS -w ' [%{http_code}]\n' 'http://127.0.0.1:8423/member?member=U-1001'

reply=$(curl -sS -D - 'http://127.0.0.1:8423/member?member=U-1001')
echo "--- number of lines the key appears on in the application's response (headers + body) ---"
printf '%s' "$reply" | grep -c 'k-9f3a-example'

kill "$penalty" "$app"
```

```
penalty service 127.0.0.1:8422
application 127.0.0.1:8423
--- client goes directly to the penalty service ---
{"error":"unauthorized"} [401]
--- client goes to the application ---
{"member":"U-1002","openLoans":1,"penalty":12.5,"canBorrow":false} [200]
{"member":"U-1001","openLoans":1,"penalty":0,"canBorrow":true} [200]
--- number of lines the key appears on in the application's response (headers + body) ---
0
```

Three things have been measured. The penalty service cannot be reached without the
credential. Going through the application merges data from two sources into a single
response. And in the application's response — headers included — the number of lines the key
appears on is zero.

The last measurement is decisive. Every byte sent to the client is a byte the person running
that client can read; code running in the browser cannot hide the script it downloaded or the
response it received. So if the penalty service's key had been given to the client, the key
would have been given to everyone. **The integration responsibility belongs on the server,
because the credential integration requires cannot be carried to the client.**

Integration is not limited to secrecy alone, either. What happens when the penalty service
slows down, which status code is produced when it does not respond, how multiple sources are
merged — these are decisions made on the server side. The application above makes the
simplest of these decisions: if the upstream service fails, it produces `502` and does not
break its own error contract.

#### The Common Name for the Three Responsibilities

Persistence, business rules, and integration look like three independent jobs, but they rest
on a single concept: the **source of truth**. In a system, the question "which component
decides this" must have exactly one answer for every piece of data.

For the loan record, the answer is the server, because the record is shared. For the loan
rule, it is the server, because the decision must be binding. For penalty information, it is
the server, because access to the source is protected there. In all three cases, the client
produces a **view**: it formats the data it gets from the server, previews the rule, and gives
the user a response without making them wait. Producing a view is not a small job — the
entire Frontend Development curriculum is devoted to it — but being the source of truth is a
different job.

Once this distinction is established, the next question follows on its own: should every
computation move to the server? No — every computation moved to the server means a network
round trip, a response delay, and a scaling cost. Where exactly the boundary runs is a
separate decision.

#### Summary

- When state is kept in a single client's memory, every client makes a decision that is
  correct from its own view but wrong for the system as a whole; the example run demonstrates
  this by giving a single-copy book to two members.
- Persistence means data outlives the lifetime and visibility of a single client; the
  criterion is whether other clients need to see that data too.
- A business rule is **displayed** on the client and **enforced** on the server; the
  ready-made decision field the client adds to the body is not read by the server — the
  decision is recomputed from the data on hand.
- Every rule that leaves data inconsistent when enforced incorrectly belongs on the server
  side; only decisions that affect presentation can stay on the client.
- Integration belongs on the server, because the credentials of upstream services cannot be
  carried to the client; in the measured response, the number of lines the key appears on is
  zero.
- The three responsibilities rest on a single concept: for every piece of data, which
  component is the source of truth must be determined in exactly one way.

#### Next Step

The server being the source of truth does not mean every computation happens on the server.
Sorting a book list, the amount of a late fee, the results of a search box — each of these can
be computed on either side, and the choice produces measurable results: bytes transferred,
number of round trips, response time, and how binding the decision is. The next lesson draws
that boundary: it performs the same computation on both sides and measures the data and time
transferred, then shows an item measurement cannot decide — where a computation that must be
binding needs to sit comes from trust, not performance.

### The Client and Server Responsibility Boundary

Source: https://academia.sh/en/courses/server-fundamentals/client-and-server-responsibility-boundary

The criteria that decide the choice when the same computation can be done on either side; bytes transferred, number of network round trips, and how binding the decision is are measured and compared.

The previous lesson established that the server is the source of truth through three
responsibilities: persistence, business rules, and integration. This does not mean every
computation happens on the server. In the library application, many operations — filtering a
list, computing an amount, normalizing a search term — can be written on either side, and both
work correctly.

This lesson examines where that boundary runs using three measures: bytes transferred, number
of network round trips, and how binding the decision is. The first two are measurable; the
third is not, but it is the most decisive.

#### Same Result, Two Paths: Data Transferred

The library's catalog holds five thousand books. The user wants to see the first page of
books on the topic of "networks." There are two paths: the server sends the whole catalog and
the client filters; or the server filters, pages, and sends only the slice to be displayed.
The server below serves both.

```js
// catalog-server.mjs — serves the same catalog in two forms: the whole thing, and a filtered page
import { createServer } from "node:http";

const TOPICS = ["algorithms", "databases", "networks", "operating-systems", "compilers"];
const CATALOG = Array.from({ length: 5000 }, (_, i) => ({
  isbn: `978-0${(1000000 + i).toString()}`,
  title: `Book ${i + 1}: a study of ${TOPICS[i % 5]}`,
  topic: TOPICS[i % 5],
  year: 1970 + (i % 55),
  shelf: `S-${Math.floor(i / 50) + 1}`,
}));

createServer((req, res) => {
  res.sendDate = false;
  const address = new URL(req.url, "http://local");
  res.setHeader("content-type", "application/json; charset=utf-8");

  if (address.pathname === "/books/all") {
    return res.writeHead(200).end(JSON.stringify(CATALOG));
  }
  if (address.pathname === "/books") {
    const topic = address.searchParams.get("topic");
    const page = Number(address.searchParams.get("page") ?? 1);
    const pageSize = 20;
    const filtered = topic ? CATALOG.filter((k) => k.topic === topic) : CATALOG;
    const slice = filtered.slice((page - 1) * pageSize, page * pageSize);
    return res.writeHead(200).end(JSON.stringify({ total: filtered.length, page, books: slice }));
  }
  res.writeHead(404).end(JSON.stringify({ error: "route_not_found" }));
}).listen(8424, "127.0.0.1", () => console.log("catalog 127.0.0.1:8424"));
```

```bash
#!/usr/bin/env bash
# Measures the bytes and time carried by the two approaches that produce the same result.
node catalog-server.mjs & server=$!
sleep 0.5
curl -sS -o /dev/null 'http://127.0.0.1:8424/books?topic=networks'   # warm-up

FORMAT='  body %{size_download} B   first byte %{time_starttransfer} s\n'
echo "--- whole catalog moves to the client (filtering on the client) ---"
curl -sS -o /dev/null -w "$FORMAT" 'http://127.0.0.1:8424/books/all'
echo "--- filtering and paging on the server ---"
curl -sS -o /dev/null -w "$FORMAT" 'http://127.0.0.1:8424/books?topic=networks&page=1'

echo "--- first lines of the two responses ---"
curl -sS 'http://127.0.0.1:8424/books?topic=networks&page=1' | head -c 220; echo
kill "$server"
```

```
catalog 127.0.0.1:8424
--- whole catalog moves to the client (filtering on the client) ---
  body 579494 B   first byte 0.002180 s
--- filtering and paging on the server ---
  body 2191 B   first byte 0.000734 s
--- first lines of the two responses ---
{"total":1000,"page":1,"books":[{"isbn":"978-01000002","title":"Book 3: a study of networks","topic":"networks","year":1972,"shelf":"S-1"},{"isbn":"978-01000007","title":"Book 8: a study of networks","topic":"networks",
```

Time fields depend on the machine and the load at that moment; they change on every run. What
stays fixed is the byte ratio: 579,494 bytes against 2,191 bytes, about 264 times. The user
will see twenty lines on screen; on the first path, the entire five thousand records were
transferred for those twenty lines.

The difference is not only bandwidth. Once the whole catalog lands on the client, it is also
held in memory, parsed, and walked again on every filter operation; as the catalog grows, this
cost is paid on the client's weakest device. On the other path, by contrast, every filter
change means a new request: when the user changes the topic, a network call goes out and the
result is awaited.

The criterion is the ratio between these two costs. If the data set is too large to fit on
screen, filtering belongs on the server. If the data set is small and already downloaded — a
member's six open loans, say — repeatedly requesting the same list from the server produces
unnecessary round trips.

#### The Item Measurement Cannot Decide: Whether the Decision Is Binding

The decision above can be made on performance grounds; either option produces the correct
result. For some computations, though, there is no choice, and the reasoning does not rest on
measurement.

The late fee is such a computation. The loan period is fourteen days, and a per-day fee is
charged for every day of delay. The computation is two multiplications and a subtraction; the
client could do it too. The server below does not read the amount the client sends or the time
the client reports.

```js
// return-server.mjs — computes the late fee itself, without looking at what the client reports
import { createServer } from "node:http";

const DAY = 86_400_000;
const LOAN_DAYS = 14;      // loan period
const DAILY_FEE = 2.5;     // fee per day of delay

// Seed data: a loan created 20 days ago as the server comes up.
const LOANS = new Map([["U-1002:978-0201896831", { issuedAt: Date.now() - 20 * DAY }]]);

const readBody = (req) =>
  new Promise((resolve, reject) => {
    let data = "";
    req.on("data", (chunk) => (data += chunk));
    req.on("end", () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); } });
  });

createServer(async (req, res) => {
  res.sendDate = false;
  res.setHeader("content-type", "application/json; charset=utf-8");
  const body = await readBody(req);                    // body.amount and body.now are not read
  const record = LOANS.get(`${body.member}:${body.isbn}`);
  if (!record) return res.writeHead(404).end(JSON.stringify({ error: "loan_not_found" }));

  const dueDate = record.issuedAt + LOAN_DAYS * DAY;
  const lateDays = Math.max(0, Math.floor((Date.now() - dueDate) / DAY));
  const amount = Number((lateDays * DAILY_FEE).toFixed(2));
  res.writeHead(200).end(JSON.stringify({
    member: body.member, lateDays, amount,
    clientReported: body.amount ?? null,
  }));
}).listen(8425, "127.0.0.1", () => console.log("return 127.0.0.1:8425"));
```

```bash
#!/usr/bin/env bash
# Does the server's response change when the client-reported amount and time change?
node return-server.mjs & server=$!
sleep 0.5

send() {
  printf '%-62s\n  -> ' "$1"
  curl -sS -w '\n' -X POST -H 'content-type: application/json' -d "$1" http://127.0.0.1:8425/return
}

send '{"member":"U-1002","isbn":"978-0201896831"}'
send '{"member":"U-1002","isbn":"978-0201896831","amount":0}'
send '{"member":"U-1002","isbn":"978-0201896831","amount":0,"now":"2020-01-01T00:00:00Z"}'
kill "$server"
```

```
return 127.0.0.1:8425
{"member":"U-1002","isbn":"978-0201896831"}
  -> {"member":"U-1002","lateDays":6,"amount":15,"clientReported":null}
{"member":"U-1002","isbn":"978-0201896831","amount":0}
  -> {"member":"U-1002","lateDays":6,"amount":15,"clientReported":0}
{"member":"U-1002","isbn":"978-0201896831","amount":0,"now":"2020-01-01T00:00:00Z"}
  -> {"member":"U-1002","lateDays":6,"amount":15,"clientReported":0}
```

All three requests got the same result: six days late, a fee of fifteen units. The second
request reported a zero amount, and the third also set its own clock back six years. The
server's response did not change, because the server merely recorded both inputs and computed
the amount from its own data and its own clock.

The principle at work here is not a performance principle but a **trust boundary** principle:
the server treats no value as binding unless it can derive that value from data under its own
control. This list includes amounts, clock readings, identity claims, prices, and
authorization information coming from the client. The client can send all of these; the server
takes all of them as **input** and none of them as a **decision**.

The client performing the same computation is still useful. When a member opens the return
screen, they see the amount immediately and are not surprised. This computation is an
estimate, and when it conflicts with the server's computation, the server's holds. The same
rule appeared in the first lesson with the loan limit: the client displays, the server binds.

#### The Reverse Direction: Computation That Should Not Move to the Server

The boundary does not run one way only. Moving a computation to the server unnecessarily is
also a design mistake, and its cost is measured in the number of round trips.

As the user types in the catalog search box, the search term is corrected: leading and
trailing spaces are dropped, the text is lowercased, and internal spaces are collapsed to one.
Case folding is locale-sensitive, and that can look like a reason to do the correction on the
server. The measurement below shows the cost.

```js
// round-trip-cost.mjs — the cost of going to the server for every keystroke in a search box
import { createServer } from "node:http";

const KEYSTROKES = ["a", "al", "alg", "algo", "algor", "algori", "algorit", "algorithm"];
const normalize = (text) => text.trim().toLocaleLowerCase("en-US").replace(/\s+/g, " ");

const server = createServer((req, res) => {
  res.sendDate = false;
  const q = new URL(req.url, "http://local").searchParams.get("q") ?? "";
  res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ q: normalize(q) }));
});

await new Promise((resolve) => server.listen(8426, "127.0.0.1", resolve));
await fetch("http://127.0.0.1:8426/normalize?q=warmup");            // warm-up trip

const t0 = performance.now();
for (const key of KEYSTROKES) await (await fetch(`http://127.0.0.1:8426/normalize?q=${key}`)).json();
const serverSide = performance.now() - t0;

const t1 = performance.now();
for (const key of KEYSTROKES) normalize(key);
const clientSide = performance.now() - t1;

server.close();

console.log(`keystrokes: ${KEYSTROKES.length}`);
console.log(`client-side normalize : ${clientSide.toFixed(3)} ms`);
console.log(`server-side normalize : ${serverSide.toFixed(3)} ms  (over the loopback interface)`);
console.log("");
console.log("total wait once a network round trip is added (one trip per keystroke):");
console.log("  " + "trip".padEnd(10) + "server-side".padStart(12) + "client-side".padStart(12));
for (const rtt of [0, 20, 60, 150]) {
  const total = serverSide + KEYSTROKES.length * rtt;
  console.log("  " + `${rtt} ms`.padEnd(10) + `${total.toFixed(1)} ms`.padStart(12) +
    `${clientSide.toFixed(1)} ms`.padStart(12));
}
```

```
keystrokes: 8
client-side normalize : 0.004 ms
server-side normalize : 12.858 ms  (over the loopback interface)

total wait once a network round trip is added (one trip per keystroke):
  trip       server-side client-side
  0 ms           12.9 ms      0.0 ms
  20 ms         172.9 ms      0.0 ms
  60 ms         492.9 ms      0.0 ms
  150 ms       1212.9 ms      0.0 ms
```

Time fields are machine-dependent; the ratio holds. Even over the loopback interface the
difference is three orders of magnitude, because a local function call and an HTTP request are
not operations of the same size. The table shows how the difference grows once a network round
trip is added: at an eighty-millisecond round trip, eight keystrokes produce a wait of almost
one second.

There is no gain in moving this computation to the server, because the result is not binding:
getting the term correction wrong does not corrupt data, it only changes the search result.
And the server already has to perform the same correction on its own side — it will not hand
the incoming term to the query as-is. So the correction exists on both sides; it speeds up the
client's response and secures the server's correctness. **The same computation existing on
both sides is not duplication — it is what two different jobs require.**

#### Three Questions That Draw the Boundary

The three measurements above reduce to a single decision flow. Which side a computation
belongs to is asked in this order:

**Is the computation binding?** If a wrong result gets written to data or to money, the
computation belongs on the server, and the discussion ends there. A copy of the same
computation may still exist on the client, but only for display.

**Where does the input sit?** If the data the computation needs is on the server and it is
large, taking the computation to the data is cheaper than taking the data to the computation —
that is what filtering the catalog is. If the data is already downloaded to the client, the
reverse holds.

**How many times is it done?** When a computation that repeats multiple times per user
interaction moves to the server, its cost is multiplied by the number of round trips. One
request per keystroke means several round trips per second.

The three questions can conflict. A search box is both repeated frequently and has its input
on the server: in that case, the computation itself stays on the server, but request frequency
is reduced with debouncing. The conflict is resolved not by canceling out one of the questions
but by reducing both costs together.

#### Summary

- The choice between two placements that produce the same result is measurable: moving
  filtering to the server on a five-thousand-record catalog reduced the body transferred from
  579,494 bytes to 2,191 bytes.
- Where filtering happens depends on the size of the data set; for a small,
  already-downloaded list, going to the server on every interaction produces unnecessary round
  trips.
- Binding computations are not chosen by measurement: the server takes no value it cannot
  derive from its own data and its own clock as a decision; the amount and time the client
  reported did not change the response.
- The cost of moving a presentation computation to the server is multiplied by the number of
  round trips; the measured difference for eight keystrokes is three orders of magnitude over
  the loopback interface, and climbs to seconds over a real round trip.
- The decision is made with three questions: is the computation binding, where does the input
  sit, how many times is it repeated.

#### Next Step

Up to this point, "the server" has been treated as a single program: one process that listens
on a port, reads the request, enforces the rule, and writes the response. In real deployments,
this work is split across multiple components. The component serving the library
application's cover images and interface files does not need to be the same one that runs the
loan rule; a component's only job up front might even be forwarding the request to the right
place. The next lesson builds that division of labor: serving static files, splitting requests
by path, and handing a request off to another server are each run separately and demonstrated.

### The Web Server Concept

Source: https://academia.sh/en/courses/server-fundamentals/web-server-concept

The server side's three distinct roles: serving files with the correct headers, splitting requests by path, and handing a request off to a backend server, each set up and measured as a separate process.

In the previous two lessons, "the server" was treated as a single program: one process that
listens on a port, reads the request, enforces the rule, and writes the response. The library
application's real traffic, however, is not homogeneous. The same port receives both a
`/style.css` request and a `/api/books` request; the first is a file on disk being served
as-is, the second is the result of a computation.

This lesson splits that distinction into three roles and runs each role as a separate process:
**static serving**, **routing**, and **proxying**. When the roles are set up separately, which
job belongs to which component becomes visible; when they are gathered into a single program,
the same roles still exist, only their boundaries are invisible.

#### Static Serving: Finding the File and Returning It with the Right Header

Static serving means mapping a request's path to a file on the file system and sending the
content back as-is. On the surface it looks like a one-line job; counted out, it has four
responsibilities.

```js
// static-server.mjs — serves files under assets/, blocks escaping outside it
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { join, normalize, extname, sep } from "node:path";

const ROOT = join(process.cwd(), "assets");
const TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
  ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8" };

createServer(async (req, res) => {
  res.sendDate = false;
  res.setHeader("x-served-by", "static");
  const path = decodeURIComponent(new URL(req.url, "http://local").pathname);
  const full = normalize(join(ROOT, path === "/" ? "/index.html" : path));

  if (!full.startsWith(ROOT + sep)) {                       // path escaping outside the root is rejected
    return res.writeHead(403, { "content-type": "text/plain" }).end("outside root\n");
  }
  try {
    const content = await readFile(full);
    res.writeHead(200, {
      "content-type": TYPES[extname(full)] ?? "application/octet-stream",
      "content-length": content.length,
      "cache-control": "public, max-age=31536000, immutable",
    }).end(content);
  } catch {
    res.writeHead(404, { "content-type": "text/plain" }).end("not found\n");
  }
}).listen(8427, "127.0.0.1", () => console.log("static 127.0.0.1:8427"));
```

To run the example, there must be an `assets` directory next to the server file, containing
two files:

```bash
mkdir -p assets
printf 'body { font-family: system-ui; }\n.book { padding: 8px; }\n' > assets/style.css
printf '<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><title>Library</title>\n<link rel="stylesheet" href="/style.css"></head>\n<body><h1>Library Loan Service</h1></body></html>\n' > assets/index.html
```

```bash
#!/usr/bin/env bash
# Starts static-server.mjs, tries four paths, stops it. The assets/ directory must sit next to it.
node static-server.mjs & server=$!
sleep 0.5
for path in / /style.css /missing.css '/%2e%2e%2f%2e%2e%2fetc%2fhosts'; do
  printf '%-32s -> ' "$path"
  curl -sS -o /dev/null --path-as-is -w '%{http_code}  type=%{content_type}  size=%{size_download} B\n' \
    "http://127.0.0.1:8427$path"
done
echo "--- response headers ---"
curl -sS -o /dev/null -D - --path-as-is http://127.0.0.1:8427/style.css
kill "$server"
```

```
static 127.0.0.1:8427
/                                -> 200  type=text/html; charset=utf-8  size=182 B
/style.css                       -> 200  type=text/css; charset=utf-8  size=57 B
/missing.css                     -> 404  type=text/plain  size=10 B
/%2e%2e%2f%2e%2e%2fetc%2fhosts   -> 403  type=text/plain  size=13 B
--- response headers ---
HTTP/1.1 200 OK
x-served-by: static
content-type: text/css; charset=utf-8
content-length: 57
cache-control: public, max-age=31536000, immutable
Connection: keep-alive
Keep-Alive: timeout=5
```

The four responsibilities appear separately in the output. **Path mapping**: the `/` request
corresponds to the directory entry. **Content type**: the same server produced two different
types; when the type is wrong, the browser cannot interpret the file and the style does not
apply. **Missing resource**: a missing file is not a crash, it is a `404` status code. **Root
boundary**: the last request got a `403`.

The last line also carries a warning. The `new URL()` constructor simplifies `..` components
in the path on its own, so a plain `/../../etc/hosts` request is cleaned up before it reaches
the server. The encoded form (`%2e%2e%2f`), however, passes through without simplification and
turns into a path that escapes the directory after `decodeURIComponent`. This is why the check
is done on the resolved **absolute path**, compared against the root directory: a check that
looks at the textual form of the path has to anticipate every encoding of it in advance.

The `cache-control` header in the response carries the distinguishing property of static
serving: because the content does not vary by request, it can be cached for a long time. The
meaning and trade-offs of this header were established in The Browser and the Web Platform
course; what matters here is that the same header cannot be placed on a dynamic response.

#### Routing and Proxying: Getting the Request to the Right Component

The static server does not know what to do with a `/api/books` request, and the loan
application should not have to bother reading a CSS file. The component that sets up the
division of labor between them is the **reverse proxy**: it accepts the request coming from
the client, forwards it to one of the backend servers according to a rule, and writes the
response it gets back to the client.

```js
// application-server.mjs — loan application; tells the request which connection it came from
import { createServer } from "node:http";

const BOOKS = [{ isbn: "978-0262033848", title: "Introduction to Algorithms", shelf: "R-12" }];

createServer((req, res) => {
  res.sendDate = false;
  res.setHeader("x-served-by", "application");
  res.setHeader("content-type", "application/json; charset=utf-8");
  res.writeHead(200).end(JSON.stringify({
    path: req.url,
    books: BOOKS,
    connectionOwner: `${req.socket.remoteAddress}:${req.socket.remotePort}`,
    forwarded: req.headers.forwarded ?? null,
  }));
}).listen(8428, "127.0.0.1", () => console.log("application 127.0.0.1:8428"));
```

```js
// proxy.mjs — reverse proxy in front: forwards to one of two backends by path
import { createServer, request } from "node:http";

const TARGET = (path) => (path.startsWith("/api/") ? 8428 : 8427);

createServer((outerReq, outerRes) => {
  outerRes.sendDate = false;
  const source = outerReq.socket.remoteAddress;
  const headers = { ...outerReq.headers, forwarded: `for="${source}";proto=http` };

  const innerReq = request(
    { host: "127.0.0.1", port: TARGET(outerReq.url), path: outerReq.url,
      method: outerReq.method, headers },
    (innerRes) => {
      outerRes.writeHead(innerRes.statusCode, { ...innerRes.headers, "x-proxy": "passed" });
      innerRes.pipe(outerRes);
    },
  );
  innerReq.on("error", () => outerRes.writeHead(502).end("backend unreachable\n"));
  outerReq.pipe(innerReq);
}).listen(8429, "127.0.0.1", () => console.log("proxy 127.0.0.1:8429"));
```

```bash
#!/usr/bin/env bash
# Starts all three servers together: static (8427), application (8428), proxy (8429).
node static-server.mjs & s1=$!
sleep 0.3
node application-server.mjs & s2=$!
sleep 0.3
node proxy.mjs & s3=$!
sleep 0.6

echo "--- through the proxy: which backend answered ---"
for path in /style.css /api/books; do
  printf '%-16s -> ' "$path"
  curl -sS -o /dev/null -D - "http://127.0.0.1:8429$path" | grep -iE '^(HTTP|x-served-by|x-proxy)' | tr -d '\r' | paste -sd' ' -
done

echo "--- connection seen by the application: direct request and request through the proxy ---"
printf 'direct  : '; curl -sS http://127.0.0.1:8428/api/books | tr ',' '\n' | grep -E 'connectionOwner|forwarded' | paste -sd' ' -
printf 'proxied : '; curl -sS http://127.0.0.1:8429/api/books | tr ',' '\n' | grep -E 'connectionOwner|forwarded' | paste -sd' ' -

echo "--- duration of the extra hop (same response, two paths) ---"
curl -sS -o /dev/null -w 'direct  : %{time_total} s\n' http://127.0.0.1:8428/api/books
curl -sS -o /dev/null -w 'proxied : %{time_total} s\n' http://127.0.0.1:8429/api/books

kill "$s1" "$s2" "$s3"
```

```
static 127.0.0.1:8427
application 127.0.0.1:8428
proxy 127.0.0.1:8429
--- through the proxy: which backend answered ---
/style.css       -> HTTP/1.1 200 OK x-served-by: static x-proxy: passed
/api/books       -> HTTP/1.1 200 OK x-served-by: application x-proxy: passed
--- connection seen by the application: direct request and request through the proxy ---
direct  : "connectionOwner":"127.0.0.1:59338" "forwarded":null}
proxied : "connectionOwner":"127.0.0.1:59337" "forwarded":"for=\"127.0.0.1\";proto=http"}
--- duration of the extra hop (same response, two paths) ---
direct  : 0.000647 s
proxied : 0.000935 s
```

The order of the three servers' startup lines in the output changes from run to run; the
ephemeral port numbers and time fields also differ on every run.

The client made a request to a single address, 8429. The `x-served-by` header shows that the
two requests came from two separate programs. From the client's perspective, this distinction
is invisible: one origin, one address, one configuration. The routing rule here is a single
line — if the path starts with `/api/`, it goes to the application; if not, to the static
server.

#### What the Proxy Adds and What It Takes Away

The proxy is not free. The two times in the measurement compare, for the same response, a
direct connection against a connection that goes through the proxy: the proxied path is
longer, because a second TCP connection is established, the request is rewritten, and the
response is relayed again. Over the loopback interface this difference is small; in a real
deployment, the network between the proxy and the backend also counts.

In exchange, several jobs are gathered into a single point. **Encrypted connections can be
terminated at the front**; the backends speak plain HTTP, and certificate management comes
down to a single component. **The same path rule can distribute across multiple backend
instances**: when target selection is made over a list instead of a single port, the proxy
turns into a load balancer. **Static assets never touch the application process**, so the
application's concurrency budget is spent only on computed requests.

Part of the cost, however, is information loss, and it shows up in the output. The application
reads who opened the request through `req.socket`; once the proxy steps in, the party that
opened this connection is the **proxy**, not the client. Because the measurement was taken on
the same machine, the address on both lines is `127.0.0.1`; what distinguishes them is that
the connection's owner has changed. Information about who the client is can only be carried by
the `Forwarded` header the proxy adds, and in the output it is populated only on the proxied
line.

This header is a **claim**, not a measurement. The client can send the same header too. That
is why the application only takes the `Forwarded` header into account on connections it has
verified as coming from a known proxy; on an application open to direct access, the header is
disregarded. The same reasoning was established in the second lesson: no value coming from the
client counts as a decision unless it is under the server's own control.

#### Roles or Programs?

Three files started three separate processes, but this is a presentation choice, not a
requirement. The same three roles can also live inside a single program: the application could
route paths that do not start with `/api/` to its own file-reading branch. What is durable
about the distinction is not the number of programs but the content of the responsibilities.

- **The web server role**: accepts the connection, parses the HTTP message, serves static
  content directly, maps paths to targets. What this role does not know is business rules.
- **The application server role**: carries the request to a handler, runs the business rule,
  produces the response body. What this role does not know is the layout of the file system.
- **The proxying role**: connects the two sides, carries source information, distributes
  across targets, produces `502` when a backend is unreachable.

The practical consequence of separating the roles is that each can scale independently: a
system whose static traffic grows scales out the static side, one whose computed requests grow
scales out the application side. In a setup where the roles are gathered into a single
program, these two needs are tied together, and both are scaled out at once.

#### Summary

- Static serving carries four responsibilities: mapping the path to a file, the correct
  content type, meeting a missing file with `404`, and checking the root directory boundary
  through the resolved absolute path.
- Because address resolution simplifies `..` components, path checking is not done on the
  text but by comparing the joined and normalized path against the root directory.
- A reverse proxy distributes requests arriving at a single address to backend servers
  according to a rule; in the measurement, the `x-served-by` header shows that two requests
  made to the same address came from two separate programs.
- The proxy adds a hop and changes the connection's owner; the client's identity can only be
  carried by the `Forwarded` header, and this header is taken into account only when it comes
  from a known proxy.
- The web server, the application server, and proxying are each a role; splitting them into
  separate processes is not required, but this split is what enables them to scale
  independently.

#### Next Step

The application server behind the proxy ran as a single process in this lesson and answered
requests one after another. What happens to the others when one request takes a long time?
How do two copies of the same application share the same port, and how are requests
distributed between them? Is the unit of a scaling decision the process, the thread, or
something else? The next lesson descends into the runtime beneath the application: it measures
how the event loop makes a single request wait, then starts worker processes that share the
same port and counts how requests are distributed among them.

### The Application Runtime

Source: https://academia.sh/en/courses/server-fundamentals/application-runtime

The process model beneath the application: how long work that blocks the event loop makes other requests wait is measured, the limit of the thread pool is shown, and why the unit of scaling is the process is counted with workers that share the same port.

The previous lesson ran the application server behind the proxy as a single process and left
its question open: what happens to the other requests when one takes a long time, how do two
copies of the same application share the same port, and is the unit of a scaling decision the
process or the thread? This lesson answers those three questions by measuring them.

The measurement uses two endpoints of the library loan service: an expensive endpoint that
verifies a member's password, and a cheap endpoint that returns a single line from the
catalog. When the two live in the same process, what the expensive work does to the cheap
requests becomes visible through the delay between them.

#### What the Runtime Provides

**The runtime** is the layer application code sits on top of: it houses the engine that
executes the code, the doors that open onto the operating system's socket and file interfaces,
and the **event loop** that queues the work between the two. The model established in The
Node.js Runtime course holds here just as it did there; what is new in this lesson is how that
same model determines a server application's scaling decisions.

The model comes down to one sentence: application code runs on a **single thread**. While one
request handler is running, no other handler runs. The event loop picks up the next queued
work once a handler finishes. Input/output waits — disk reads, network calls, database
queries — do not occupy this queue; the runtime hands the wait off to the operating system and
queues the callback when the result arrives.

This model has two consequences. Waiting work is cheap: thousands of open connections can be
held in a single process, because each one is just a socket and a callback. By contrast,
**computation-heavy work is expensive**: a computation that occupies the processor locks the
queue for its entire duration.

#### Blocking Work: How Long One Request Makes Others Wait

The server below performs member password verification in two separate ways. `verify-blocking`
calls key derivation directly and holds the event loop while waiting for the result.
`verify-pooled` hands the same computation to the runtime's **thread pool** and releases the
loop. The computation itself is the same in both.

```js
// single-process.mjs — loan service in a single process; the same work done both blocking and non-blocking
import { createServer } from "node:http";
import { scrypt, scryptSync } from "node:crypto";

const SHELVES = [{ isbn: "978-0262033848", title: "Introduction to Algorithms", shelf: "R-12" }];

// Key derivation cost: high enough to produce over half a second of processor work.
const COST = { N: 1 << 16, r: 8, p: 8, maxmem: 512 * 1024 * 1024 };

createServer((req, res) => {
  res.sendDate = false;
  res.setHeader("content-type", "application/json; charset=utf-8");
  const path = new URL(req.url, "http://local").pathname;

  if (path === "/member/verify-blocking") {
    const digest = scryptSync("password", "salt", 32, COST).toString("hex").slice(0, 16);
    return res.writeHead(200).end(JSON.stringify({ digest, path }));
  }
  if (path === "/member/verify-pooled") {
    return scrypt("password", "salt", 32, COST, (err, key) => {
      if (err) return res.writeHead(500).end(JSON.stringify({ error: "derivation" }));
      res.writeHead(200).end(JSON.stringify({ digest: key.toString("hex").slice(0, 16), path }));
    });
  }
  if (path === "/books") {
    return res.writeHead(200).end(JSON.stringify({ books: SHELVES }));
  }
  res.writeHead(404).end(JSON.stringify({ error: "route_not_found" }));
}).listen(8430, "127.0.0.1", () => console.log("single process 127.0.0.1:8430 pid=" + process.pid));
```

The measurement follows a set pattern: the heavy request is sent first, and fifty milliseconds
later four light requests follow it. The light requests have almost no work of their own; their
durations come only from waiting their turn.

```bash
#!/usr/bin/env bash
# Starts single-process.mjs; measures the duration of four light requests arriving alongside a heavy request.
node single-process.mjs & server=$!
sleep 0.6
curl -sS -o /dev/null http://127.0.0.1:8430/books          # warm-up

measure() {  # $1 = path of the heavy endpoint
  pids=()
  curl -sS -o /dev/null -w "heavy    %{time_total} s\n" "http://127.0.0.1:8430$1" > heavy.txt &
  pids+=($!)
  sleep 0.05
  for i in 1 2 3 4; do
    curl -sS -o /dev/null -w "light-$i  %{time_total} s\n" http://127.0.0.1:8430/books > "light-$i.txt" &
    pids+=($!)
  done
  wait "${pids[@]}"
  cat heavy.txt light-1.txt light-2.txt light-3.txt light-4.txt
}

echo "--- heavy work on the event loop: /member/verify-blocking ---"
measure /member/verify-blocking
echo "--- heavy work on the thread pool: /member/verify-pooled ---"
measure /member/verify-pooled

kill "$server"
rm -f heavy.txt light-*.txt
```

```
single process 127.0.0.1:8430 pid=14674
--- heavy work on the event loop: /member/verify-blocking ---
heavy    0.635000 s
light-1  0.581384 s
light-2  0.581601 s
light-3  0.580992 s
light-4  0.581166 s
--- heavy work on the thread pool: /member/verify-pooled ---
heavy    0.618827 s
light-1  0.001071 s
light-2  0.000954 s
light-3  0.000756 s
light-4  0.001232 s
```

The process id and the durations change on every run; the heavy work's absolute duration also
depends on the machine's processor. What stays fixed is the order-of-magnitude difference
between the light requests' durations in the two blocks.

In the first measurement, the light requests waited about **580 milliseconds**. Their own work
takes less than a millisecond; what they waited for was the heavy request's remaining duration.
All four finish at nearly the same moment, because all of them waited for the same block to
lift, and once it lifted, they were processed one after another.

In the second measurement, the heavy request's duration did not change — it is the same
computation — but the light requests were answered in **under one millisecond**. The only
difference is where the computation happens. In the pooled version, the event loop handed the
computation to a thread and freed itself, served the requests arriving in between at their
normal speed, and ran the callback once the result was ready.

#### The Pool Is Limited Too

The thread pool is not the solution to blocking work; it is where that work moves to. The
pool's thread count is fixed, and its default value is small. If more work is handed to the
pool at once than its capacity, the excess queues up; this time it is not the event loop that
saturates, but the pool. Because the single heavy request in the measurement did not fill the
pool, no delay appeared.

This gives the first rule of scaling: **a process's processor capacity is fixed.** Moving the
computation from the loop to the pool reduces how much requests wait on each other, but it does
not increase the process's total processor work. The number of key derivations that can be done
per second is bounded by the machine's core count, independent of which thread does the work.

#### The Unit of Scaling: The Process

A single process can saturate a single core. On a multi-core machine, the way to use the
remaining cores is to run several copies of the application. In a **cluster** setup, a
**primary process** opens the listening socket and forks several **worker processes**;
incoming connections are distributed among the workers. The workers share the same port,
because the party that opened the socket is the primary process.

```js
// cluster.mjs — three worker processes sharing the same port; each process has its own counter
import cluster from "node:cluster";
import { createServer } from "node:http";

const WORKER_COUNT = 3;

if (cluster.isPrimary) {
  let shuttingDown = false;
  for (let i = 0; i < WORKER_COUNT; i++) cluster.fork();

  cluster.on("exit", (worker) => {                        // a new worker replaces the one that died
    if (shuttingDown) return;
    console.log(`worker died pid=${worker.process.pid}, forking a replacement`);
    cluster.fork();
  });

  process.on("SIGTERM", () => {                         // workers go down together with the primary
    shuttingDown = true;
    for (const worker of Object.values(cluster.workers)) worker.kill();
    process.exit(0);
  });

  console.log(`primary pid=${process.pid}, worker count=${WORKER_COUNT}`);
} else {
  let loanCounter = 0;                                  // PROCESS-SPECIFIC memory: separate in each worker
  createServer((req, res) => {
    res.sendDate = false;
    res.setHeader("content-type", "application/json; charset=utf-8");
    if (new URL(req.url, "http://local").pathname === "/loan") loanCounter++;
    res.writeHead(200).end(JSON.stringify({ pid: process.pid, loanCounter }));
  }).listen(8431, "127.0.0.1");
}
```

The server answers every path and reports its own process id in the response; only the `/loan`
path increments the counter. This distinction makes it possible to poll the distribution without
changing the counter value.

```bash
#!/usr/bin/env bash
# Starts cluster.mjs; shows the distribution of 12 requests among the workers, the counters
# diverging, and a replacement arriving for a worker that died.
node cluster.mjs & cluster=$!
sleep 0.8

echo "--- 12 loan requests: which worker answered how many times ---"
for i in $(seq 12); do curl -sS http://127.0.0.1:8431/loan; echo; done |
  grep -o '"pid":[0-9]*' | sort | uniq -c

echo "--- four requests to the same endpoint: each worker's counter lives in its own memory ---"
for i in 1 2 3 4; do curl -sS http://127.0.0.1:8431/loan; echo; done

echo "--- a worker is killed, a replacement arrives ---"
victim=$(curl -sS http://127.0.0.1:8431/status | sed -n 's/.*"pid":\([0-9]*\).*/\1/p')
echo "killed worker pid=$victim"
kill -9 "$victim"
sleep 0.8
for i in $(seq 6); do curl -sS http://127.0.0.1:8431/status; echo; done |
  grep -o '"pid":[0-9]*' | sort -u

kill "$cluster"
```

```
primary pid=3068, worker count=3
--- 12 loan requests: which worker answered how many times ---
      4 "pid":3070
      4 "pid":3071
      4 "pid":3072
--- four requests to the same endpoint: each worker's counter lives in its own memory ---
{"pid":3072,"loanCounter":5}
{"pid":3070,"loanCounter":5}
{"pid":3071,"loanCounter":5}
{"pid":3072,"loanCounter":6}
--- a worker is killed, a replacement arrives ---
killed worker pid=3070
worker died pid=3070, forking a replacement
"pid":3071
"pid":3072
"pid":3098
```

The process ids are different on every run. What stays fixed are three observations.

**The distribution is even.** Twelve requests landed four apiece on three workers; the primary
process distributed the connections in turn. Nothing changed on the client side: the request
went to the same address, the same port. How many processes are running is invisible to the
client.

**A replacement arrives for the worker that died.** When a worker is forcibly terminated, the
primary process sees it and forks a new worker; the final list contains none of the old ids, but
a new id in their place. This also shows that a process is a **unit of isolation** as well as a
unit of scaling: one worker crashing does not affect the others, and the service does not go
down.

#### Process Memory Is the Scaling Boundary

The most instructive lines in the output are the counter lines. After twelve requests, every
worker's counter reads four; once the next four requests are distributed, the values read `5`,
`5`, `5`, and `6`. No worker knows the total count, because the counter lives in **process
memory**, and processes do not share memory.

In the library application, the counterpart of this is direct. Loan count, session information,
a temporary reservation list, a rate-limit counter — all of them break the moment they are kept
in process memory and scaled. The request in which a member logs in lands on one worker, their
next request lands on another, and the second worker does not recognize the session.

The consequence of this is the binding rule the runtime model places on application design:
**the process must be stateless.** Every value that needs to survive between requests moves out
of the process, into a shared store. The process keeps in its own memory only the values that
live for the duration of that one request. Once this rule is met, increasing the process count
comes down to a configuration decision; when it is not met, starting a second process leads to
errors.

The same rule also follows from the process being mortal. A dead worker's memory goes with it;
the worker that replaces it starts with empty memory. A value kept in process memory is lost not
only during scaling, but on every restart.

#### Summary

- Application code runs on a single thread; waiting input/output is cheap, while a computation
  that occupies the processor locks the queue for its entire duration.
- In the measurement, a half-second computation that blocked the event loop made four light
  requests arriving alongside it wait about 580 milliseconds; once the same computation was
  handed to the thread pool, the light requests were answered in under one millisecond.
- The thread pool moves the work; it does not increase capacity. The total processor work a
  process can handle is fixed.
- The unit of scaling is the process: the primary process opens the listening socket, worker
  processes share the same port, twelve requests were distributed four apiece to three workers,
  and a replacement was forked for the worker that died.
- Because processes do not share memory, a counter in process memory diverges across workers;
  state that needs to survive between requests must move out of the process, into a shared
  store.

#### Next Step

This lesson measured the outside of the process: how many copies run, which copy a request
lands on, what the copies do not share. Inside the process, however, there is a path a single
request takes, and up to now that path has been treated as a single box. How many separate
stages are there from the moment a connection is accepted to the moment the response's last
byte is written, how long does each stage take, and how does a decision made at one stage
constrain the next? The next lesson breaks the request's life cycle into stages; it timestamps
each stage and prints the order and durations.

### The Request Lifecycle

Source: https://academia.sh/en/courses/server-fundamentals/request-lifecycle

The path a request takes from connection accept to the last byte written is split into seven stages; timestamping each stage measures the order and durations, and shows that a connection and a request are separate things, and when the status line becomes irreversible.

The previous lesson measured the outside of the process: how many copies run, which copy a
request lands on, what the copies do not share. Inside the process, however, there is a path a
single request takes, and up to now that path has been treated as a single box. This lesson
opens the box.

From the outside, a request handler looks like a single step: "take the request, give the
response." In reality, there are stages in between, each occurring in its own time and each
constraining the next. The most direct way to see the stages is to put a timestamp on each one
and print the order. Two endpoints of the library loan service — lending a book and listing the
catalog — will be used for this measurement.

#### Seven Stages

A request's path on the server side goes through these steps:

1. **Connection accept.** The operating system places a TCP connection on the listening socket,
   and the application accepts it. At this point, no HTTP data has been read yet.
2. **Parsing the header lines.** The request line and headers are read and parsed. The method,
   path, and headers are now known; the body is not.
3. **Reading the body.** The body arrives in pieces; the request is complete once the last piece
   arrives.
4. **Routing.** The method and path are mapped to a handler. If there is no match, the request's
   response is `404`.
5. **The handler running.** The business rule is enforced, data is read or written, the response
   body is produced.
6. **Writing the response header.** The status code and headers are written to the socket.
7. **Completing the response.** The body's last byte is written, and the connection either
   closes or stays open for the next request.

This order is not a rule but an observation; the server below records each stage the moment it
occurs.

#### Measuring the Stages

```js
// lifecycle.mjs — records the stages a request passes through with timestamps and prints them
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";

const CATALOG = new Map([["978-0262033848", { title: "Introduction to Algorithms", copies: 2 }]]);
const t0 = () => process.hrtime.bigint();
const ms = (a, b) => Number(b - a) / 1e6;                    // nanosecond difference -> millisecond

let connectionId = 0;

const server = createServer();

server.on("connection", (socket) => {                        // 1. TCP connection accepted
  socket.acceptedAt = t0();
  socket.id = ++connectionId;
  socket.requestCount = 0;
});

server.on("request", (req, res) => {
  const socket = req.socket;
  socket.requestCount++;
  const stages = [["connection accept", socket.acceptedAt]];
  const record = (name) => stages.push([name, t0()]);

  record("header lines parsed");                             // 2. request event = headers ready

  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", async () => {
    record("body read");                                      // 3. got the body's last byte

    const path = new URL(req.url, "http://local").pathname;
    const handler = path === "/loan" && req.method === "POST" ? "lendBook"
      : path === "/books" ? "listCatalog" : null;
    record(`routing -> ${handler ?? "none"}`);                 // 4. path mapped to a handler

    let status = 404, body = { error: "route_not_found" };
    if (handler === "lendBook") {
      const payload = JSON.parse(Buffer.concat(chunks).toString() || "{}");
      const book = CATALOG.get(payload.isbn);
      await readFile(new URL(import.meta.url));                // work: a real input/output operation
      status = book && book.copies > 0 ? 201 : 409;
      body = status === 201 ? { isbn: payload.isbn, member: payload.member, remaining: --book.copies }
        : { error: "shelf_empty" };
    } else if (handler === "listCatalog") {
      status = 200;
      body = { books: [...CATALOG].map(([isbn, k]) => ({ isbn, ...k })) };
    }
    record("handler finished");                                // 5. response body ready

    const text = JSON.stringify(body);
    res.writeHead(status, { "content-type": "application/json; charset=utf-8",
      "content-length": Buffer.byteLength(text) });
    record("header written");                                  // 6. status line now final

    res.end(text);
    res.on("finish", () => {                                   // 7. last byte handed to the kernel
      record("response completed");
      const start = stages[0][1];
      console.log(`\n#${socket.id}.${socket.requestCount}  ${req.method} ${req.url} -> ${status}`);
      for (const [name, at] of stages) {
        console.log(`  ${String(ms(start, at).toFixed(3)).padStart(9)} ms  ${name}`);
      }
    });
  });
});

server.listen(8432, "127.0.0.1", () => console.log("lifecycle 127.0.0.1:8432"));
```

The measurement makes three requests: one loan and one catalog listing on the same connection,
then one unknown path on a separate connection. The `--next` option defines a second request
inside the same `curl` invocation, and the connection is reused.

```bash
#!/usr/bin/env bash
# Starts lifecycle.mjs; makes two requests on one connection and one request on a separate connection.
node lifecycle.mjs & server=$!
sleep 0.6

echo "### two requests on one connection (curl reuses the connection)"
curl -sS -o /dev/null \
  -X POST -H 'content-type: application/json' \
  -d '{"isbn":"978-0262033848","member":"U-4711"}' http://127.0.0.1:8432/loan \
  --next -sS -o /dev/null http://127.0.0.1:8432/books
sleep 0.3

echo "### unknown path on a separate connection"
curl -sS -o /dev/null http://127.0.0.1:8432/shelves
sleep 0.3

kill "$server"
```

```
lifecycle 127.0.0.1:8432
### two requests on one connection (curl reuses the connection)

#1.1  POST /loan -> 201
      0.000 ms  connection accept
      1.378 ms  header lines parsed
      2.192 ms  body read
      2.240 ms  routing -> lendBook
      3.534 ms  handler finished
      5.434 ms  header written
      6.882 ms  response completed

#1.2  GET /books -> 200
      0.000 ms  connection accept
      7.580 ms  header lines parsed
      7.702 ms  body read
      7.728 ms  routing -> listCatalog
      7.779 ms  handler finished
      7.823 ms  header written
      8.088 ms  response completed
### unknown path on a separate connection

#2.1  GET /shelves -> 404
      0.000 ms  connection accept
      0.180 ms  header lines parsed
      0.243 ms  body read
      0.278 ms  routing -> none
      0.280 ms  handler finished
      0.334 ms  header written
      0.541 ms  response completed
```

Durations change on every run; the first request's measurements also carry the warm-up cost of
module loading and the first file read. What stays fixed is the order of the stages and the
order-of-magnitude relationship between them.

The third measurement gives a baseline: for an unmatched path, the total is half a millisecond,
and the whole of that duration is protocol work — connection accept, parsing, response writing.
The application's own work is zero. In the first measurement, the 1.3 milliseconds between
`handler finished` and `routing` is the real work: the file read and the business rule. When a
request is said to be slow, the first question to ask is which of these two parts the time was
spent in.

#### The Connection and the Request Are Separate Things

The second measurement's first line reads `0.000 ms connection accept`, but its second line
shows `7.580 ms`. There is no new connection accept in between; the second request came in on
the connection the first request finished on. The `#1.1` and `#1.2` labels say this directly:
same connection, second request. The third request got a new connection number, with the label
`#2.1`.

This distinction carries practical consequences. **Work done per connection happens once**: the
TCP handshake, secure connection setup, and the delay that comes with them are amortized over
the following requests for as long as the connection stays open. By contrast, **state attached
to the connection is dangerous**: more than one member's request can pass over a single socket,
because a proxy in between can reuse the connection. A credential belongs to the request, not to
the connection.

The distinction also determines resource management. The number of open connections is
independent of the number of requests processed; a client that sends no requests at all still
holds a connection. This is why the server places separate limits on the number of connections
and on idle wait time.

#### The Body Is a Stream, Not a Value

In the code, the body is collected piece by piece through `data` events and completed by the
`end` event. This shows up in the measurement output as the interval between `header lines
parsed` and `body read`. For a small JSON body, this interval is under a millisecond; for a
large upload, it can take seconds.

Two decisions follow from this. First, **routing can be done before the body.** In this example,
the routing decision was recorded after the body was read, because the handler logic was
gathered in one place for readability. Since the method and path are already known at the
second stage, routing can be moved there; once it is, an unmatched request is rejected without
its body ever being read.

Second, **the body must be bounded.** For as long as the server collects the body into memory,
the number of bytes sent determines the server's memory consumption. Without a limit, a single
client can leave the process out of memory. How the limit is set, and which status code is
returned when it is exceeded, is covered in the File Upload and Storage lesson of this course.

#### The Moment the Decision Becomes Irreversible

The sixth stage is a threshold. Once the status line and headers are written to the socket, the
client has received them; there is no taking them back. Confirming this only takes a server that
tries to add a header and change the status code after it has already started writing the
response.

```js
// after-header.mjs — shows that the decision cannot be taken back once the status line is written
import { createServer } from "node:http";

createServer((req, res) => {
  res.sendDate = false;
  res.writeHead(201, { "content-type": "application/json; charset=utf-8" });
  res.write('{"status":"loan given"');            // body started streaming

  for (const [name, attempt] of [
    ["setHeader", () => res.setHeader("x-warning", "too late")],
    ["writeHead", () => res.writeHead(500)],
  ]) {
    try {
      attempt();
      console.log(`${name}: passed`);
    } catch (err) {
      console.log(`${name}: ${err.code}`);
    }
  }

  res.end(',"note":"status code stayed 201"}');
}).listen(8433, "127.0.0.1", () => console.log("after header 127.0.0.1:8433"));
```

```bash
#!/usr/bin/env bash
# Starts after-header.mjs; fetches the response's headers and body, shows the server log.
node after-header.mjs & server=$!
sleep 0.6
curl -sS -D - http://127.0.0.1:8433/loan
echo
kill "$server"
```

```
after header 127.0.0.1:8433
setHeader: ERR_HTTP_HEADERS_SENT
writeHead: ERR_HTTP_HEADERS_SENT
HTTP/1.1 201 Created
content-type: application/json; charset=utf-8
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"status":"loan given","note":"status code stayed 201"}
```

Because the server's log lines and `curl`'s output are written to the same terminal, the first
two lines come from the server, and the rest from the client.

Both attempts were rejected with the same error code, and the response stayed at `201`. The
output also shows `Transfer-Encoding: chunked` instead of `content-length`: because the body was
written piece by piece, the server could not announce the length in advance.

The rule this result has become is: **every decision about the response must be made before the
first byte is written.** Code that starts writing partway through the handler and then runs into
an error cannot report that error to the client — what remains is a half-finished body and the
wrong status code. This is why the response body is produced in memory first and written in a
single pass, or, in a streamed send, the possibility of an error is exhausted before the stream
begins.

#### Summary

- A request's path passes through seven stages: connection accept, parsing the headers, reading
  the body, routing, the handler running, writing the header, and completing the response.
- In the measurement, an unmatched path completed in half a millisecond; that duration is
  protocol work. The application's own work shows up in the interval between routing and the
  handler finishing.
- The connection and the request have separate lifetimes: the second request on the same
  connection does not include a new accept stage, which is why identity and state attach to the
  request, not the connection.
- The body arrives in pieces; routing can be done before the body, and when the body's size is
  not bounded, the client determines the server's memory consumption.
- Adding a header or changing the status code after the headers have been written is rejected
  with `ERR_HTTP_HEADERS_SENT`; decisions about the response are made before the first byte.

#### Next Step

All the measured stages were inside a single handler, and each stage was written into the code
by hand. In a real application, the same jobs — resolving identity, parsing the body, writing
the request to the log, catching the error — repeat in every handler. The structure that gathers
these repetitions in one place is a chain that passes the request through in sequence. The next
lesson builds that chain: it measures how the result changes when the order of the links
changes, in which order identity checking and logging need to be arranged, and where in the
chain the error catcher stands.

### The Middleware Chain

Source: https://academia.sh/en/courses/server-fundamentals/middleware-chain

An onion-model chain that gathers the work repeated on every request into one place is built; the same links are run in four separate orders, and the placement of identity checking relative to logging, the scope of the error catcher, and the correctness of the log lines are measured and compared.

The previous lesson measured a request's seven stages and wrote all the work into a single
handler. In a real application, most of the same jobs repeat at every endpoint: parsing the
body, resolving identity, writing the request to the log, catching the error. Because these do
not belong to a single endpoint, they are called **cross-cutting concerns**.

The structure that gathers cross-cutting concerns into one place is a chain that passes the
request through in sequence. This lesson builds that chain for the library loan service and
looks for an answer to one question: how much does order matter? The answer will come from
running the same links in four separate orders and comparing the results.

#### The Onion Model

A **middleware** takes a context representing the request and response, along with a function
that calls the rest of the chain. The link does its work, calls `next()`, and when control
returns, it can do a second piece of work. Because of this, the chain is not a flat pipe but
nested layers: the request goes in from the outside toward the inside, the response comes back
out from the inside toward the outside.

The model has three consequences, and all three shape this lesson's measurements:

- **If a link does not call `next()`, the chain stops there.** When identity checking fails,
  none of the links behind it run.
- **A link's code after `next()` runs after everything inside it has finished.** Duration
  measurement and the log's exit line are written here.
- **A link can only catch errors from inside itself.** An error thrown by a link outside it
  never reaches it.

#### Building the Chain

The program below defines five links and arranges them in four separate orders. The order is
chosen from the command line; the links themselves never change.

```js
// middleware.mjs — a chain of links that pass the request through in sequence; the order is chosen from the command line
import { createServer } from "node:http";

let counter = 0;

const write = (ctx, status, body) => {
  const text = JSON.stringify(body);
  ctx.res.writeHead(status, { "content-type": "application/json; charset=utf-8",
    "content-length": Buffer.byteLength(text) });
  ctx.res.end(text);
};

// --- links: each takes (context, next); if next() is not called, the chain stops there ---

const log = async (ctx, next) => {
  console.log(`${ctx.id} -> ${ctx.req.method} ${ctx.req.url}`);
  try {
    await next();
  } finally {
    console.log(`${ctx.id} <- ${ctx.res.headersSent ? ctx.res.statusCode : "no response written"}`);
  }
};

const catchErrors = async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    console.log(`${ctx.id} !! caught: ${err.code ?? err.name}`);
    write(ctx, err.code === "malformed_body" ? 400 : 500,
      { error: err.code ?? "internal_error", requestId: ctx.id });
  }
};

const parseBody = async (ctx, next) => {
  const chunks = [];
  for await (const chunk of ctx.req) chunks.push(chunk);
  const text = Buffer.concat(chunks).toString();
  try {
    ctx.body = text ? JSON.parse(text) : {};
  } catch {
    const err = new Error("body could not be parsed as JSON");
    err.code = "malformed_body";
    throw err;
  }
  await next();
};

const checkIdentity = async (ctx, next) => {
  const member = (ctx.req.headers.authorization ?? "").replace(/^Member /, "");
  if (!/^U-\d{4}$/.test(member)) return write(ctx, 401, { error: "no_identity", requestId: ctx.id });
  ctx.member = member;
  await next();
};

const route = async (ctx) => {
  const path = new URL(ctx.req.url, "http://local").pathname;
  if (path === "/loan" && ctx.req.method === "POST") {
    return write(ctx, 201, { loan: { isbn: ctx.body?.isbn, member: ctx.member }, requestId: ctx.id });
  }
  write(ctx, 404, { error: "route_not_found", requestId: ctx.id });
};

// --- the same links, four separate orders ---

const CHAINS = {
  "catcher-outside": [catchErrors, log, parseBody, checkIdentity, route],
  "identity-first": [catchErrors, checkIdentity, log, parseBody, route],
  "catcher-inside": [log, parseBody, catchErrors, checkIdentity, route],
  "log-outside": [log, catchErrors, parseBody, checkIdentity, route],
};

const choice = process.argv[2] ?? "catcher-outside";
const links = CHAINS[choice];
if (!links) {
  console.error(`unknown chain: ${choice}`);
  process.exit(2);
}

const runChain = (ctx) => {
  const advance = (i) =>
    i === links.length ? Promise.resolve() : links[i](ctx, () => advance(i + 1));
  return advance(0);
};

createServer((req, res) => {
  res.sendDate = false;
  const ctx = { req, res, id: `r-${++counter}` };
  runChain(ctx).catch((err) => {                  // last resort: an error that escaped the chain
    console.log(`${ctx.id} XX escaped the chain: ${err.code ?? err.name}`);
    if (!res.headersSent) write(ctx, 500, { error: "internal_error" });
  });
}).listen(8434, "127.0.0.1", () => console.log(`chain=${choice} 127.0.0.1:8434`));
```

The chain runner is five lines: `advance(i)` calls the i-th link and gives it `advance(i + 1)`
as `next`. Once the end of the list is reached, it returns a resolved promise. The `catch` in
the server callback is the **last-resort** link that keeps an error escaping the chain from
bringing down the process; the measurement will show when this kicks in.

The measurement makes the same three requests for each order: a valid loan request, a request
carrying no credential, and a request with a malformed body.

```bash
#!/usr/bin/env bash
# Repeats the same three requests for four separate link orders; prints the responses and the server log for each round.
ENDPOINT=http://127.0.0.1:8434/loan

requests() {
  echo "  [1] valid      : $(curl -sS -X POST -H 'authorization: Member U-4711' \
    -H 'content-type: application/json' -d '{"isbn":"978-0262033848"}' "$ENDPOINT")"
  echo "  [2] no identity: $(curl -sS -X POST \
    -H 'content-type: application/json' -d '{"isbn":"978-0262033848"}' "$ENDPOINT")"
  echo "  [3] bad body   : $(curl -sS -X POST -H 'authorization: Member U-4711' \
    -H 'content-type: application/json' -d '{isbn' "$ENDPOINT")"
}

for chain in catcher-outside identity-first catcher-inside log-outside; do
  echo "=== chain: $chain ==="
  node middleware.mjs "$chain" > "log-$chain.txt" 2>&1 & s=$!
  sleep 0.6
  requests
  sleep 0.3
  kill "$s"; wait "$s" 2>/dev/null
  echo "  --- server log ---"
  sed 's/^/  /' "log-$chain.txt"
done

rm -f log-*.txt
```

```
=== chain: catcher-outside ===
  [1] valid      : {"loan":{"isbn":"978-0262033848","member":"U-4711"},"requestId":"r-1"}
  [2] no identity: {"error":"no_identity","requestId":"r-2"}
  [3] bad body   : {"error":"malformed_body","requestId":"r-3"}
  --- server log ---
  chain=catcher-outside 127.0.0.1:8434
  r-1 -> POST /loan
  r-1 <- 201
  r-2 -> POST /loan
  r-2 <- 401
  r-3 -> POST /loan
  r-3 <- no response written
  r-3 !! caught: malformed_body
=== chain: identity-first ===
  [1] valid      : {"loan":{"isbn":"978-0262033848","member":"U-4711"},"requestId":"r-1"}
  [2] no identity: {"error":"no_identity","requestId":"r-2"}
  [3] bad body   : {"error":"malformed_body","requestId":"r-3"}
  --- server log ---
  chain=identity-first 127.0.0.1:8434
  r-1 -> POST /loan
  r-1 <- 201
  r-3 -> POST /loan
  r-3 <- no response written
  r-3 !! caught: malformed_body
=== chain: catcher-inside ===
  [1] valid      : {"loan":{"isbn":"978-0262033848","member":"U-4711"},"requestId":"r-1"}
  [2] no identity: {"error":"no_identity","requestId":"r-2"}
  [3] bad body   : {"error":"internal_error"}
  --- server log ---
  chain=catcher-inside 127.0.0.1:8434
  r-1 -> POST /loan
  r-1 <- 201
  r-2 -> POST /loan
  r-2 <- 401
  r-3 -> POST /loan
  r-3 <- no response written
  r-3 XX escaped the chain: malformed_body
=== chain: log-outside ===
  [1] valid      : {"loan":{"isbn":"978-0262033848","member":"U-4711"},"requestId":"r-1"}
  [2] no identity: {"error":"no_identity","requestId":"r-2"}
  [3] bad body   : {"error":"malformed_body","requestId":"r-3"}
  --- server log ---
  chain=log-outside 127.0.0.1:8434
  r-1 -> POST /loan
  r-1 <- 201
  r-2 -> POST /loan
  r-2 <- 401
  r-3 -> POST /loan
  r-3 !! caught: malformed_body
  r-3 <- 400
```

#### The Order of Identity Checking and Logging

The responses returned to the client in the first two rounds are **identical**. Three requests,
three identical bodies. The change in order did not change anything the client saw.

In the server log, however, a pair of lines is missing. In the `identity-first` round, `r-2`
never appears. Because identity checking comes before logging, the request without a credential
stopped at the chain's second link and never reached the logging link. The application rejected
the request without a credential but never recorded it.

The practical consequence of this is severe. A client that floods the library service with
requests carrying no credential for an hour leaves no trace during that time. Rejected requests
are exactly the requests that most need to be recorded: rate-limit decisions, abuse detection,
and debugging all depend on these records.

There is also a reason for the reverse order, and it should not be dismissed. In the
`identity-first` arrangement, a request with no credential is rejected without its body ever
being read; in the `catcher-outside` arrangement, the body is first loaded into memory, and
only then does identity checking fail. When requests have large bodies, rejecting them early is
a real saving.

The arrangement where the two reasons reconcile is the one where **logging is outermost and
identity checking comes before body parsing**: every request is recorded, and a request with no
credential is still rejected without its body being read. The `log-outside` order in the
measurement is exactly this, and the `r-2` lines have taken their place in the log.

#### The Error Catcher's Scope Comes From Its Position

In the third round, what the client sees changes. In the `catcher-inside` arrangement, the
request with the malformed body gets `{"error":"internal_error"}`; in the other three
arrangements, `{"error":"malformed_body"}` comes back along with the request id.

The reason is the onion model directly. In the `catcher-inside` order, `catchErrors` is
**inside** the `parseBody` link. When body parsing throws an error, that error propagates
outward and never passes through the catcher. The `XX escaped the chain` line in the log says
this: the error was caught not by the chain itself, but by the last-resort handler in the
server callback.

The last-resort response is inevitably unqualified. At that point, which link the request broke
in, which field is malformed, and what should be told to the user are all unknown; what remains
is a `500` and a generic message. A condition that was a user error has been reported as an
internal error, the request id could not be placed in the response, and a member seeking
support is left with no number to trace.

The rule is this: **an error catcher protects only what remains inside itself.** If it is meant
to protect the whole chain, it must stand as far outside as possible. The last resort is a
fallback, not a design; every request that triggers it is a sign of a scope missing from the
chain.

#### The Log Line Writing the Correct State

Comparing the logs of the four rounds shows a flaw in the `catcher-outside` arrangement: for the
third request, `r-3 <- no response written` was logged, and the catcher's record came only after
that. The log line was written without knowing that the response was `400`.

This too comes from the order. When `catchErrors` is outermost, the `log` link's `finally` block
runs while the error is propagating outward — that is, **before** the catcher writes the
response. The log cannot see a decision made outside itself.

In the `log-outside` arrangement, the two links swap places: logging is outermost, and the error
catcher sits right inside it. Now the catcher writes the response, control returns to the `log`
link, and the `finally` block writes `r-3 <- 400`. The same three requests, the same five links;
only two links have swapped places, and the log is now correct.

From this comes the general rule for ordering a chain: **a link must stand outside everything it
wants to observe or change.** If logging wants to see the response's final state, it stands
outside every link that produces a response; if the error catcher wants to see every error, it
stands outside every link that can throw one. Together, the two say that logging must also stand
outside the catcher.

#### Summary

- The middleware chain is the onion model: the request goes in from the outside toward the
  inside, the response comes back out from the inside toward the outside; a link's code after
  `next()` runs after everything inside it has finished.
- When identity checking comes before logging, a rejected request never appears in the log; in
  the measurement, the `r-2` lines are missing in the `identity-first` round, while the
  responses returned to the client are identical across these two arrangements.
- Early identity checking provides the benefit of rejecting without ever reading the body; the
  two reasons reconcile when logging is placed outermost and identity checking is placed before
  body parsing.
- An error catcher only sees errors from inside itself; in the `catcher-inside` arrangement, the
  malformed body was answered with `{"error":"internal_error"}`, while in the others
  `{"error":"malformed_body"}` came back along with the request id.
- A log line can only see decisions made inside itself; when the catcher stayed outside the
  logger, the state was recorded as `no response written`, and once the order was corrected,
  `r-3 <- 400` was written with the correct value.

#### Next Step

This topic broke a request's path on the server side into its components: the web server and
proxy roles, the runtime's process model, the request's stages, and the chain that orders
cross-cutting concerns. But the resulting chain's links still sit in a single file, all side by
side. The next topic builds a scaffold that can carry these parts apart: by what criterion are
files split, which module is allowed to call which, and is this boundary kept by hand or by a
check? The first lesson takes up project structure and writes a check that tests the dependency
direction.

## Application Scaffolding

The server application's carrying structure — layer and module layout, configuration read from the environment and validated at startup, secrets kept separate, structured logging, a shared error response, and portability principles.

### Project Structure

Source: https://academia.sh/en/courses/server-fundamentals/project-structure

Two layouts for files, layer-oriented and feature-oriented, are compared by measurement; domain, data, transport, and composition responsibilities are separated, and the dependency direction rule is wired to an audit program that reports a violation through its exit code.

The previous topic broke a request's server-side path into its components. The parts that
came out of it — chain links, handlers, business rules, data access — still sit side by side
in a single file. As a file grows, changing it gets harder; the real problem is not the line
count but the fact that parts that should not know about each other can still reach one
another.

This lesson sets up a directory layout that could carry a library lending service, and it
looks for the answer to one question: which module can call which? The answer will be written
not as a document but as an audit program that reports a violation through its exit code.

#### Two Layouts: Layer-Oriented and Feature-Oriented

There are two common ways to split an application's files. A **layer-oriented** layout opens
its top-level directories by technical concern: every business rule in one directory, every
piece of data access in another. A **feature-oriented** layout opens its top-level directories
by business area: everything belonging to the loan operation in one directory, everything
belonging to the catalog operation in another.

The same nine modules can be placed into either layout. The script below builds both and
counts how many directories two different changes touch.

```bash
#!/usr/bin/env bash
# Places the same nine modules in two different layouts and counts how many directories a change touches.
mkdir -p layouts

for feature in loan catalog member; do
  for concern in domain data http; do
    mkdir -p "layouts/layer-oriented/src/$concern" "layouts/feature-oriented/src/$feature"
    printf '// %s feature, %s concern\n' "$feature" "$concern" \
      > "layouts/layer-oriented/src/$concern/$feature.mjs"
    printf '// %s feature, %s concern\n' "$feature" "$concern" \
      > "layouts/feature-oriented/src/$feature/$concern.mjs"
  done
done

echo "--- layer-oriented tree ---"
find layouts/layer-oriented -name '*.mjs' | sort
echo "--- feature-oriented tree ---"
find layouts/feature-oriented -name '*.mjs' | sort

directoryCount() {  # $1 = tree root, $2 = search marker
  grep -rl "$2" "$1" | xargs -n1 dirname | sort -u | wc -l | tr -d ' '
}

echo "--- how many directories a change touches ---"
printf '%-16s %-22s %s\n' "layout" "loan feature" "data concern"
for layout in layer-oriented feature-oriented; do
  printf '%-16s %-22s %s\n' "$layout" \
    "$(directoryCount "layouts/$layout" 'loan feature') dirs" \
    "$(directoryCount "layouts/$layout" 'data concern') dirs"
done
```

```
--- layer-oriented tree ---
layouts/layer-oriented/src/data/catalog.mjs
layouts/layer-oriented/src/data/loan.mjs
layouts/layer-oriented/src/data/member.mjs
layouts/layer-oriented/src/domain/catalog.mjs
layouts/layer-oriented/src/domain/loan.mjs
layouts/layer-oriented/src/domain/member.mjs
layouts/layer-oriented/src/http/catalog.mjs
layouts/layer-oriented/src/http/loan.mjs
layouts/layer-oriented/src/http/member.mjs
--- feature-oriented tree ---
layouts/feature-oriented/src/catalog/data.mjs
layouts/feature-oriented/src/catalog/domain.mjs
layouts/feature-oriented/src/catalog/http.mjs
layouts/feature-oriented/src/loan/data.mjs
layouts/feature-oriented/src/loan/domain.mjs
layouts/feature-oriented/src/loan/http.mjs
layouts/feature-oriented/src/member/data.mjs
layouts/feature-oriented/src/member/domain.mjs
layouts/feature-oriented/src/member/http.mjs
--- how many directories a change touches ---
layout           loan feature           data concern
layer-oriented   3 dirs                 1 dirs
feature-oriented 1 dirs                 3 dirs
```

The table is symmetric, and that symmetry is the point of the choice: neither layout is
better than the other, each just makes a different kind of change cheaper. Adding a new
condition to the loan rule touches a single directory in the feature-oriented layout;
changing the shape of data access touches a single directory in the layer-oriented layout.

The question that decides between them is which kind of change happens more often in this
application. With a small number of business areas and technical concerns that change often,
a layer-oriented layout moves fewer files around; with many independent business areas, a
feature-oriented layout does the same job. The rest of this lesson uses a layer-oriented
layout, because the rule it sets up — dependency direction — can be expressed directly through
directory names there.

#### Four Responsibilities

A layer-oriented layout has four directories, and each one is defined by something it does
not know.

**Domain** (`src/domain`) carries the business rules and calls no other layer. It does not
know where the database lives, that the request arrived over HTTP, or that the response is
JSON.

```js
// src/domain/loan.mjs — loan rule; calls no other layer
export const LOAN_DAYS = 14;
export const MEMBER_LOAN_LIMIT = 5;

export const canLoan = (book, openLoanCount) => {
  if (!book) return { allowed: false, code: "book_not_found" };
  if (book.shelfCount < 1) return { allowed: false, code: "shelf_empty" };
  if (openLoanCount >= MEMBER_LOAN_LIMIT) return { allowed: false, code: "limit_exceeded" };
  return { allowed: true };
};

export const dueDate = (start) =>
  new Date(start.getTime() + LOAN_DAYS * 86400000);
```

None of these functions is asynchronous, and none of them touches the outside world. They
compute a decision from the inputs they are given, which is why testing them needs neither a
server nor a database.

**Data** (`src/data`) takes on persistence and knows nothing about business rules. It is never
asked how many loans the limit allows; it is only ever asked for a number.

```js
// src/data/book-store.mjs — owns persistence; knows nothing about business rules
const BOOKS = new Map([
  ["978-0262033848", { title: "Introduction to Algorithms", shelfCount: 1 }],
  ["978-0201835953", { title: "The Mythical Man-Month", shelfCount: 0 }],
]);
const LOANS = [];

export const findBook = async (isbn) => BOOKS.get(isbn) ?? null;

export const openLoanCount = async (member) =>
  LOANS.filter((l) => l.member === member && !l.returned).length;

export const recordLoan = async (record) => {
  BOOKS.get(record.isbn).shelfCount -= 1;
  LOANS.push(record);
  return record;
};
```

The store is kept in memory here. Under the rule set up in the previous topic, this is a
temporary application that does not work across more than one process; once it moves to a
persistent store, this is the only directory that has to change, and the domain layer is
unaffected by that change.

**Transport** (`src/http`) translates the protocol into the domain's language and carries no
business rule. Its job is to gather input, leave the decision to the domain layer, and map the
result onto a status code.

```js
// src/http/loan-endpoint.mjs — translates the HTTP request into domain language; no business rule
import { canLoan, dueDate } from "../domain/loan.mjs";
import { findBook, openLoanCount, recordLoan } from "../data/book-store.mjs";

export const loanBook = async ({ isbn, member }, today = new Date()) => {
  const book = await findBook(isbn);
  const decision = canLoan(book, await openLoanCount(member));
  if (!decision.allowed) return { status: 409, body: { error: decision.code } };

  const record = await recordLoan({ isbn, member, dueDate: dueDate(today), returned: false });
  return { status: 201, body: { isbn, member, dueDate: record.dueDate.toISOString() } };
};
```

**Composition** (`src/setup`) is the single place that brings the parts together. It is where
an application wires its dependencies, reads its configuration, and brings the server up.

```js
// src/setup/app.mjs — the single place that wires the parts together
import { loanBook } from "../http/loan-endpoint.mjs";

const today = new Date("2026-03-01T00:00:00Z");
for (const request of [
  { isbn: "978-0262033848", member: "U-4711" },   // only one copy on the shelf
  { isbn: "978-0262033848", member: "U-4712" },   // that same copy is now on loan
  { isbn: "978-0000000000", member: "U-4713" },   // not in the catalog
]) {
  console.log(JSON.stringify(await loanBook(request, today)));
}
```

#### Dependency Direction

The existence of the directories alone guarantees nothing. What makes the separation
meaningful is that the imports between them run in **one direction only**: an outer layer may
call an inner one, an inner layer may never call an outer one.

| Layer | May import |
|---|---|
| `domain` | — |
| `data` | `domain` |
| `http` | `domain`, `data` |
| `setup` | `domain`, `data`, `http` |

The reasoning behind the rule is concrete. If the domain layer called the data layer, testing
a business rule would require a store; when the store changes, the rule would change with it.
A function that knows the loan limit is five would also have to know where the record is kept
— a module that changes for two unrelated reasons.

The rule holds because the domain layer **receives the data it needs as a parameter**. The
`canLoan` function does not query the member's open loan count itself; it takes the count from
its caller. The querying is left to an outer layer, here the transport layer.

#### Tying the Rule to an Audit

A rule written in a document does not warn anyone when it is broken. The program below reads
import lines, works out each file's layer from its directory name, and reports any line that
does not match the permission table. If there is a violation, it produces a nonzero exit code
— the one thing an audit needs in order to be wired into a continuous integration step.

```js
// audit/dependency.mjs — checks the dependency direction between layers; exits with 1 on violation
import { readdir, readFile } from "node:fs/promises";
import { join, relative, sep } from "node:path";

const ALLOWED = {                          // layers each layer may import
  domain: [],
  data: ["domain"],
  http: ["domain", "data"],
  setup: ["domain", "data", "http"],
};

const IMPORT_STATEMENT = /^\s*import[^"']*["']([^"']+)["']/gm;

const entries = await readdir("src", { withFileTypes: true, recursive: true });
const files = entries.filter((e) => e.isFile() && e.name.endsWith(".mjs"))
  .map((e) => join(e.parentPath, e.name)).sort();

let violations = 0;
for (const path of files) {
  const sourceLayer = relative("src", path).split(sep)[0];
  const text = await readFile(path, "utf8");
  for (const [, specifier] of text.matchAll(IMPORT_STATEMENT)) {
    if (!specifier.startsWith(".")) continue;            // skip packages and built-in modules
    const target = specifier.replace(/^(\.\.\/|\.\/)+/, "").split("/")[0];
    if (!(target in ALLOWED) || target === sourceLayer) continue;
    if (!ALLOWED[sourceLayer].includes(target)) {
      console.log(`VIOLATION  ${path}: ${sourceLayer} -> ${target}`);
      violations++;
    }
  }
}

console.log(violations === 0
  ? `${files.length} files scanned, dependency direction follows the rule`
  : `${files.length} files scanned, ${violations} violations found`);
process.exit(violations === 0 ? 0 : 1);
```

To see the audit actually work, the rule has to be broken. The script below first runs the
application, then passes the audit, then adds an import from the data layer into the domain
layer and runs the audit again, and finally restores the tree to its original state. The
script runs in the directory holding the four source files shown above and
`audit/dependency.mjs`.

```bash
#!/usr/bin/env bash
# Runs the scaffold, passes the audit, then deliberately breaks the rule and shows the audit catching it.
echo "--- application ---"
node src/setup/app.mjs

echo "--- audit: tree that follows the rule ---"
node audit/dependency.mjs; echo "exit code=$?"

echo "--- adding a data-layer import to the domain layer ---"
cp src/domain/loan.mjs loan.bak
printf '%s\n' 'import { findBook } from "../data/book-store.mjs";' > loan.violating
cat loan.bak >> loan.violating
cp loan.violating src/domain/loan.mjs

echo "--- audit: tree with the broken rule ---"
node audit/dependency.mjs; echo "exit code=$?"

cp loan.bak src/domain/loan.mjs           # tree restored to its original state
rm -f loan.bak loan.violating
```

```
--- application ---
{"status":201,"body":{"isbn":"978-0262033848","member":"U-4711","dueDate":"2026-03-15T00:00:00.000Z"}}
{"status":409,"body":{"error":"shelf_empty"}}
{"status":409,"body":{"error":"book_not_found"}}
--- audit: tree that follows the rule ---
4 files scanned, dependency direction follows the rule
exit code=0
--- adding a data-layer import to the domain layer ---
--- audit: tree with the broken rule ---
VIOLATION  src/domain/loan.mjs: domain -> data
4 files scanned, 1 violations found
exit code=1
```

The application's output also confirms the layers' division of labor: the book with a single
copy on the shelf is loaned to the first member (`201`), the second member requesting the same
book gets a `shelf_empty` response, and a request not in the catalog gets `book_not_found`. All
three decisions were computed in the domain layer; their translation into a status code
happened in the transport layer.

The only difference between the audit's two runs is a single import line. The moment the rule
breaks, the violation is reported by file and by direction, and the exit code flips from `0`
to `1`. When a build step sees that code, it stops; the rule is no longer a suggestion, it is a
constraint.

#### Summary

- A layer-oriented layout opens its top-level directories by technical concern, a
  feature-oriented one by business area; in the measurement, a change touching the loan
  feature touched three directories in one and a single directory in the other, and the ratio
  reversed for a change touching data access.
- A layer-oriented layout separates four responsibilities: domain carries the business rule
  and calls no layer, data owns persistence, transport translates the protocol into the
  domain's language, and composition wires the parts together in one place.
- The dependency direction runs one way: an outer layer calls an inner one, never the other
  way around. The data the domain layer needs is handed to it as a parameter.
- Once the rule is wired to an audit program, a violation is reported by file name and
  direction and the exit code becomes `1`; because that code can stop a build step, the rule
  becomes a constraint.
- The in-memory store is only valid for a single-process application; moving to a persistent
  store changes only the data directory and leaves the domain layer untouched.

#### Next Step

The scaffold is standing, but every value is still written into the code: the port, the loan
period, the member loan limit, the store's location. The same application has to run with
different values locally, in a test environment, and in production, and those values cannot be
read out of the code itself. The next lesson takes up reading configuration from the
environment: which value is variable and which is a constant, and what should the application
do with a missing or wrong-typed variable? The measurement will show which message and which
exit code an application started with a missing variable fails at, on startup.

### Configuration Management

Source: https://academia.sh/en/courses/server-fundamentals/configuration-management

Configuration read from environment variables is defined by a schema; type, range, and option validation happens at startup, every problem is reported at once, and the application's crash with a sysexits exit code on an invalid environment is measured.

The previous lesson separated the scaffold's layers, but every value was still written into
the code: the port, the loan period, the member loan limit, the store's location. The same
application has to run with different values locally, in a test environment, and in
production; as long as those values live in the code, a separate build has to be produced for
each environment.

This lesson moves the values out of the code and into **environment variables**. The real
subject is not where the values are read from but when and how they are validated: what should
an application started with a missing or wrong-typed variable do?

#### What Counts as Configuration

Not every constant sitting in the code is configuration. There is a single test that draws the
line: **a value that changes from environment to environment is configuration.** A loan period
of fourteen days is the library's rule, and it is the same in production and locally; a port
number, on the other hand, changes from machine to machine.

Applying this test produces three groups:

- **Environment-dependent values.** The port, the store's address, the log level, maintenance
  mode. These are environment variables.
- **Business rule constants.** The loan period, the member loan limit. These belong to the
  domain layer and live in the code — but once they need to be tuned across environments, they
  move into configuration.
- **Secrets.** The database password, the signing key. These also come from the environment,
  but they form a separate class and are the subject of the next lesson.

The schema below defines the first two groups together; the loan period and member limit have
been pulled into configuration so that they stay open to trying different values across
environments, and their defaults are the business rule itself.

#### Schema, Default, and Validation

A configuration reader has three jobs: read the value, validate its type, and fall back to a
default when it is missing. The module below does all three through a schema. Environment
variables share a single prefix; the prefix keeps variables that do not belong to the
application from leaking into the schema.

```js
// src/setup/configuration.mjs — reads from the environment, validates everything, returns a frozen object
const PREFIX = "LIBRARY_";

export const SCHEMA = {
  PORT: { type: "number", default: 8435, min: 1024, max: 65535 },
  LOAN_DAYS: { type: "number", default: 14, min: 1, max: 90 },
  MEMBER_LOAN_LIMIT: { type: "number", default: 5, min: 1, max: 50 },
  STORE_ADDRESS: { type: "text", required: true },
  LOG_LEVEL: { type: "option", options: ["error", "warning", "info", "verbose"],
    default: "info" },
  MAINTENANCE_MODE: { type: "boolean", default: false },
};

const convert = (name, rule, raw) => {
  if (rule.type === "number") {
    const number = Number(raw);
    if (!Number.isInteger(number)) return { error: `expected an integer, got "${raw}"` };
    if (number < rule.min || number > rule.max) {
      return { error: `must be within ${rule.min}-${rule.max}, got ${number}` };
    }
    return { value: number };
  }
  if (rule.type === "boolean") {
    if (raw !== "true" && raw !== "false") {
      return { error: `expected "true" or "false", got "${raw}"` };
    }
    return { value: raw === "true" };
  }
  if (rule.type === "option") {
    if (!rule.options.includes(raw)) {
      return { error: `options [${rule.options.join(", ")}], got "${raw}"` };
    }
    return { value: raw };
  }
  return raw.length > 0 ? { value: raw } : { error: "cannot be empty" };
};

export const readConfiguration = (env) => {
  const issues = [];
  const settings = {};

  for (const [name, rule] of Object.entries(SCHEMA)) {
    const key = PREFIX + name;
    const raw = env[key];
    if (raw === undefined) {
      if (rule.required) issues.push(`${key}: required, not defined`);
      else settings[name] = rule.default;
      continue;
    }
    const result = convert(name, rule, raw);
    if (result.error) issues.push(`${key}: ${result.error}`);
    else settings[name] = result.value;
  }

  for (const key of Object.keys(env)) {          // typos should not pass through silently
    if (key.startsWith(PREFIX) && !(key.slice(PREFIX.length) in SCHEMA)) {
      issues.push(`${key}: unknown key`);
    }
  }

  if (issues.length > 0) {
    const error = new Error("configuration invalid");
    error.issues = issues.sort();
    throw error;
  }
  return Object.freeze(settings);
};
```

Three design decisions are hiding in here, and all three will show up in the measurement.

**Every problem is collected.** The reader does not stop at the first error; it walks the whole
schema, gathers problems into a list, and reports them all at once at the end. This is how you
avoid having to start an environment with eight wrong variables eight separate times.

**An unknown key is an error.** A variable that carries the prefix but has no match in the
schema is almost always a typo. Ignored silently, the application would start, run on a
default, and the reason for the wrong behavior would be hunted for in the environment file for
hours.

**The result is frozen.** The `Object.freeze` call makes the configuration **immutable**. Code
that changes a setting during execution makes it unclear what value the process is running
with at any given moment; freezing closes off that path.

#### Crashing at Startup

What determines the value of validation is when it happens. If configuration is read while
serving a request, the error surfaces on the first request — maybe hours after release. If it
is read at startup, an invalid environment never serves a single request.

The startup file below applies this: configuration is read **before** the server is built; if
it is invalid, the server is never created at all.

```js
// src/setup/start.mjs — startup: configuration is read first; if invalid, the server is never built
import { createServer } from "node:http";
import { readConfiguration } from "./configuration.mjs";

const EX_CONFIG = 78;                       // sysexits.h: configuration error

let settings;
try {
  settings = readConfiguration(process.env);
} catch (error) {
  console.error(error.message + ":");
  for (const issue of error.issues ?? []) console.error("  - " + issue);
  process.exit(EX_CONFIG);
}

console.log("configuration: " + JSON.stringify(settings));

try {
  settings.LOAN_DAYS = 30;                  // frozen object: write attempt
} catch (error) {
  console.log("settings mutation attempt: " + error.constructor.name);
}

createServer((req, res) => {
  res.sendDate = false;
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
  res.end(JSON.stringify({ loanDays: settings.LOAN_DAYS, maintenance: settings.MAINTENANCE_MODE }));
}).listen(settings.PORT, "127.0.0.1",
  () => console.log(`started 127.0.0.1:${settings.PORT}`));
```

The exit code was not picked at random. The `sysexits.h` header defines `EX_CONFIG` as `78`,
meaning "configuration error." A supervisor watching the application sees this code and knows
that restarting the process will not help: the same environment will produce the same error.

The measurement tries startup with four environments. The `env -i` call clears the environment,
so variables already sitting in the shell do not leak into the result.

```bash
#!/usr/bin/env bash
# Tries startup with four different environments; prints the message and exit code for each attempt.
try() {  # $1 = description, remaining args are KEY=value pairs
  description="$1"; shift
  echo "=== $description ==="
  env -i PATH="$PATH" "$@" node src/setup/start.mjs & pid=$!
  sleep 0.8
  if kill -0 "$pid" 2>/dev/null; then
    curl -sS "http://127.0.0.1:8435/"; echo
    kill "$pid"; echo "process still up, terminated"
  else
    wait "$pid"; echo "exit code=$?"
  fi
}

try "1. required variable missing"
try "2. type and option error together" \
  LIBRARY_STORE_ADDRESS=file:./data/library.db \
  LIBRARY_PORT=eighty \
  LIBRARY_LOG_LEVEL=detailed \
  LIBRARY_LOAN_DAY=14
try "3. out-of-range value" \
  LIBRARY_STORE_ADDRESS=file:./data/library.db \
  LIBRARY_PORT=80
try "4. valid environment (defaults fill in the rest)" \
  LIBRARY_STORE_ADDRESS=file:./data/library.db \
  LIBRARY_MAINTENANCE_MODE=true
```

```
=== 1. required variable missing ===
configuration invalid:
  - LIBRARY_STORE_ADDRESS: required, not defined
exit code=78
=== 2. type and option error together ===
configuration invalid:
  - LIBRARY_LOAN_DAY: unknown key
  - LIBRARY_LOG_LEVEL: options [error, warning, info, verbose], got "detailed"
  - LIBRARY_PORT: expected an integer, got "eighty"
exit code=78
=== 3. out-of-range value ===
configuration invalid:
  - LIBRARY_PORT: must be within 1024-65535, got 80
exit code=78
=== 4. valid environment (defaults fill in the rest) ===
configuration: {"PORT":8435,"LOAN_DAYS":14,"MEMBER_LOAN_LIMIT":5,"STORE_ADDRESS":"file:./data/library.db","LOG_LEVEL":"info","MAINTENANCE_MODE":true}
settings mutation attempt: TypeError
started 127.0.0.1:8435
{"loanDays":14,"maintenance":true}
process still up, terminated
```

#### What the Measurement Shows

**The first attempt** shows a required variable missing. The application never started, never
listened on the port, and exited with `78`. Which value is missing is written by name; a
message that just says "something went wrong" tells the person who has to fix the environment
nothing.

**The second attempt** reports three separate problems at once. `PORT` could not be converted
to a number, `LOG_LEVEL` was not on the option list, and `LIBRARY_LOAN_DAY` was flagged as an
unknown key. The third one is a variable written as `LOAN_DAY` instead of `LOAN_DAYS`; had the
schema not caught it, the application would have started and the loan period would have
silently stayed on its default. Having the option list itself written into the message means
the fix can be made without checking any documentation.

**The third attempt** separates the case where the type is right but the value is wrong. `80`
is a valid integer, but it falls in the range of privileged ports. Type checking alone is not
enough; a meaningful range is also part of the schema.

**The fourth attempt** shows a valid environment. Only two variables were given; the other four
were filled in by their defaults, and the full configuration was printed on a single line.
Writing the configuration to the log at startup removes any later need to ask what settings a
process is running with.

In the same output, the line `settings mutation attempt: TypeError` confirms that the attempt
to write to the frozen object was not silently swallowed. Because the module's code runs in
strict mode, the assignment throws an error; outside strict mode, the same assignment would
have been silently ignored.

#### Precedence Order and the Environment File

An environment variable is not the only source. The **layered configuration** arrangement set
up in the Configuration Management lesson of the Node.js Runtime course holds here too: a value
can be defined in more than one place, and which one wins has to be decided in advance. A
common order, left to right in increasing priority, is: the schema's default, an environment
file, the actual environment variable, a command-line option.

This reader uses only two layers — default and environment variable — but the order is open to
extension: because the `readConfiguration` function takes the environment as a parameter, the
caller can merge the layers and hand it a single object. The function deliberately does not
read `process.env` on its own; that is what lets different environments be tried in tests
without polluting the real one.

The environment file itself is a convenience, and it has a limit: it makes local development
comfortable, but in production it hides where values are coming from. That is also why the file
does not go into version control, and it marks the boundary of the next lesson.

#### Summary

- A value that changes from environment to environment is configuration; a rule that does not
  change belongs to the code, and secrets belong to a separate class.
- The schema defines every variable's type, range, options, and default in one place; the
  reader does not stop at the first error, it collects every problem and reports them all at
  once.
- A key that carries the prefix but has no match in the schema is treated as an error; in the
  measurement, `LIBRARY_LOAN_DAY` was a typo caught by this rule.
- Validation happens at startup: on an invalid environment the server is never built, and the
  process exits with the `sysexits.h` header's `EX_CONFIG` value, `78`.
- The configuration that is read is frozen; an attempt to mutate it in code running in strict
  mode produces a `TypeError`. Settings left out are filled in by their defaults and written to
  the log at startup.

#### Next Step

One variable was deliberately missing from this lesson's schema: the store's password. The
port and the password are read from the same place, the environment; even so, one can be
written to the log and the other cannot. The next lesson draws that line: what is the
difference between configuration and a secret, which rules keep a secret value from reaching
the log, an error response, and the client, and how is redaction actually verified to be in
place?

### Separating Secrets

Source: https://academia.sh/en/courses/server-fundamentals/separating-secrets

The boundary between configuration and a secret is drawn by exit paths; two-layer redaction based on the key name and on the value itself is applied, internal detail is kept out of the client while staying in the log, and a leak audit catches an unredacted field through its exit code.

One variable was deliberately missing from the previous lesson's schema: the store's password.
The port and the password are read from the same place, the environment, and carried in the
same object; even so, one can be written to the log at startup and the other cannot. This
lesson draws that line and shows where in the code it is enforced.

The subject is not hiding a secret value — the value already sits in process memory, and the
application has to use it. The subject is keeping that value from reaching the process's
**exit paths**: log lines, error responses, bodies returned to the client, and headers.

#### Where the Boundary Sits

The test that separates configuration from a secret is not what the value is, it is **what
happens once it is exposed.** A port number showing up in the log is not a problem; a password
showing up means anyone who can read the log can reach the store.

This test produces three practical rules:

- **A secret is not written into code.** A value that lands in a file under version control has
  entered every copy of that repository and its entire history. It cannot be scrubbed from
  history once it needs to change.
- **A secret is not written into the log.** Logs get collected, moved around, and are usually
  visible to a wider audience than the application itself.
- **A secret does not reach the client.** Error responses, settings views, and diagnostic
  output are the places this rule breaks most often.

Rules are not enough as intent; they have to be wired into code. The two modules below do that.

#### Two-Layer Redaction

**Redaction** replaces a value with a fixed mark before it is written to output. There are two
ways to apply it, and each is incomplete on its own.

The first looks at the **key name**: if the field name contains a word like password, key, or
token, redact its value. This approach is declarative and readable, but it misses a secret
landing in an unexpected field — inside the text of an error message, for instance.

The second looks at the **value itself**: if any of the secret values registered at startup
turns up, whatever field it is in, replace it. This approach closes off unexpected paths, but
it only recognizes values that were registered at startup.

The two are applied together.

```js
// src/setup/secrets.mjs — registers secret values; redacts them on log and response paths
export const MASK = "***";

const SECRET_VALUES = new Set();
const SECRET_KEY = /password|key|token|secret|authorization|cookie/i;

// Registering every secret once at startup is what value-based redaction relies on.
export const registerSecret = (value) => {
  if (typeof value === "string" && value.length >= 6) SECRET_VALUES.add(value);
};

export const redact = (value, key = "") => {
  if (typeof value === "string") {
    if (SECRET_KEY.test(key)) return MASK;                       // 1. by key name
    let result = value;
    for (const secret of SECRET_VALUES) result = result.split(secret).join(MASK);
    return result;                                                // 2. by the value itself
  }
  if (Array.isArray(value)) return value.map((e) => redact(e));
  if (value instanceof Error) {
    return { name: value.name, message: redact(value.message), code: value.code ?? null };
  }
  if (value && typeof value === "object") {
    return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redact(v, k)]));
  }
  return value;
};
```

Error objects have to be handled separately, because `JSON.stringify` turns an `Error` instance
into an empty object; the message and stack trace disappear. The conversion here keeps the
message but sends it through redaction first.

The configuration reader registers secret fields at the same time it reads them. This single
line is what value-based redaction depends on.

```js
// src/setup/settings.mjs — reads from the environment; flags secret fields and registers them with secrets
import { registerSecret } from "./secrets.mjs";

const SCHEMA = {
  PORT: { default: "8436" },
  STORE_ADDRESS: { default: "store://library@10.0.0.7:5432/loans" },
  STORE_PASSWORD: { required: true, secret: true },
  SIGNING_KEY: { required: true, secret: true },
};

export const readSettings = (env) => {
  const settings = {}, missing = [];
  for (const [name, rule] of Object.entries(SCHEMA)) {
    const value = env["LIBRARY_" + name] ?? rule.default;
    if (value === undefined) { missing.push("LIBRARY_" + name); continue; }
    if (rule.secret) registerSecret(value);
    settings[name] = value;
  }
  if (missing.length > 0) {
    console.error("configuration invalid: " + missing.join(", "));
    process.exit(78);
  }
  return Object.freeze(settings);
};
```

The `secret` flag in the schema is the only field added to the previous lesson's schema. The
distinction is declared in the schema; its enforcement is gathered into a single place.

#### Closing the Exit Paths

The server's rule is this: **every object heading to the log or the response passes through
redaction.** This is enforced by defining two functions in one spot; no direct write happens
anywhere else.

```js
// src/http/server.mjs — every exit path goes through redact: log, error response, and settings view
import { createServer } from "node:http";
import { readSettings } from "../setup/settings.mjs";
import { redact } from "../setup/secrets.mjs";

const settings = readSettings(process.env);
let counter = 0;

const log = (record) => console.log(JSON.stringify(redact(record)));

const connect = () => {                       // store connection attempt: error message carries the address
  const error = new Error(
    `could not connect to the store: ${settings.STORE_ADDRESS} (password: ${settings.STORE_PASSWORD})`);
  error.code = "store_unreachable";
  throw error;
};

createServer((req, res) => {
  res.sendDate = false;
  const id = `r-${++counter}`;
  const path = new URL(req.url, "http://local").pathname;
  const write = (status, body) => {
    const text = JSON.stringify(redact(body));
    res.writeHead(status, { "content-type": "application/json; charset=utf-8",
      "content-length": Buffer.byteLength(text), "x-request-id": id });
    res.end(text);
  };

  try {
    if (path === "/health") return write(200, { status: "up" });
    if (path === "/settings") return write(200, { settings });          // secret fields are redacted
    if (path === "/loans") { connect(); return; }
    write(404, { error: "path_not_found", requestId: id });
  } catch (error) {
    log({ level: "error", requestId: id, path, error });   // internal detail stays only in the log
    write(500, { error: "internal_error", requestId: id }); // client gets only the code
  }
}).listen(Number(settings.PORT), "127.0.0.1",
  () => log({ level: "info", event: "started", settings }));
```

The `connect` function stands in for a real failure: error messages produced by store-connecting
layers often carry the connection address and sometimes the credential too. If this message
were written to the log without passing through redaction, the secret value would stay there.

The same `catch` block sets up a second boundary as well: internal detail goes to the log, the
client gets only an error code and a request id. Having the id on both sides means a member
asking for support can hand over a number that leads straight back to the log line, without the
detail itself ever reaching the response.

#### The Leak Audit

Whether the rules above actually hold is verified by running, not by reading. The program below
opens a server with known secret values, sends requests to its endpoints, collects the response
bodies, the response headers, and everything the process writes to its log, and then searches
those texts for the secret values.

```js
// audit/leak.mjs — opens the given server with known secret values; tests whether those values
// land in the response body, response headers, or the log. Exits with 1 if it finds any.
// Usage: node audit/leak.mjs <script> <port>
import { spawn } from "node:child_process";
import { setTimeout as wait } from "node:timers/promises";

const SECRETS = {
  LIBRARY_STORE_PASSWORD: "shelf-password-9f3a2b4c",
  LIBRARY_SIGNING_KEY: "signing-key-7c1d8e5a",
};
const ENDPOINTS = ["/health", "/settings", "/loans"];

const [script, port] = process.argv.slice(2);

const child = spawn("node", [script], {
  env: { ...process.env, ...SECRETS, LIBRARY_PORT: port },
});
let log = "";
child.stdout.on("data", (p) => (log += p));
child.stderr.on("data", (p) => (log += p));
await wait(800);

const findings = [];
console.log(`### ${script} — responses returned to the client`);
for (const path of ENDPOINTS) {
  const res = await fetch(`http://127.0.0.1:${port}${path}`);
  const body = await res.text();
  const headers = [...res.headers].map(([k, v]) => `${k}: ${v}`).join("\n");
  console.log(`  ${res.status} ${path}  ${body}`);
  for (const [name, value] of Object.entries(SECRETS)) {
    if (body.includes(value)) findings.push(`${path} response body <- ${name}`);
    if (headers.includes(value)) findings.push(`${path} response headers <- ${name}`);
  }
}

await wait(200);
child.kill();
await wait(300);
for (const [name, value] of Object.entries(SECRETS)) {
  if (log.includes(value)) findings.push(`log <- ${name}`);
}

console.log("--- log ---");
console.log(log.trimEnd().split("\n").map((s) => "  " + s).join("\n"));
console.log("--- audit ---");
for (const finding of findings) console.log(`  LEAK  ${finding}`);
console.log(findings.length === 0
  ? "  no secret value found on any exit path"
  : `  ${findings.length} leaks found`);
process.exit(findings.length === 0 ? 0 : 1);
```

An audit that finds no leak can stay silent for two different reasons: there genuinely is no
leak, or the audit itself is broken. Telling the two apart requires also running the audit
against an example deliberately left flawed. The file below sits in the `audit/` directory for
that purpose — it is outside the source tree and is never called from any of the application's
paths.

```js
// audit/flawed-example.mjs — AUDIT EXAMPLE, not production code.
// Mimics a version with no redaction applied; exists to prove the leak audit
// actually catches it, and sits outside the source tree.
import { createServer } from "node:http";
import { readSettings } from "../src/setup/settings.mjs";

const settings = readSettings(process.env);

createServer((req, res) => {
  res.sendDate = false;
  const path = new URL(req.url, "http://local").pathname;
  const body = path === "/settings" ? { settings } : { status: "up" };
  const text = JSON.stringify(body);
  res.writeHead(200, { "content-type": "application/json; charset=utf-8",
    "content-length": Buffer.byteLength(text) });
  res.end(text);
}).listen(Number(settings.PORT), "127.0.0.1",
  () => console.log(JSON.stringify({ event: "started", settings })));
```

```bash
#!/usr/bin/env bash
# Runs the leak audit first against the real server, then against the deliberately flawed example.
node audit/leak.mjs src/http/server.mjs 8436; echo "exit code=$?"
echo
node audit/leak.mjs audit/flawed-example.mjs 8437; echo "exit code=$?"
```

```
### src/http/server.mjs — responses returned to the client
  200 /health  {"status":"up"}
  200 /settings  {"settings":{"PORT":"8436","STORE_ADDRESS":"store://library@10.0.0.7:5432/loans","STORE_PASSWORD":"***","SIGNING_KEY":"***"}}
  500 /loans  {"error":"internal_error","requestId":"r-3"}
--- log ---
  {"level":"info","event":"started","settings":{"PORT":"8436","STORE_ADDRESS":"store://library@10.0.0.7:5432/loans","STORE_PASSWORD":"***","SIGNING_KEY":"***"}}
  {"level":"error","requestId":"r-3","path":"/loans","error":{"name":"Error","message":"could not connect to the store: store://library@10.0.0.7:5432/loans (password: ***)","code":"store_unreachable"}}
--- audit ---
  no secret value found on any exit path
exit code=0

### audit/flawed-example.mjs — responses returned to the client
  200 /health  {"status":"up"}
  200 /settings  {"settings":{"PORT":"8437","STORE_ADDRESS":"store://library@10.0.0.7:5432/loans","STORE_PASSWORD":"shelf-password-9f3a2b4c","SIGNING_KEY":"signing-key-7c1d8e5a"}}
  200 /loans  {"status":"up"}
--- log ---
  {"event":"started","settings":{"PORT":"8437","STORE_ADDRESS":"store://library@10.0.0.7:5432/loans","STORE_PASSWORD":"shelf-password-9f3a2b4c","SIGNING_KEY":"signing-key-7c1d8e5a"}}
--- audit ---
  LEAK  /settings response body <- LIBRARY_STORE_PASSWORD
  LEAK  /settings response body <- LIBRARY_SIGNING_KEY
  LEAK  log <- LIBRARY_STORE_PASSWORD
  LEAK  log <- LIBRARY_SIGNING_KEY
  4 leaks found
exit code=1
```

The most instructive line in the output is the error record in the log. The message reading
`password: ***`, even though no field there is named "password," has been redacted. The secret
value sits embedded in the middle of an error message; the layer that catches it is the second
one, the one working off values registered at startup. Redaction that only looked at key names
would have written this line through unchanged.

The `/settings` response shows the second boundary: `STORE_ADDRESS` in the same object is
visible, while the two secret fields become `***`. Having a settings view at all is not a
problem; having which fields show up defined in the schema is what makes it safe.

The `/loans` response gives the third. The client got `{"error":"internal_error","requestId":"r-3"}`;
the store's address, why the connection failed, and the error's internal code are absent from
the response. The log line for that same request carries all of it, and the two records line up
by `r-3`.

Running against the flawed example proves the audit does not produce an empty pass by default:
four separate exit points were reported by name, and the exit code was `1`. Wired to a build
step, this code stops a field whose redaction was forgotten from reaching release.

#### The Cost of Change

Redaction alone is not enough, because the only remedy for an exposed secret is to
**rotate** it. The application has to make that cheap: if a value is read only from the
environment, rotating it is nothing more than updating the environment and restarting the
processes. If the same value is baked into the code or copied to more than one place, rotation
turns into a search.

The measurable form of this is that the number of places in the application where a secret is
read has to be countable. In the scaffold above, that number is one — the `readSettings`
function. Every piece of code that uses a value gets it from the settings object; none of them
looks at `process.env` on its own.

#### Summary

- The test separating configuration from a secret is the consequence of exposure; a secret is
  not written into code, not written into the log, and does not reach the client.
- Redaction is two-layered: the layer keyed on field name is declarative but incomplete, the
  layer keyed on values registered at startup closes off unexpected fields; in the measurement,
  the second layer caught a password embedded in an error message.
- Error objects are handled separately, because serializing them directly loses the message;
  the conversion here keeps the message and sends it through redaction.
- Internal detail stays in the log, the client gets only an error code and a request id; because
  the same id appears in both, they can be matched up afterward.
- The leak audit opens the server with known secret values and scans the responses and the log;
  it produced `0` against the real server and four findings and `1` against the deliberately
  flawed example.

#### Next Step

This lesson's log lines were in JSON, and each one carried a request id, but that id was only
written by hand in two places. Once a request passes through five chain links and three
modules, how does every line end up carrying the same id? What is the level threshold for, and
what single query gathers every line belonging to one request afterward? The next lesson sets up
structured logging: it carries the request context through the chain and gathers every record
of a single request with one command.

### Logging Setup

Source: https://academia.sh/en/courses/server-fundamentals/logging-setup

Log lines are produced as JSON objects; the request context is carried through asynchronous local storage, deep layers record the request id without taking it as a parameter, the level threshold determines the line count, and every line belonging to a single request is gathered with a query.

This lesson's log lines were in JSON, and each one carried a request id, but that id was only
written by hand in two places. In a real application, the same request passes through the
chain's links, the handler, the domain layer, and the data layer; every layer produces its own
record. Does every function have to take the id as a parameter for it to appear on every line?

This lesson sets up three things: the shape of the log line, carrying the request context across
layers, and querying the lines afterward. The measurement will show that the records of three
concurrent requests do not get mixed up.

#### Why the Line Is an Object

A free-text log line is read by a person, not by a machine. Extracting the ISBN from the line
"Loan request rejected, ISBN 978-0262033848" requires writing a pattern, and the pattern breaks
every time the message changes.

A **structured log record** solves this problem with named fields. Each line is an independent
JSON object; there is no separator between lines, and the file is processed line by line. The
fields a record has to carry fall into four groups:

- **When**: a timestamp in ISO 8601 format. Fixed length, sortable, and carries a time zone.
- **How important**: the level. Determines whether the record is written, based on the threshold.
- **What happened**: the event name. Not a free sentence but a fixed identifier — because it
  will be queried.
- **In what context**: the request id and fields specific to the event.

#### Carrying the Context

Passing the request id to every function as a parameter works, but it comes at a high cost: the
signatures of functions that have nothing to do with logging change, and the chain breaks the
moment a function in the middle of it forgets to pass the parameter along.

The runtime provides **asynchronous local storage** for exactly this. A value is bound to a
specific call tree; every read made from within that tree — no matter how many `await` steps
later — sees the same value. Because concurrent requests have separate trees, the values never
mix.

```js
// src/setup/log.mjs — produces JSON log lines; reads the request context from async local storage
import { AsyncLocalStorage } from "node:async_hooks";

const LEVELS = { error: 0, warning: 1, info: 2, verbose: 3 };

export const requestStore = new AsyncLocalStorage();

export const createLog = (thresholdName) => {
  const threshold = LEVELS[thresholdName] ?? LEVELS.info;
  const write = (level, event, fields = {}) => {
    if (LEVELS[level] > threshold) return;                // level threshold: below it, nothing is written
    const context = requestStore.getStore() ?? {};         // request id never passed by hand
    console.log(JSON.stringify({
      at: new Date().toISOString(), level, event, ...context, ...fields,
    }));
  };
  return Object.fromEntries(
    Object.keys(LEVELS).map((l) => [l, (event, fields) => write(l, event, fields)]));
};
```

Levels are defined by their numeric counterparts; the comparison runs on these numbers. When the
threshold is `info`, `verbose` records are never written at all — they are not written to a file
and filtered out afterward, they are not even produced. This distinction is about cost: no
serialization happens for a record below the threshold.

The server runs every request in its own context. The `checkShelf` function in the data layer
does not take the request id, does not know it, and does not pass it along; even so, the lines
it produces carry the id.

```js
// src/http/server.mjs — each request runs in its own context; deep layers do not carry the id
import { createServer } from "node:http";
import { setTimeout as wait } from "node:timers/promises";
import { createLog, requestStore } from "../setup/log.mjs";

const log = createLog(process.env.LIBRARY_LOG_LEVEL ?? "info");
const ON_SHELF = new Set(["978-0262033848"]);
let counter = 0;

// Deep function in the data layer: does NOT take the request id as a parameter.
const checkShelf = async (isbn) => {
  log.verbose("shelf_query", { isbn });
  await wait(150);                                   // delay standing in for a store access
  const found = ON_SHELF.has(isbn);
  log.info("shelf_result", { isbn, found });
  return found;
};

createServer((req, res) => {
  res.sendDate = false;
  const context = { requestId: `r-${++counter}` };
  requestStore.run(context, async () => {               // context spans this request's whole chain
    const start = process.hrtime.bigint();
    const url = new URL(req.url, "http://local");
    log.info("request_received", { method: req.method, path: url.pathname });

    let status = 404, body = { error: "path_not_found" };
    if (url.pathname === "/loans") {
      const found = await checkShelf(url.searchParams.get("isbn") ?? "");
      status = found ? 201 : 409;
      body = found ? { loan: "granted" } : { error: "shelf_empty" };
      if (!found) log.warning("loan_rejected", { reason: "shelf_empty" });
    } else if (url.pathname === "/books") {
      status = 200;
      body = { bookCount: ON_SHELF.size };
    }

    const text = JSON.stringify({ ...body, requestId: context.requestId });
    res.writeHead(status, { "content-type": "application/json; charset=utf-8",
      "content-length": Buffer.byteLength(text), "x-request-id": context.requestId });
    res.end(text);

    const duration = Number(process.hrtime.bigint() - start) / 1e6;
    log.info("request_finished", { status, durationMs: Number(duration.toFixed(1)) });
  });
}).listen(8438, "127.0.0.1", () => log.info("server_started", { port: 8438 }));
```

The request id is also present in the response: both in the `x-request-id` header and in the
body. The number a member gives when asking for support leads straight to the log.

#### Measurement

The measurement runs the server with two different thresholds. In each round, three requests are
sent close to concurrently: a loan for a book that is on the shelf, a quick catalog request, and
a loan for a book that is not on the shelf. The filtering is done with `jq`, a filter that
operates on a stream of JSON.

```bash
#!/usr/bin/env bash
# Opens the server with two level thresholds; collects the log lines of three near-simultaneous requests and queries them.
run() {  # $1 = log level, $2 = output file
  LIBRARY_LOG_LEVEL="$1" node src/http/server.mjs > "$2" 2>&1 & s=$!
  sleep 0.7
  curl -sS -o /dev/null 'http://127.0.0.1:8438/loans?isbn=978-0262033848' & p1=$!
  sleep 0.05
  curl -sS -o /dev/null 'http://127.0.0.1:8438/books' & p2=$!
  sleep 0.05
  curl -sS -o /dev/null 'http://127.0.0.1:8438/loans?isbn=978-0000000000' & p3=$!
  wait "$p1" "$p2" "$p3"
  sleep 0.2
  kill "$s"; wait "$s" 2>/dev/null
}

run info log-info.jsonl
run verbose log-verbose.jsonl

echo "--- log (threshold=info), in time order ---"
cat log-info.jsonl

echo "--- every line for a single request: r-1 ---"
jq -c 'select(.requestId == "r-1")' log-info.jsonl

echo "--- line count by level ---"
for file in log-info.jsonl log-verbose.jsonl; do
  printf '%-24s %s\n' "$file" \
    "$(jq -r .level "$file" | sort | uniq -c | tr '\n' ' ' | tr -s ' ')"
done

rm -f log-info.jsonl log-verbose.jsonl
```

```
--- log (threshold=info), in time order ---
{"at":"2026-08-18T04:20:15.331Z","level":"info","event":"server_started","port":8438}
{"at":"2026-08-18T04:20:16.028Z","level":"info","event":"request_received","requestId":"r-1","method":"GET","path":"/loans"}
{"at":"2026-08-18T04:20:16.083Z","level":"info","event":"request_received","requestId":"r-2","method":"GET","path":"/books"}
{"at":"2026-08-18T04:20:16.085Z","level":"info","event":"request_finished","requestId":"r-2","status":200,"durationMs":2}
{"at":"2026-08-18T04:20:16.144Z","level":"info","event":"request_received","requestId":"r-3","method":"GET","path":"/loans"}
{"at":"2026-08-18T04:20:16.179Z","level":"info","event":"shelf_result","requestId":"r-1","isbn":"978-0262033848","found":true}
{"at":"2026-08-18T04:20:16.180Z","level":"info","event":"request_finished","requestId":"r-1","status":201,"durationMs":152.3}
{"at":"2026-08-18T04:20:16.295Z","level":"info","event":"shelf_result","requestId":"r-3","isbn":"978-0000000000","found":false}
{"at":"2026-08-18T04:20:16.295Z","level":"warning","event":"loan_rejected","requestId":"r-3","reason":"shelf_empty"}
{"at":"2026-08-18T04:20:16.296Z","level":"info","event":"request_finished","requestId":"r-3","status":409,"durationMs":151.8}
--- every line for a single request: r-1 ---
{"at":"2026-08-18T04:20:16.028Z","level":"info","event":"request_received","requestId":"r-1","method":"GET","path":"/loans"}
{"at":"2026-08-18T04:20:16.179Z","level":"info","event":"shelf_result","requestId":"r-1","isbn":"978-0262033848","found":true}
{"at":"2026-08-18T04:20:16.180Z","level":"info","event":"request_finished","requestId":"r-1","status":201,"durationMs":152.3}
--- line count by level ---
log-info.jsonl           9 info 1 warning
log-verbose.jsonl        9 info 2 verbose 1 warning
```

Timestamps and duration fields change on every run; the order of the lines relative to one
another can also shift depending on timing.

#### What the Measurement Shows

**The lines are interleaved.** Between the first request's `request_received` line and its
`shelf_result` line sit three lines belonging to the second and third requests. In a log read in
time order, a single request's flow is not visible; this is the unavoidable consequence of an
application that runs concurrently in a single process.

**The ids did not mix up.** The `checkShelf` function was called by two of the three requests,
each call waited a hundred and fifty milliseconds, and other requests' code ran during that
wait. Even so, one `shelf_result` line carries `r-1`, the other `r-3`. The function never took
that id from any parameter.

**The query reconstructs the flow.** When the `jq` filter selects the lines belonging to a
single id, the request's own story emerges: received, shelf checked, finished. This is
structured logging's real payoff — the log is written in time order and reordered on demand.

**The duration measurement is part of the record.** The `durationMs` field on the
`request_finished` line shows the second request finishing in about two milliseconds, while the
loan requests took just over a hundred and fifty milliseconds. Because the same field carries the
same name on every line, querying for slow requests is a single filter expression.

**The threshold determines the line count.** At the `info` threshold, the file holds ten lines;
at `verbose`, `shelf_query` records are added and the count rises to twelve. Because the
threshold comes from configuration, the level of detail can be changed without rebuilding the
release.

#### Where the Log Is Written

The server writes its log to standard output; it does not open a file, rotate one, or compress
one. This is not a gap, it is a deliberate boundary: the application's job is to **produce** the
record, not to store it.

The reasoning was measured in an earlier lesson. The application runs as multiple processes, and
processes are mortal; each process writing to its own file would scatter a single request's
trail across several files, and a dead process's file would end up orphaned. Once written to
standard output, the collecting work belongs to the layer that runs the process: the output of
every process merges into a single stream.

The name and the reasoning for this separation come back up in this section's last lesson, among
the portability principles.

#### Summary

- A structured log record writes each line as an independent JSON object; the timestamp is in
  ISO 8601 format, the event name is a fixed identifier, and the context is carried in named
  fields.
- The level threshold comparison is numeric, and a record below the threshold is never produced;
  in the measurement, ten lines were written at the `info` threshold and twelve at the `verbose`
  threshold.
- Asynchronous local storage binds the request context to the call tree; even though the
  function in the data layer does not take the request id as a parameter, the lines it produces
  carry the correct id.
- Concurrent requests' lines interleave in time order; a single request's flow is only
  reconstructed by filtering on its id.
- The log is written to standard output; the work of collecting and storing it is left to the
  layer that runs the process, because processes are numerous and mortal.

#### Next Step

This lesson's server had error handling squeezed into a single branch: a book not on the shelf
produced `409`. In a real application, errors come from two separate sources — the user's
mistaken request and the application's own defect — and the two cannot be handled the same way.
The next lesson sets up a shared error response: which error maps to which status code, which
fields the response body carries, and what test verifies that the body does not leak internal
detail?

### Error Handling Layer

Source: https://academia.sh/en/courses/server-fundamentals/error-handling-layer

Client error is separated from programmer error, and a single layer translates both into the same response shape; the text sent to the client comes from a fixed table, internal detail stays only in the log, and the response bodies are tested for leaks across seven scenarios.

This lesson's server had error handling squeezed into a single branch: a book not on the shelf
produced `409`. In a real application, errors come from two separate sources. A member sends a
request with a missing field; this is a condition the application anticipates, and its response
should be informative. The application crashes on a defect in its own code; this is a condition
it did not anticipate, and its response should give away nothing.

This lesson separates the two sources and passes both through a single layer. Every error ends
up translated into the same shape; the difference between them is how much of the body it
carries.

#### Two Error Classes

A **client error** is a condition the application expects: a missing field, a malformed body, a
resource that does not exist, a conflicting state. These are not defects; they are the business
rule itself. Their response falls in the `4xx` range and has to tell the client what to do.

A **programmer error** is the application's own defect: calling a function that does not exist,
reading an undefined value, an assumption that did not hold. Their response falls in the `5xx`
range and has nothing meaningful to say to the client — because there is nothing the client can
do about it.

This connects directly to the **operational error** and **programmer error** distinction set up
in the Node.js Runtime course; the difference here is that it gets translated into an HTTP
response.

The module below sets up the distinction with a class and a translation function.

```js
// src/setup/errors.mjs — separates client error from programmer error and translates it into a response
export class ClientError extends Error {
  constructor(code, status, details = null) {
    super(`client error: ${code}`);
    this.name = "ClientError";
    this.code = code;                     // fixed identifier; the client branches on this
    this.status = status;
    this.details = details;               // fields that are safe to show to the client
  }
}

// The single source of the text sent to the client. The error object's own message never enters the response.
const MESSAGES = {
  body_malformed: "The request body could not be parsed as JSON.",
  field_missing: "The request is missing required fields.",
  member_missing: "A valid member id is required.",
  book_not_found: "This ISBN is not in the catalog.",
  shelf_empty: "The shelf has no copy of this book left.",
  internal_error: "The request could not be processed.",
};

export const toResponse = (error, requestId) => {
  const known = error instanceof ClientError && error.code in MESSAGES;
  const code = known ? error.code : "internal_error";
  const body = { error: { code, message: MESSAGES[code] }, requestId };
  if (known && error.details) body.error.details = error.details;
  return { status: known ? error.status : 500, body };
};
```

Three decisions are hiding in these thirty lines.

**The text sent to the client does not come from the error.** The `toResponse` function never
reads `error.message`; it takes the message from a fixed table. Error messages are written for
the developer and can carry a file path, a query string, an internal variable name. A message
coming from the table makes that structurally impossible.

**Everything unknown is an internal error.** The condition passes not just `ClientError`
instances but ones whose code is found in the table. An error built with a code that was never
added to the table also becomes `internal_error`; the default direction is toward not leaking.

**Details are marked explicitly.** The `details` field carries only values the throwing code
deliberately placed there — the names of missing fields, the ISBN that was queried. This ties
the boundary between information that helps the client and internal detail to a single field
name.

#### Passing Through a Single Layer

Every handler in the server throws; none of them writes a response. The work of writing a
response sits in a single `catch` block. This is the application of the rule set up in an
earlier section: an error handler stands outside everything it protects.

```js
// src/http/server.mjs — a single error layer: every error is translated into the same shape
import { createServer } from "node:http";
import { ClientError, toResponse } from "../setup/errors.mjs";

const CATALOG = new Map([["978-0262033848", { onShelf: 0 }], ["978-0201835953", { onShelf: 3 }]]);
let counter = 0;

const parseBody = (text) => {
  try {
    return text ? JSON.parse(text) : {};
  } catch {
    throw new ClientError("body_malformed", 400);
  }
};

const lendBook = (input, member) => {
  if (!member) throw new ClientError("member_missing", 401);
  const missing = ["isbn"].filter((field) => !input[field]);
  if (missing.length > 0) throw new ClientError("field_missing", 400, { missingFields: missing });
  const book = CATALOG.get(input.isbn);
  if (!book) throw new ClientError("book_not_found", 404, { isbn: input.isbn });
  if (book.onShelf < 1) throw new ClientError("shelf_empty", 409, { isbn: input.isbn });
  book.onShelf -= 1;
  return { loan: { isbn: input.isbn, member }, remaining: book.onShelf };
};

createServer(async (req, res) => {
  res.sendDate = false;
  const id = `r-${++counter}`;
  const write = (status, body) => {
    const text = JSON.stringify(body);
    res.writeHead(status, { "content-type": "application/json; charset=utf-8",
      "content-length": Buffer.byteLength(text), "x-request-id": id });
    res.end(text);
  };

  try {
    const chunks = [];
    for await (const chunk of req) chunks.push(chunk);
    const path = new URL(req.url, "http://local").pathname;
    const member = (req.headers.authorization ?? "").replace(/^Member /, "") || null;

    if (path === "/loans") return write(201, lendBook(parseBody(Buffer.concat(chunks).toString()), member));
    if (path === "/report") return write(200, { total: CATALOG.getAll().length });   // programmer error
    throw new ClientError("book_not_found", 404);
  } catch (error) {
    const expected = error instanceof ClientError;
    console.log(JSON.stringify({                                   // internal detail stays only in the log
      level: expected ? "warning" : "error", requestId: id,
      type: expected ? "client_error" : "programmer_error",
      name: error.name, message: error.message,
      stack: expected ? null : ((error.stack ?? "").split("\n")[1] ?? "")
        .trim().replace(/\(file:.*\/(?=[^\/]+:\d+:\d+\))/, "("),
    }));
    const { status, body } = toResponse(error, id);
    write(status, body);
  }
}).listen(8439, "127.0.0.1", () => console.log(JSON.stringify({ event: "started", port: 8439 })));
```

The `CATALOG.getAll()` call on the `/report` path calls a method that does not exist and
produces a genuine programmer error; not a simulated one, but the natural consequence of a typo.

The log record puts the two error classes at separate levels. Client errors sit at the `warning`
level and carry no stack trace — because where the code is is already known. Programmer errors
sit at the `error` level and carry the first line of the stack trace; that is the only piece of
information needed to find the defect.

#### Testing Seven Scenarios

The correctness of the response shape is verified not by reading but by running every branch.
The test below tries seven scenarios in order, compares each response's status code against what
is expected, and searches the body for a forbidden pattern.

The second half of the test matters: clean responses could also be produced by an application
that silently swallows its errors. To rule that out, the test also verifies that the internal
detail is **actually found** in the log. A test that passes only means something once both
directions are checked together.

```js
// audit/error-response.mjs — tests the shape of every error response and that it carries no leak.
// Also verifies that the programmer error's internal detail is REALLY found in the log:
// both directions are needed so the test does not approve an application that silently
// swallows everything.
import { spawn } from "node:child_process";
import { setTimeout as wait } from "node:timers/promises";

const FORBIDDEN = [/at\s+\S+\s+\(/, /\.mjs/, /node:/, /TypeError/, /\/Users\//, /client error:/];

const SCENARIOS = [
  ["valid request", "/loans", "Member U-4711", '{"isbn":"978-0201835953"}', 201],
  ["no member id", "/loans", null, '{"isbn":"978-0201835953"}', 401],
  ["malformed body", "/loans", "Member U-4711", "{isbn", 400],
  ["missing field", "/loans", "Member U-4711", "{}", 400],
  ["not in catalog", "/loans", "Member U-4711", '{"isbn":"978-0000000000"}', 404],
  ["shelf empty", "/loans", "Member U-4711", '{"isbn":"978-0262033848"}', 409],
  ["programmer error", "/report", "Member U-4711", null, 500],
];

const child = spawn("node", ["src/http/server.mjs"]);
let log = "";
child.stdout.on("data", (p) => (log += p));
child.stderr.on("data", (p) => (log += p));
await wait(700);

const findings = [];
console.log(`${"status".padEnd(6)} | ${"scenario".padEnd(18)} | response body`);
for (const [name, path, id, body, expected] of SCENARIOS) {
  const res = await fetch(`http://127.0.0.1:8439${path}`, {
    method: body === null ? "GET" : "POST",
    headers: { ...(id ? { authorization: id } : {}),
      ...(body === null ? {} : { "content-type": "application/json" }) },
    body,
  });
  const text = await res.text();
  console.log(`${String(res.status).padEnd(6)} | ${name.padEnd(18)} | ${text}`);
  if (res.status !== expected) findings.push(`${name}: expected ${expected}, got ${res.status}`);
  for (const pattern of FORBIDDEN) {
    if (pattern.test(text)) findings.push(`${name}: response body has forbidden pattern ${pattern}`);
  }
}

await wait(200);
child.kill();
await wait(300);

console.log("--- log ---");
console.log(log.trimEnd().split("\n").map((s) => "  " + s).join("\n"));

if (!/programmer_error/.test(log)) findings.push("no programmer error record in the log");
if (!/TypeError/.test(log)) findings.push("no internal error type in the log");

console.log("--- audit ---");
for (const finding of findings) console.log("  FINDING  " + finding);
console.log(findings.length === 0
  ? "  all seven responses match the shape; internal detail stays only in the log"
  : `  ${findings.length} findings`);
process.exit(findings.length === 0 ? 0 : 1);
```

```
status | scenario           | response body
201    | valid request      | {"loan":{"isbn":"978-0201835953","member":"U-4711"},"remaining":2}
401    | no member id       | {"error":{"code":"member_missing","message":"A valid member id is required."},"requestId":"r-2"}
400    | malformed body     | {"error":{"code":"body_malformed","message":"The request body could not be parsed as JSON."},"requestId":"r-3"}
400    | missing field      | {"error":{"code":"field_missing","message":"The request is missing required fields.","details":{"missingFields":["isbn"]}},"requestId":"r-4"}
404    | not in catalog     | {"error":{"code":"book_not_found","message":"This ISBN is not in the catalog.","details":{"isbn":"978-0000000000"}},"requestId":"r-5"}
409    | shelf empty        | {"error":{"code":"shelf_empty","message":"The shelf has no copy of this book left.","details":{"isbn":"978-0262033848"}},"requestId":"r-6"}
500    | programmer error   | {"error":{"code":"internal_error","message":"The request could not be processed."},"requestId":"r-7"}
--- log ---
  {"event":"started","port":8439}
  {"level":"warning","requestId":"r-2","type":"client_error","name":"ClientError","message":"client error: member_missing","stack":null}
  {"level":"warning","requestId":"r-3","type":"client_error","name":"ClientError","message":"client error: body_malformed","stack":null}
  {"level":"warning","requestId":"r-4","type":"client_error","name":"ClientError","message":"client error: field_missing","stack":null}
  {"level":"warning","requestId":"r-5","type":"client_error","name":"ClientError","message":"client error: book_not_found","stack":null}
  {"level":"warning","requestId":"r-6","type":"client_error","name":"ClientError","message":"client error: shelf_empty","stack":null}
  {"level":"error","requestId":"r-7","type":"programmer_error","name":"TypeError","message":"CATALOG.getAll is not a function","stack":"at Server.<anonymous> (server.mjs:44:64)"}
--- audit ---
  all seven responses match the shape; internal detail stays only in the log
```

The test produces a zero exit code. The line and column number in the stack trace shift with
every change to the code.

#### What the Measurement Shows

**Six of the error responses share the same shape.** All of them have `error.code`,
`error.message`, and `requestId`; two also carry `details`. On the client side, a single handler
can process every error and base its branching on the `code` field. The message itself is for
display; the decision is made on `code`, because the message can change while the code does not.

**Details are present only when they help.** The `field_missing` response names the missing
fields, the `book_not_found` response returns the queried ISBN. These are values the client
already knows or has to fix. The member error's response carries no detail at all; saying which
id was invalid and why would mean giving feedback to a party that is guessing at ids.

**The fifth scenario makes a distinction visible.** An ISBN not in the catalog gets `404`, a
book with no copy left on the shelf gets `409`. Both say "I cannot give you this," but one means
the resource never existed, the other that it exists but is not available right now. The client
fixes its input in the first case and waits in the second.

**The seventh scenario is the whole point of the distinction.** The body sent to the client
carries only the `internal_error` code, the message from the fixed table, and the `r-7` id. The
log, on the other hand, holds the fact that the same request was a `TypeError`, that its message
was `CATALOG.getAll is not a function`, and the first line of the stack trace. The same event is
recorded in two places at two different levels of detail, and the two are matched by the `r-7`
id.

**The test runs in both directions.** The absence of a forbidden pattern in the responses is not
enough by itself; the test also checks that `programmer_error` and `TypeError` records exist in
the log. An application that silently swallows its errors would pass the first condition and
fail the second.

#### Summary

- Errors split into two classes: a client error is a condition the application anticipates and
  produces `4xx`, a programmer error is the application's own defect and produces `5xx`.
- The message sent to the client comes from a fixed table, not from the error object; any error
  with no match in the table counts as `internal_error`, meaning the default direction is toward
  not leaking.
- Fields that are safe to show the client are marked explicitly under the `details` key; the
  member error gives no detail at all.
- Every handler throws, and a single layer writes the response; the log record keeps client
  errors at the `warning` level and programmer errors at the `error` level, adding a stack trace
  only to the latter.
- The test does not stop at searching the seven scenarios' response bodies for a forbidden
  pattern; it also verifies that the internal detail is found in the log. With both directions
  checked together, an application that swallows its errors cannot pass the test.

#### Next Step

The pieces set up in this section — a layered directory layout, configuration read from the
environment and validated at startup, secrets kept separate, a structured log written to
standard output, and a shared error response — look like independent decisions. There is a
connection between them, and it converges on a single goal: making the application independent
of the machine it runs on. The next lesson gathers these decisions into a named set of
principles, shows which measurement in this section satisfies each one, and points out the ones
that are not satisfied.

### Twelve-Factor App Principles

Source: https://academia.sh/en/courses/server-fundamentals/twelve-factor-app-principles

The decisions made in this section are mapped to twelve principles named as a portability contract; a single build running unchanged across two environments is measured with a file hash, and fast startup and graceful shutdown are measured through the completion of an in-flight request.

The decisions made in this section look independent of one another: splitting directories into
layers, reading configuration from the environment, masking secrets, writing the log to standard
output, passing errors through a single layer. There is a connection between them, and it
converges on a single goal: making the application **independent of the machine it runs on**.

There is a method that turns this goal into a named set of rules: the **twelve-factor app**
principles. The principles are not a framework or a tool; they are a contract that defines the
interface between the party that writes the application and the party that runs it. This lesson
counts the principles, matches which ones this section satisfies against its measurements, and
points out the ones that are not satisfied.

#### The Subject of the Contract

The principles share a common assumption: the party running the application has to be able to
install it, configure it, replicate it, monitor it, and stop it without knowing its internals.
Once that holds, the application is bound not to a machine but to a contract.

For the library loan service, this has a concrete counterpart. The developer who runs the service
on a laptop and the operator who runs it on a server use the same file; the only difference
between them is which environment variables they supply. The code does not know which machine it
is on.

The twelve principles and their counterparts in this section are as follows.

| Principle | Summary | This section's counterpart |
|---|---|---|
| Codebase | One codebase, many deploys | Introduction to Version Control course |
| Dependencies | Explicitly declared and isolated | Modules, Tooling and the Ecosystem course |
| Config | Stored in the environment | Configuration Management lesson |
| Backing services | Treated as an attached resource | The Local Development Environment lesson |
| Build, release, run | Stages strictly separated | Rendering Strategies and Infrastructure course |
| Processes | Stateless and share-nothing | The Application Runtime lesson |
| Port binding | Service binds its own port | The Web Server Concept lesson |
| Concurrency | Scales via the process model | The Application Runtime lesson |
| Disposability | Fast startup, graceful shutdown | This lesson |
| Dev/prod parity | Environments kept close to each other | This lesson |
| Logs | Treated as an event stream | Logging Setup lesson |
| Admin processes | Run as one-off tasks | Backend Development curriculum's advanced courses |

Four of the principles fall outside this course; two are this lesson's measurements.

#### Measurement: One Build, Two Environments

The **config** principle has a testable form: the file's hash stays unchanged while its
behavior changes through environment variables. The same measurement also shows part of
**dev/prod parity** — what runs in both environments is one build — but the parity
principle is broader: it asks that the time, personnel, and tool gaps between environments
stay small, and above all that both use the same backing services; this lesson does not
measure that side.

```js
// src/http/server.mjs — the same file in every environment; binds its own port,
// writes the log to standard output, finishes the in-flight request and exits on SIGTERM.
import { createServer } from "node:http";
import { setTimeout as wait } from "node:timers/promises";

const PORT = Number(process.env.LIBRARY_PORT ?? 8440);
const ENV = process.env.LIBRARY_ENV ?? "local";
const LOAN_DAYS = Number(process.env.LIBRARY_LOAN_DAYS ?? 14);

const log = (record) => console.log(JSON.stringify({ at: new Date().toISOString(), ...record }));

let openRequests = 0;

const server = createServer(async (req, res) => {
  res.sendDate = false;
  openRequests++;
  const path = new URL(req.url, "http://local").pathname;
  if (path === "/loans") await wait(1500);            // a loan operation that takes time
  const text = JSON.stringify({ env: ENV, loanDays: LOAN_DAYS, path });
  res.writeHead(200, { "content-type": "application/json; charset=utf-8",
    "content-length": Buffer.byteLength(text) });
  res.end(text);
  openRequests--;
});

server.listen(PORT, "127.0.0.1", () => log({
  event: "started", port: PORT, env: ENV, loanDays: LOAN_DAYS,
  startupMs: Number(performance.now().toFixed(1)),      // since process start
}));

process.on("SIGTERM", () => {
  log({ event: "sigterm_received", openRequests });
  server.close(() => {                                // no new connections, open ones finish
    log({ event: "closed", openRequests });
    process.exit(0);
  });
  server.closeIdleConnections();                      // idle connections close immediately
  setTimeout(() => { log({ event: "force_closed" }); process.exit(1); }, 5000).unref();
});
```

```bash
#!/usr/bin/env bash
# Runs the same file with two environments; shows behavior changing while the file's hash stays unchanged.
echo "file hash: $(shasum -a 256 src/http/server.mjs | cut -c1-16)…"

try() {  # $1 = env label, $2 = loan days, $3 = port
  LIBRARY_ENV="$1" LIBRARY_LOAN_DAYS="$2" LIBRARY_PORT="$3" \
    node src/http/server.mjs > "log-$1.jsonl" 2>&1 & s=$!
  sleep 0.7
  printf '%-11s -> ' "$1"
  curl -sS "http://127.0.0.1:$3/books"; echo
  kill -TERM "$s"; wait "$s" 2>/dev/null
  grep -o '"startupMs":[0-9.]*' "log-$1.jsonl"
  rm -f "log-$1.jsonl"
}

try local 14 8441
try production 21 8442
```

```
file hash: cc28586133fd7c6a…
local       -> {"env":"local","loanDays":14,"path":"/books"}
"startupMs":28.5
production  -> {"env":"production","loanDays":21,"path":"/books"}
"startupMs":28.2
```

The file hash and startup times change on every machine and after every edit; the hash is here to
show that the file did not change between the two runs.

The same file produced two different responses in two environments: the loan period is fourteen
days in one, twenty-one in the other. There is no branch in the code; no condition carries the
environment's name, no `if (ENV === "production")` line exists. The moment such a line is added,
the principle breaks, because the application now knows where it is running, and the path tested
locally diverges from the path that runs in production.

The same output confirms another principle too. The **port binding** principle asks the service to
open its own port without needing an external component. The `curl` call went straight to the
process; no component sat in between. The reverse proxy set up in an earlier section is an option,
not a requirement — and that is exactly why local development and production run the same code.

#### Measurement: Disposability

The **disposability** principle asks processes to start up fast and shut down cleanly. Both rest
on the same reasoning: processes get started and stopped often. Releases, scaling changes, machine
changes, and restarts after a crash all make this necessary.

The startup time is visible in the output above: under thirty milliseconds from process start to
listening. What actually needs testing is how the process comes down. The measurement below sends
`SIGTERM` to the process while a request is in flight.

```bash
#!/usr/bin/env bash
# Sends SIGTERM while a request is in flight; measures whether it finishes and whether a new connection is rejected.
node src/http/server.mjs > log.jsonl 2>&1 & pid=$!
sleep 0.7

curl -sS -o /dev/null -w 'in-flight request : %{http_code}  %{time_total} s\n' \
  http://127.0.0.1:8440/loans > inflight.txt & req_pid=$!
sleep 0.4
echo "SIGTERM sent"
kill -TERM "$pid"
sleep 0.4

printf 'new request during shutdown : '
if curl -s -o /dev/null --max-time 2 http://127.0.0.1:8440/books; then
  echo "response received"
else
  echo "connection could not be established (curl exit code=$?)"
fi

wait "$req_pid"; cat inflight.txt
wait "$pid"; echo "process exit code=$?"
echo "--- log ---"
cat log.jsonl
rm -f log.jsonl inflight.txt
```

```
SIGTERM sent
new request during shutdown : connection could not be established (curl exit code=7)
in-flight request : 200  1.505712 s
process exit code=0
--- log ---
{"at":"2026-07-28T20:20:44.905Z","event":"started","port":8440,"env":"local","loanDays":14,"startupMs":25.9}
{"at":"2026-07-28T20:20:45.986Z","event":"sigterm_received","openRequests":1}
{"at":"2026-07-28T20:20:47.101Z","event":"closed","openRequests":0}
```

Timestamps and durations change on every run.

Three observations hold steady. **The in-flight request finished**: the signal arrived in the
middle of the request, and even so the response came back as `200` and the one-and-a-half-second
job was not cut short. The log's `sigterm_received` line records that one request was open at that
moment, and the `closed` line records that zero requests were open once shutdown completed.

**The new connection was rejected.** The request made after the signal could not even establish a
connection; the `server.close` call closed the listening socket but did not cut off open
connections. This is the correct behavior for a process that is shutting down: take no new work,
finish the work you already took.

**The exit code is zero.** A supervisor seeing this code knows the process went down deliberately;
a nonzero code would report an unexpected termination and call for a different response.

The force-shutdown timer is the unavoidable complement to this arrangement. A request that never
finishes could keep the process waiting forever; once the five-second limit is up, the process
goes down with a nonzero code. The `unref` call keeps this timer from extending the process's life
once shutdown has already finished early.

#### What Is Not Satisfied

Reading the list of principles honestly means also counting the ones that are not satisfied.

The **backing services** principle has not been applied yet. This section's stores lived in
process memory; treating a database or an external service as a swappable attached resource
identified by nothing more than an address is set up in this course's The Local Development
Environment lesson.

The **admin processes** principle is also open. One-off tasks like data migration, bulk fixes, and
report generation have to run with the same code and the same environment as the application
itself; done with a separate script, two different realities are created.

The **build, release, run** separation was not measured in this section; keeping the build output
separate from environment variables and giving every release a revertible identity was covered in
the Rendering Strategies and Infrastructure course, and its server-side counterpart sits in more
advanced courses.

Not satisfying a principle is not a defect; not knowing that you have not satisfied it is. That is
what the list is for — it ties each decision, whether made or deferred, to a name.

#### Summary

- The twelve-factor principles are not a tool but a contract between the party that writes the
  application and the party that runs it; their shared goal is making the application independent
  of the machine it runs on.
- Config kept in the environment was measured by the file's hash staying unchanged while behavior
  changed through environment variables; the code has no branch that looks at the environment's
  name. The side of dev/prod parity this lesson does not measure is both environments using the
  same backing services.
- Under the port binding principle, the service opens its own socket; a reverse proxy is an option,
  not a precondition.
- In the disposability measurement, after `SIGTERM` the in-flight request finished with `200`, the
  new connection was rejected, and the process went down with a zero exit code; the force-shutdown
  timer guards the limit against a request that never finishes.
- Backing services, admin processes, and the build-release-run separation were not satisfied in
  this section; the list's job is to name the decisions that were deferred.

#### Next Step

The scaffold is now standing: configuration is read from the environment, secrets are kept
separate, the log is written to standard output, errors turn into a shared response shape, and the
process starts up and shuts down cleanly. Even so, the application still gives nothing real to the
outside world; the only thing it hands back is its own state. The next section fills that gap
starting from the simplest case: serving a file sitting on disk over the network. The next lesson
sets up under what condition that file is allowed to be read, how the recipient will interpret its
content, what happens when the same file is requested a second time, and whether that load stays
on the server.

## Static and Dynamic Serving

What the running application skeleton gives back to the outside; serving the file on disk with a validator and cache directive, rendering the view from a template on the server, accepting an incoming file within limits, and bringing dependent services up locally.

### Static File Serving

Source: https://academia.sh/en/courses/server-fundamentals/static-file-serving

How a file on disk turns into an HTTP response: the document root and path resolution, content type and length, the conditional request enabled by an entity tag, the two classes of cache directive, and the decision to hand the load off to a content delivery network.

The previous topic built the application's skeleton: configuration is read from the
environment, logs are written, errors turn into a common response shape. The skeleton
stands but gives nothing back yet. The library lending service's first thing to give back
is also its simplest: a file sitting on disk. The catalog page's document, its stylesheet,
a script.

This lesson establishes what it takes to serve that file over the network. Reading a file
and writing it to a response is the small part of the job; the real decisions are which
file is allowed to be read, how the recipient will interpret the content, what happens
when the same file is requested a second time, and whether this load should stay on the
server at all.

#### Document Root and Path Resolution

Servable files are collected under a single directory. This directory is called the
**document root**, and it is the only region of the file system the server is authorized
to reach. Joining the request path with the document root is called **path resolution**.

Path resolution alone is not enough: the request path can carry components that point
outside the document root. Whether the resulting absolute path still falls under the
document root must be checked **after** the join, because joining normalizes the path and
a check performed before it would be misleading.

```js
// server.mjs — file server for the library catalog's static assets
import { createServer } from "node:http";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { extname, join, sep } from "node:path";

const ROOT = join(process.cwd(), "public");

const TYPES = {
  ".html": "text/html; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
  ".svg": "image/svg+xml",
  ".json": "application/json; charset=utf-8",
};

// A name carrying a content hash (catalog.7f3a91c2.css) can be cached forever;
// the document must be revalidated on every request.
const HASHED = /\.[0-9a-f]{8}\.(css|js)$/;
const cacheControl = (path) =>
  HASHED.test(path) ? "public, max-age=31536000, immutable" : "no-cache";

// join() concatenates and normalizes the path; the request is rejected if the
// result no longer falls under the root.
const safePath = (requestPath) => {
  const full = join(ROOT, decodeURIComponent(requestPath.split("?")[0]));
  return full === ROOT || full.startsWith(ROOT + sep) ? full : null;
};

createServer(async (request, response) => {
  response.sendDate = false;
  const path = safePath(request.url === "/" ? "/index.html" : request.url);
  if (!path) return response.writeHead(403).end();

  let body;
  try {
    body = await readFile(path);
  } catch {
    return response.writeHead(404).end();
  }

  const etag = `"${createHash("sha256").update(body).digest("hex").slice(0, 16)}"`;
  response.setHeader("ETag", etag);
  response.setHeader("Cache-Control", cacheControl(path));
  response.setHeader("Content-Type", TYPES[extname(path)] ?? "application/octet-stream");

  if (request.headers["if-none-match"] === etag) return response.writeHead(304).end();

  response.setHeader("Content-Length", body.length);
  response.writeHead(200).end(body);
}).listen(8310, "127.0.0.1", () => console.log("listening: 127.0.0.1:8310"));
```

The files the server expects sit in the `public/` directory. The block below produces
that directory; it is run in the same place as the server file.

```bash
#!/usr/bin/env bash
# Creates the public/ directory and the four assets the server expects.
mkdir -p public
cat > public/index.html <<'END'
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Library Catalog</title>
<link rel="stylesheet" href="/catalog.7f3a91c2.css">
<script src="/catalog.4b8e0d15.js" defer></script></head>
<body><h1>Library Catalog</h1><div id="result"></div></body></html>
END
cat > public/catalog.7f3a91c2.css <<'END'
body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 46rem; }
h1 { font-size: 1.5rem; }
.book { border-bottom: 1px solid #ddd; padding: .5rem 0; }
.book .author { color: #555; }
END
cat > public/catalog.4b8e0d15.js <<'END'
const root = document.getElementById("result");
const books = await (await fetch("/books.json")).json();
root.innerHTML = books.map((b) =>
  `<div class="book">${b.title} <span class="author">${b.author}</span></div>`).join("");
END
cat > public/books.json <<'END'
[{"isbn":"9789750718533","title":"Kayip Zamanin Izinde","author":"Marcel Proust"},
 {"isbn":"9789944885300","title":"Tutunamayanlar","author":"Oguz Atay"},
 {"isbn":"9789750726439","title":"Kar","author":"Orhan Pamuk"}]
END
```

#### Content Type and Length

What travels over the network is a byte sequence; the bytes themselves do not say what
they are. The **content type** header determines how the recipient will process the
content. The mapping from file extension to type is limited to the set of types the
server knows, and an unrecognized extension gets the generic binary type. For text types
the character encoding is also declared in the header; when it is not, the recipient
falls back to a guess, and that guess can conflict with the file's own encoding.

The **content length** header gives the body's byte count. The recipient thereby knows
where the response ends without waiting for the connection to close, and can compute
download progress. This header can only be written once the whole body is ready in hand:
here that is possible because the file is read into memory; when the body is produced
piece by piece, chunked transfer takes its place.

Notice that the length header is not written on a 304 response. That response has no
body. HTTP does not forbid the header on a 304, but the only value it permits is not
the length of the body: it is the length the 200 response to the same request would
have carried; this server avoids that distinction by not writing the header at all.

#### Validator and Conditional Request

When the same file is requested a second time and the content has not changed, moving
the bytes again is wasted work. The way to detect this is a **validator**: a short value
the server derives from the content, one that changes when the content changes. In HTTP
this value is carried by the **entity tag** header.

The server above derives the tag from a digest of the body. Deriving it from the
modification time is also possible, but a timestamp has second-level resolution and
changes when a file is copied; a tag derived from the content carries neither problem.

The client sends the tag it holds back on the next request. The server compares the
tags: if they match, it returns a bodyless 304 response. This exchange is called a
**conditional request**, and it is the server-side counterpart of the mechanism
introduced in the How the Internet Works course.

#### The Two Classes of Cache Directive

A conditional request does not carry the body, but it still brings the request itself
all the way to the server. Not making the request at all is achieved with a **cache
directive**: the server states how long the response can be used without being asked
for again.

The decision is made by whether the file's name depends on its content. On the server,
the name `catalog.7f3a91c2.css` carries a **content hash**: the name changes when the
content changes, so the byte sequence under this name never changes. Such an asset can
be cached with a long lifetime and a declaration that it will not change. The document's
name, by contrast, is fixed and its content can change; it is granted caching permission,
but revalidation is required on every use.

This distinction is static serving's one real design decision. Giving a long lifetime to
a file with a fixed name serves stale content for days; giving a short lifetime to a file
with a hashed name produces revalidation requests that were never necessary.

#### Measurement

The script below brings the server up, tests three behaviors, and stops it. Port 8310
is arbitrary and must be free.

```bash
#!/usr/bin/env bash
# Starts server.mjs, measures caching and conditional-request behavior, then stops it.
node server.mjs > /dev/null &
server=$!
sleep 1
A=http://127.0.0.1:8310

echo "--- first request: hashed asset ---"
curl -sS -D - -o /dev/null "$A/catalog.7f3a91c2.css"

echo "--- first request: document ---"
curl -sS -D - -o /dev/null "$A/index.html"

TAG=$(curl -sS -D - -o /dev/null "$A/index.html" | awk '/^ETag:/ {print $2}' | tr -d '\r')
echo "--- conditional request (If-None-Match: $TAG) ---"
curl -sS -D - -o /dev/null -H "If-None-Match: $TAG" "$A/index.html"

echo "--- downloaded bytes: unconditional / conditional ---"
printf 'unconditional  %s B\n' "$(curl -sS -o /dev/null -w '%{size_download}' "$A/index.html")"
printf 'conditional    %s B\n' "$(curl -sS -o /dev/null -w '%{size_download}' -H "If-None-Match: $TAG" "$A/index.html")"

echo "--- attempt to escape the root ---"
curl -sS -o /dev/null -w 'GET /../server.mjs -> %{http_code}\n' --path-as-is "$A/../server.mjs"

kill "$server"
```

```
--- first request: hashed asset ---
HTTP/1.1 200 OK
ETag: "a725ac1e3dee13d5"
Cache-Control: public, max-age=31536000, immutable
Content-Type: text/css; charset=utf-8
Content-Length: 198
Connection: keep-alive
Keep-Alive: timeout=5

--- first request: document ---
HTTP/1.1 200 OK
ETag: "9493205a58f55fac"
Cache-Control: no-cache
Content-Type: text/html; charset=utf-8
Content-Length: 270
Connection: keep-alive
Keep-Alive: timeout=5

--- conditional request (If-None-Match: "9493205a58f55fac") ---
HTTP/1.1 304 Not Modified
ETag: "9493205a58f55fac"
Cache-Control: no-cache
Content-Type: text/html; charset=utf-8
Connection: keep-alive
Keep-Alive: timeout=5

--- downloaded bytes: unconditional / conditional ---
unconditional  270 B
conditional    0 B
--- attempt to escape the root ---
GET /../server.mjs -> 403
```

Tag values are derived from file content; they come out different in a directory where
the files were not produced exactly as above. Connection headers depend on the runtime's
default settings.

Three results are worth reading. The hashed asset and the document received **different
cache directives**. The conditional request transferred **0 bytes instead of 270**; the
gain stays proportionally the same as the file grows, because a 304 response's body is
empty at every size. The request pointing outside the document root was met with a
**403**: because the path check runs after the join, the path that escapes as a result of
normalization is caught.

#### The Decision to Hand Off to a Content Delivery Network

Past a certain point, serving static assets stops being the application server's job.
Sending the same bytes thousands of times requires no application logic; a layer sitting
closer to the user can do it instead. The **content delivery network** introduced in the
How the Internet Works course is exactly this layer, and in this arrangement the
application server takes on the name **origin server**.

The handoff decision is made with two ratios. The script below first learns each asset's
real size and cache directive from the origin server, then computes the same workload as
if a caching layer sat in front of it.

```js
// offload.mjs — measures the same workload against the origin server first,
// then computes the remaining load on the origin assuming a shared cache sits in front of it.
const A = "http://127.0.0.1:8310";
const PAGE = ["/index.html", "/catalog.7f3a91c2.css", "/catalog.4b8e0d15.js", "/books.json"];
const VISITS = 500; // number of visitors opening the same page

// 1) Learn the real size and cache directive of each asset from the origin.
const asset = new Map();
for (const path of PAGE) {
  const r = await fetch(A + path);
  const body = await r.arrayBuffer();
  asset.set(path, {
    bytes: body.byteLength,
    cacheable: (r.headers.get("cache-control") ?? "").includes("max-age"),
  });
}

// 2) Origin server alone: every visit pulls every asset from it.
const originRequests = VISITS * PAGE.length;
const originBytes = VISITS * PAGE.reduce((t, path) => t + asset.get(path).bytes, 0);

// 3) A cache layer in front: a cacheable asset is fetched once, the rest is served
//    from the edge. The non-cacheable document is revalidated on every visit; the
//    response is 304, so the request reaches the origin but the body is not transferred.
let edgeRequests = 0, edgeBytes = 0;
for (const path of PAGE) {
  const a = asset.get(path);
  if (a.cacheable) { edgeRequests += 1; edgeBytes += a.bytes; }
  else { edgeRequests += VISITS; edgeBytes += a.bytes; }
}

const ratio = (a, b) => ((1 - a / b) * 100).toFixed(1);
console.log(`workload: ${VISITS} visits x ${PAGE.length} assets`);
console.log(`origin server alone : ${originRequests} requests, ${originBytes} B`);
console.log(`cache layer in front : ${edgeRequests} requests, ${edgeBytes} B`);
console.log(`request hit ratio    : %${ratio(edgeRequests, originRequests)}`);
console.log(`byte hit ratio       : %${ratio(edgeBytes, originBytes)}`);
```

```
workload: 500 visits x 4 assets
origin server alone : 2000 requests, 458500 B
cache layer in front : 1002 requests, 917 B
request hit ratio    : %49.9
byte hit ratio       : %99.8
```

The two ratios are far apart from each other, and that gap is the decision itself. The
**request hit ratio** is the share of requests that never reach the origin; here it is
roughly half, because the two non-cacheable assets are revalidated on every visit. The
**byte hit ratio** is the share of bodies that never leave the origin; here it is nearly
complete, because revalidation responses carry no body.

The decision therefore looks at which resource is scarce on the server. If the
bottleneck is bandwidth, handing off drops the load to nearly zero. If the bottleneck is
the connection and processing cost paid per request, the gain is bounded by the share of
non-cacheable assets, and improvement comes from revisiting the cache directives.
Serving the catalog data with a short-lived cache directive as well pulls the second
ratio up directly.

#### Summary

- Static serving begins by joining the request path with the document root and checking,
  after the join, that the result still falls under the document root.
- Content type tells the recipient how to interpret the bytes; content length tells it
  where the body ends; this server does not write the length header on a bodyless 304.
- The entity tag is a validator derived from the content; when a conditional request
  matches, the response returns without a body, and the measurement transferred 0 bytes
  instead of 270.
- The cache directive splits into two classes: an asset whose name carries a content
  hash is cached with a long lifetime; a document with a fixed name is revalidated on
  every use.
- The decision to hand off to a content delivery network is made by looking at the
  request hit ratio and the byte hit ratio; in the measurement these two ratios came out
  49.9% and 99.8%, pointing to different conclusions depending on which resource is
  scarce.

#### Next Step

Everything served so far already sat ready on disk: the file was read, headers were
written, bytes were sent. The catalog page, though, has its script fill in its content —
the document arrives empty, and the book list is built in the browser. The server itself
could write that same list into the document. That requires a mechanism that merges data
with markup, and at the center of that mechanism sits a single question: what happens
when a piece of text coming from data is placed inside markup? The next lesson builds
this merge by writing its own template engine and measures escaping's effect on the
output.

### Server-Side Templating

Source: https://academia.sh/en/courses/server-fundamentals/server-side-templating

Building the view from data on the server: the template engine's parse and render steps, escaping as the default, raw output requested by an explicit marker, the difference the attribute context makes, and separating the layout template from the page template.

The previous lesson served files sitting on disk. The catalog page was one of those files,
but it did not carry its own content: the document went out as an empty container, and the
book list was built in the browser by a script. Since the server can already read the book
records, it can write that list into the document itself.

The mechanism that performs this merge is called a **template**: text with the places where
a value will go marked out. The program that merges a template with data is called a
**template engine**. This lesson centers on a single question: what happens when a piece of
text coming from data is placed inside markup?

#### The Template's Two Steps

The engine does its work in two steps. First the template text is **parsed**: plain-text
fragments are separated from placeholders, and block tags build a nested structure. Then
this structure is walked against the data and **rendered**.

The two steps being separate is a requirement, not a stylistic choice. Parsing looks at the
template and never at the data; rendering looks at the data and never reads the template
text again. This lets the same template be parsed once and rendered a hundred thousand
times, and more importantly, **the content of the data can never alter the template's
structure under any circumstance**. A piece of text coming from data can look exactly like
a block tag, but it is never interpreted as one during rendering, because parsing has
already finished.

The engine below recognizes four tags: `{{ name }}` writes the value with escaping,
`{{! name }}` writes it raw, `{{# name }}` through `{{/ name }}` iterates over a list, and
`{{^ name }}` through `{{/ name }}` runs when the value is empty.

```js
// template.mjs — a small template engine that makes escaping the default
const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
export const escape = (value) => String(value).replace(/[&<>"']/g, (c) => ESCAPES[c]);

// {{ name }} escapes, {{! name }} does not, {{# name }}..{{/ name }} iterates,
// {{^ name }}..{{/ name }} runs when the value is empty.
const TAG = /\{\{\s*([#^/!]?)\s*([\w.]+)\s*\}\}/g;

const resolve = (stack, name) => {
  if (name === ".") return stack[stack.length - 1];
  const parts = name.split(".");
  for (let i = stack.length - 1; i >= 0; i--) {
    let value = stack[i];
    if (value === null || typeof value !== "object") continue;
    if (!(parts[0] in value)) continue;
    for (const p of parts) value = value?.[p];
    return value;
  }
  return undefined;
};

// Converts the template to a tree in a single pass: string fragments and block nodes.
const parse = (text) => {
  const root = { children: [] };
  const stack = [root];
  let last = 0;
  for (const m of text.matchAll(TAG)) {
    const [whole, kind, name] = m;
    const parent = stack[stack.length - 1];
    if (m.index > last) parent.children.push(text.slice(last, m.index));
    last = m.index + whole.length;
    if (kind === "#" || kind === "^") {
      const node = { kind, name, children: [] };
      parent.children.push(node);
      stack.push(node);
    } else if (kind === "/") {
      if (stack.length === 1 || stack[stack.length - 1].name !== name)
        throw new Error(`unclosed block: ${name}`);
      stack.pop();
    } else {
      parent.children.push({ kind, name });
    }
  }
  if (stack.length !== 1) throw new Error(`unclosed block: ${stack[stack.length - 1].name}`);
  root.children.push(text.slice(last));
  return root;
};

const render = (node, stack) =>
  node.children.map((c) => {
    if (typeof c === "string") return c;
    const value = resolve(stack, c.name);
    if (c.kind === "#") {
      const list = Array.isArray(value) ? value : value ? [value] : [];
      return list.map((item) => render(c, [...stack, item])).join("");
    }
    if (c.kind === "^") {
      const empty = Array.isArray(value) ? value.length === 0 : !value;
      return empty ? render(c, stack) : "";
    }
    if (value === undefined || value === null) return "";
    return c.kind === "!" ? String(value) : escape(value);
  }).join("");

export const build = (text, data) => render(parse(text), [data]);
```

Name resolution runs over a scope stack: entering a block pushes that iteration's item
onto the stack, a name is looked up in the innermost scope first, and the search moves
outward when it is not found there. This is what lets code inside an iteration block still
reach fields from the outer scope.

#### Escaping Is the Default

**Escaping** converts characters that carry structural meaning in the markup language into
their **character entity** form. When this conversion happens, text coming from data stays
text in the output; when it does not, that text becomes part of the markup.

Escaping is the default in the engine, and raw output is obtained only by writing
`{{! name }}` — an explicit decision made by whoever writes the template. The order could
have been reversed; what matters is what happens when one of them is forgotten. If escaping
is the default, forgetting the raw marker costs a visible oddity. If raw output is the
default, forgetting to escape costs data-borne text bleeding into the document's structure.

The block below feeds the same data to two placeholders at once and shows the difference.
The marked-up text is harmless: a highlight element.

```js
// compare.mjs — compares escaped and unescaped rendering with the same data
import { build } from "./template.mjs";

const TEMPLATE = `<ul class="catalog">
{{# books }}  <li>{{ title }} — <span class="author">{{ author }}</span></li>
{{/ books }}{{^ books }}  <li class="empty">No results</li>
{{/ books }}</ul>
<p class="note">{{ note }}</p>
<p class="note-raw">{{! note }}</p>`;

const DATA = {
  books: [
    { title: "In Search of Lost Time", author: "Marcel Proust" },
    { title: "Snow & Ice <archive>", author: "Branch 3" },
  ],
  note: "<mark>reserved</mark>",
};

const output = build(TEMPLATE, DATA);
console.log(output);
console.log("---");
console.log("output bytes:", Buffer.byteLength(output));
console.log("mark element count:", (output.match(/<mark>/g) ?? []).length);
console.log("--- empty list ---");
console.log(build(TEMPLATE, { books: [], note: "none" }));
```

```
<ul class="catalog">
  <li>In Search of Lost Time — <span class="author">Marcel Proust</span></li>
  <li>Snow &amp; Ice &lt;archive&gt; — <span class="author">Branch 3</span></li>
</ul>
<p class="note">&lt;mark&gt;reserved&lt;/mark&gt;</p>
<p class="note-raw"><mark>reserved</mark></p>
---
output bytes: 289
mark element count: 1
--- empty list ---
<ul class="catalog">
  <li class="empty">No results</li>
</ul>
<p class="note">none</p>
<p class="note-raw">none</p>
```

The same data produced two different results. In the escaped placeholder, the marked-up
text is **text meant to be read on screen**; in the raw placeholder, it is **an element
inserted into the document**. The count confirms this: the output has exactly one
highlight element, and it came from the raw placeholder.

The `&` and the angle brackets in the book title were escaped as well. This is a
correctness matter before it is a security one: without escaping, part of the title would
not have appeared on screen at all, because the markup parser would have read it as the
start of a tag.

#### Text Context and Attribute Context

A single escaping function does not know where in the document a value lands. In a value
placed inside text, angle brackets matter; in a value placed inside an attribute, the
**quote character** matters. The function above converts both, so it produces a correct
result in either context, but why that is necessary still needs to be shown.

```js
// attribute.mjs — escaping's effect on an attribute value
import { build } from "./template.mjs";

const ESCAPED = `<a class="branch" title="{{ name }}" href="/branch/{{ code }}">Branch</a>`;
const RAW = `<a class="branch" title="{{! name }}" href="/branch/{{ code }}">Branch</a>`;
const DATA = { name: 'Central "Old Books" Hall', code: "s3" };

// The parser ends the attribute value at the first closing quote; the pattern below
// applies the same rule and shows where the value gets cut.
const titleValue = (text) => text.match(/title="([^"]*)"/)[1];

for (const [name, template] of [["escaped", ESCAPED], ["raw", RAW]]) {
  const output = build(template, DATA);
  console.log(name);
  console.log("  output:", output);
  console.log("  title :", JSON.stringify(titleValue(output)));
}
```

```
escaped
  output: <a class="branch" title="Central &quot;Old Books&quot; Hall" href="/branch/s3">Branch</a>
  title : "Central &quot;Old Books&quot; Hall"
raw
  output: <a class="branch" title="Central "Old Books" Hall" href="/branch/s3">Branch</a>
  title : "Central "
```

In the raw output, the attribute value ends at **"Central "**: the quote in the branch
name closed the attribute. The remaining words are no longer part of the value; they
become something else in the tag's body. In the escaped output, by contrast, the value
stays whole, and the parser reads the character entities back into the full branch name.

The rule that follows is this: attribute values are **always placed in quotes**, and a
value placed in a template is stripped of characters that could close that quote.
Unquoted attribute syntax is never used in this scheme, because it turns even characters
the escaping function does not convert — whitespace, for instance — into structural ones.

#### Layout and Page

Every page's document skeleton is the same: the same language declaration, the same head
element, the same body frame. Instead of copying this shared part into every template, a
**layout template** is written once, and the page's own output is placed inside it. That
placement uses the single raw placeholder inside the layout — because what goes there is
not data, it is output that has already been escaped once.

The data structure the template sees is called the **view model**. The view model is the
contract between the data source and the template: the template does not know where the
records came from, and the code that reads the records does not know the markup.

```js
// view.mjs — builds and serves the catalog page on the server (same directory as template.mjs)
import { createServer } from "node:http";
import { build } from "./template.mjs";

const BOOKS = [
  { title: "In Search of Lost Time", author: "Marcel Proust", branch: "Central" },
  { title: "The Disconnected", author: "Oğuz Atay", branch: "Branch 3" },
  { title: "Snow & Ice <archive>", author: "Compilation", branch: "Branch 3" },
];

const LAYOUT = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>{{ title }}</title></head>
<body><h1>{{ title }}</h1>{{! content }}</body></html>
`;

const CATALOG = `<p class="summary">Records: {{ count }}</p>
<ul class="catalog">
{{# books }}  <li>{{ title }} — <span class="author">{{ author }}</span> ({{ branch }})</li>
{{/ books }}{{^ books }}  <li class="empty">No results</li>
{{/ books }}</ul>`;

// View model: the single data structure the template sees. The query and the
// records are merged here; the template itself never knows the data source.
const viewModel = (query) => {
  const q = (query ?? "").toLocaleLowerCase("en-US");
  const books = q ? BOOKS.filter((b) => b.title.toLocaleLowerCase("en-US").includes(q)) : BOOKS;
  return { books, count: books.length };
};

createServer((request, response) => {
  response.sendDate = false;
  const address = new URL(request.url, "http://local");
  if (address.pathname !== "/catalog") return response.writeHead(404).end();

  const content = build(CATALOG, viewModel(address.searchParams.get("q")));
  const document = build(LAYOUT, { title: "Library Catalog", content });

  response.setHeader("Content-Type", "text/html; charset=utf-8");
  response.setHeader("Content-Length", Buffer.byteLength(document));
  response.writeHead(200).end(document);
}).listen(8311, "127.0.0.1", () => console.log("listening: 127.0.0.1:8311"));
```

```bash
#!/usr/bin/env bash
# Starts view.mjs, shows the generated document and the filtered result, then stops it.
node view.mjs > /dev/null &
server=$!
sleep 1
A=http://127.0.0.1:8311

echo "--- full catalog ---"
curl -sS "$A/catalog"
echo "--- filter: q=snow ---"
curl -sS "$A/catalog?q=snow"
echo "--- non-matching query ---"
curl -sS "$A/catalog?q=zzz" | grep -c 'class="empty"'

kill "$server"
```

```
--- full catalog ---
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Library Catalog</title></head>
<body><h1>Library Catalog</h1><p class="summary">Records: 3</p>
<ul class="catalog">
  <li>In Search of Lost Time — <span class="author">Marcel Proust</span> (Central)</li>
  <li>The Disconnected — <span class="author">Oğuz Atay</span> (Branch 3)</li>
  <li>Snow &amp; Ice &lt;archive&gt; — <span class="author">Compilation</span> (Branch 3)</li>
</ul></body></html>
--- filter: q=snow ---
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Library Catalog</title></head>
<body><h1>Library Catalog</h1><p class="summary">Records: 1</p>
<ul class="catalog">
  <li>Snow &amp; Ice &lt;archive&gt; — <span class="author">Compilation</span> (Branch 3)</li>
</ul></body></html>
--- non-matching query ---
1
```

The empty-result branch was tested as well: for the non-matching query, the number of
lines containing `class="empty"` is one. This confirms that the empty list was handled in
the template; if it had not been, the output would have kept a bodyless list item.

Port 8311 is arbitrary and must be free.

#### The Load a Template Cannot Carry

A template is a view tool, and it breaks down as its load grows. Three limits are
decisive in practice.

**Computation does not happen in the template.** The day count on an overdue loan, the
fine amount, or a sort criterion is computed in the view model. If a template computes it
instead, the same rule starts producing two different results on two pages and becomes
untestable.

**Data access does not happen in the template.** If a query could be run from inside an
iteration block, a page would spawn as many queries as it has rows. The view model brings
in everything that is needed before rendering starts.

**Raw output must stay countable.** The raw placeholder is the one point in the system
that needs auditing. As its count shrinks and it stays confined to places where only
already-rendered output is inserted, the template layer's correctness stays something the
eye can verify.

#### Summary

- The template engine first parses the template, then renders it against the data;
  because the data's content arrives after parsing has finished, it can never alter the
  template's structure.
- Escaping converts characters with structural meaning in markup into character entities
  and should be the default; raw output is requested by the template author's explicit
  decision.
- The measurement separates two contexts: in the text context an unescaped value inserts
  an element into the document, while in the attribute context it cuts the value off at
  the first quote.
- The layout template carries the shared document skeleton; the page output is placed
  into it through a single raw placeholder, because what is placed there is not data but
  output that has already been rendered.
- The view model is the contract between the template and the data source; computation
  and data access happen in the code that builds the view model, not in the template.

#### Next Step

Up to this point, data has always come from the inside: files on disk, records in memory.
In the library service's real operation, data also comes from the outside — a member's
photo, the cover image of a donated book, a count file uploaded from a branch. As this
content arrives at the server, its size is not known in advance, its declared type may not
match its actual content, and where it gets written is a decision the server makes. The
next lesson takes up uploading with these three questions: when and how does the server
reject a payload that exceeds its limit, how does it decide the file's type without
looking at the extension, and why is the written file kept separate from the served
directory?

### File Upload and Storage

Source: https://academia.sh/en/courses/server-fundamentals/file-upload-and-storage

Accepting a file coming from outside within limits: the two-layer size limit formed by the declared length and the stream counter, getting the rejection to the other side, determining the type from content instead of the extension, separating the store directory from the served directory, and generating the file name on the server.

The previous two lessons dealt with data flowing out of the server: a file on disk, a
document rendered from a template. In the library service's operation, data also flows in
the opposite direction. A donated book's cover image, a photo added to a member's record,
a count file coming from a branch — these arrive at the server from the outside.

Incoming content differs from content leaving the server in three ways: its size is not
known in advance, its declared type may not match what it actually is, and where it gets
written is the server's decision. This lesson builds the upload endpoint as the answer to
these three questions. Every item it takes up is a **defense**: a rule the server applies
before it accepts anything.

#### The Size Limit Has Two Layers

The size of an upload body can be learned from two separate places, and neither is
sufficient on its own.

The first is the **declared length**: the content-length value in the request header. This
value is visible before the body is read at all, so an upload above the limit can be
rejected before its first byte ever reaches memory. It is a cheap, early defense.

The second is the **stream counter**: counting the bytes that actually arrive as the body
is read. The declared length is part of the request, and the side that built the request
may not send it, or may send it wrong; in chunked transfer the header is not present at
all. The counter enforces the limit regardless of what was declared.

The two layers work together: the declaration enables an early rejection, and the counter
guarantees that the rejection cannot be bypassed. With only the first, the limit disappears
the moment it is not declared; with only the second, a large payload gets read all the way
to the limit every single time.

#### Getting the Rejection to the Other Side

Closing the connection on an over-limit request the instant it is detected sounds
reasonable, but the outcome is not what one would expect: the other side keeps writing the
body, so the connection breaks with an error, and the response the server wrote is never
read. What the sender sees is "connection reset"; the fact that a limit was exceeded, what
the limit was, and how to fix the request are all lost.

For the rejection to be readable, the server has to keep **draining** the incoming bytes:
not accumulating them, but reading them. Draining also has an upper bound of its own; once
that bound is exceeded too, the connection is dropped, because reading forever would erase
the point of rejecting in the first place.

This is the detail most often skipped in the upload endpoint, and it shows up directly in
the measurement: without draining, the client sees a connection error instead of a 413
response.

#### Type Is Determined from Content, Not the Extension

Two pieces of information reach the server about the uploaded file's type: the
content-type header in the request, and the file name's extension. Both are text the
sending side wrote, and neither is required to match the content. When they do not, what
results is an inconsistency before it is an attack: the record is marked as an image,
served as an image, and is not an image.

The trustworthy source is the content itself. Common binary formats carry a fixed byte
sequence at the start of the file; this sequence is called a **magic number**. The server
knows the signatures of the types it accepts and compares the body's first bytes against
that list. If there is no match, the upload is rejected.

Signature checking is an allowlist: the server counts what it accepts, not what it
rejects. Everything outside the list is rejected because it is unrecognized. A rule built
in the opposite direction — banning specific types — silently accepts every new format
that never made it onto the list.

The limit of signature checking must also be understood plainly: the signature shows the
file's format, not that the rest of it is valid. If an image's dimensions and content need
to be checked as well, that is the job of a step that decodes the format.

#### Storage Location and the Generated Name

The question of where an uploaded file gets written continues the document-root decision
from the first lesson. The static server serves every file under the document root. If
uploaded content were written into that same directory, byte sequences coming from
outside would be inserted directly into the server's served area. That is why **the store
directory is kept separate from the served directory**, and uploaded content is only ever
served through the application's own endpoint, after its record has been looked up.

The file name is not taken from outside either. The sent name is stored only as metadata
to be shown to the user; the name written to disk is **generated** on the server. The
generated name has three properties: it never collides, it carries no meaning in the
directory structure, and its extension comes not from the sent name but from the
**detected type**. This prevents two simultaneous uploads from overwriting each other, and
it prevents the name from being interpreted as a file path.

Two more headers are added on the serving side. The content type is read from the record,
not from what was declared, and type sniffing is turned off, so the receiving side never
steps outside the declared type and mistakes the content for something else.

#### The Endpoint in Full

The endpoint below takes the body directly as file bytes, instead of decoding the
**multipart/form-data** encoding that browser forms use. This is so the limit logic is not
hidden behind part parsing; in multipart encoding, the same three defenses are applied
separately to each part.

```js
// upload.mjs — cover image upload endpoint: size limit, content check, separate store
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { join } from "node:path";

const LIMIT = 64 * 1024;              // largest accepted cover image
const DRAIN_LIMIT = 1024 * 1024;      // most bytes still read from a rejected body
const STORE = join(process.cwd(), "data", "covers"); // outside the served directory
const records = new Map();            // generated name -> { type, bytes }

// Type is determined from the body's first bytes, not the declared header or file name.
const SIGNATURES = [
  { extension: "png", type: "image/png", signature: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
  { extension: "jpg", type: "image/jpeg", signature: [0xff, 0xd8, 0xff] },
  { extension: "gif", type: "image/gif", signature: [0x47, 0x49, 0x46, 0x38] },
];
const detectType = (body) =>
  SIGNATURES.find((s) => s.signature.every((b, i) => body[i] === b)) ?? null;

// Reads the body. If the limit is exceeded the bytes are not accumulated, but the stream
// keeps being read up to the drain limit: without draining, the written response never
// reaches the client before the connection breaks.
const readBody = (request, preRejected) =>
  new Promise((resolve) => {
    const chunks = [];
    let total = 0;
    let exceeded = preRejected;
    request.on("data", (chunk) => {
      total += chunk.length;
      if (!exceeded && total > LIMIT) { exceeded = true; chunks.length = 0; }
      if (exceeded) { if (total > DRAIN_LIMIT) request.destroy(); return; }
      chunks.push(chunk);
    });
    request.on("end", () => resolve(exceeded ? null : Buffer.concat(chunks)));
    request.on("error", () => resolve(null));
    request.on("close", () => resolve(null));
  });

const json = (response, status, body) => {
  const text = JSON.stringify(body);
  response.setHeader("Content-Type", "application/json; charset=utf-8");
  response.setHeader("Content-Length", Buffer.byteLength(text));
  response.writeHead(status).end(text);
};

await mkdir(STORE, { recursive: true });

createServer(async (request, response) => {
  response.sendDate = false;

  if (request.method === "POST" && request.url === "/cover") {
    // Defense 1: if the declared length is above the limit, the body is never accumulated.
    // Defense 2: the bytes that actually arrive are counted; a wrong declaration still hits the limit.
    const declared = Number(request.headers["content-length"] ?? 0);
    const stage = declared > LIMIT ? "declared_length" : "stream_counter";
    const body = await readBody(request, declared > LIMIT);
    if (body === null)
      return json(response, 413, { error: "limit_exceeded", stage, limit: LIMIT });

    // Defense 3: type is determined by the content, not the sent header or file name.
    const type = detectType(body);
    if (!type)
      return json(response, 415, {
        error: "type_not_accepted",
        declared: request.headers["content-type"] ?? null,
      });

    // Defense 4: the name is generated on the server; the extension comes from the detected type.
    const name = `${randomUUID()}.${type.extension}`;
    await writeFile(join(STORE, name), body);
    records.set(name, { type: type.type, bytes: body.length });

    response.setHeader("Location", `/cover/${name}`);
    return json(response, 201, { name, type: type.type, bytes: body.length });
  }

  // Defense 5: serving goes through recorded names; the request path never turns into a file path.
  if (request.method === "GET" && request.url.startsWith("/cover/")) {
    const name = request.url.slice("/cover/".length);
    const record = records.get(name);
    if (!record) return json(response, 404, { error: "not_found" });
    const body = await readFile(join(STORE, name));
    response.setHeader("Content-Type", record.type);
    response.setHeader("Content-Length", body.length);
    response.setHeader("Content-Disposition", "inline");
    response.setHeader("X-Content-Type-Options", "nosniff");
    return response.writeHead(200).end(body);
  }

  return json(response, 404, { error: "not_found" });
}).listen(8312, "127.0.0.1", () => console.log("listening: 127.0.0.1:8312"));
```

Keeping records in memory is only within this lesson's scope; when the server restarts,
the records are lost and the files on disk become unreachable. Writing metadata to a
persistent store is the subject of the Data Access Layer and Business Logic course.

#### Measurement

The measurement uses three files: a valid image, a text file whose name ends with an
image extension, and a file above the limit. The block below generates all three.

```js
// generate-samples.mjs — generates three files for testing: valid PNG, PNG-named text, large PNG
import { crc32, deflateSync } from "node:zlib";
import { statSync, writeFileSync } from "node:fs";

const chunk = (tag, data) => {
  const length = Buffer.alloc(4);
  length.writeUInt32BE(data.length);
  const body = Buffer.concat([Buffer.from(tag, "ascii"), data]);
  const crc = Buffer.alloc(4);
  crc.writeUInt32BE(crc32(body) >>> 0);
  return Buffer.concat([length, body, crc]);
};

const png = (width, height) => {
  const ihdr = Buffer.alloc(13);
  ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
  ihdr[8] = 8; ihdr[9] = 2; // 8-bit depth, true color
  const row = Buffer.alloc(1 + width * 3); // filter byte + RGB
  const raw = Buffer.concat(Array.from({ length: height }, () => row));
  return Buffer.concat([
    Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
    chunk("IHDR", ihdr),
    chunk("IDAT", deflateSync(raw)),
    chunk("IEND", Buffer.alloc(0)),
  ]);
};

writeFileSync("cover.png", png(8, 8));
writeFileSync("fake.png", Buffer.from("This is a text file; its name ends with .png.\n"));
writeFileSync("large.png", Buffer.concat([png(1200, 1200), Buffer.alloc(120 * 1024, 0x41)]));
for (const f of ["cover.png", "fake.png", "large.png"])
  console.log(f, statSync(f).size, "B");
```

```bash
#!/usr/bin/env bash
# Starts upload.mjs, tests four upload attempts, lists the store, then stops it.
rm -rf data
node generate-samples.mjs
node upload.mjs > /dev/null &
server=$!
sleep 1
A=http://127.0.0.1:8312

echo "--- 1) valid cover (69 B) ---"
NAME=$(curl -sS -X POST --data-binary @cover.png -H 'Content-Type: image/png' "$A/cover" \
     | tee /dev/stderr | sed 's/.*"name":"\([^"]*\)".*/\1/')
echo
echo "--- 2) serving the uploaded cover ---"
curl -sS -D - -o /dev/null "$A/cover/$NAME"

echo "--- 3) text file named .png, declared as image/png ---"
curl -sS -o - -w '  <- HTTP %{http_code}\n' -X POST --data-binary @fake.png \
     -H 'Content-Type: image/png' "$A/cover"

echo "--- 4) above the limit, with declared length ---"
curl -sS -o - -w '  <- HTTP %{http_code}\n' -X POST --data-binary @large.png \
     -H 'Content-Type: image/png' "$A/cover"

echo "--- 5) above the limit, without declared length (chunked transfer) ---"
curl -sS -o - -w '  <- HTTP %{http_code}\n' -X POST --data-binary @large.png \
     -H 'Content-Type: image/png' -H 'Transfer-Encoding: chunked' "$A/cover"

echo "--- written to the store ---"
ls data/covers | sed 's/^/  /'
echo "  total files: $(ls data/covers | wc -l | tr -d ' ')"

kill "$server"
```

```
cover.png 69 B
fake.png 46 B
large.png 127159 B
--- 1) valid cover (69 B) ---
{"name":"9397da79-072c-46bb-bbec-49e9afddcc59.png","type":"image/png","bytes":69}
--- 2) serving the uploaded cover ---
HTTP/1.1 200 OK
Content-Type: image/png
Content-Length: 69
Content-Disposition: inline
X-Content-Type-Options: nosniff
Connection: keep-alive
Keep-Alive: timeout=5

--- 3) text file named .png, declared as image/png ---
{"error":"type_not_accepted","declared":"image/png"}  <- HTTP 415
--- 4) above the limit, with declared length ---
{"error":"limit_exceeded","stage":"declared_length","limit":65536}  <- HTTP 413
--- 5) above the limit, without declared length (chunked transfer) ---
{"error":"limit_exceeded","stage":"stream_counter","limit":65536}  <- HTTP 413
--- written to the store ---
  9397da79-072c-46bb-bbec-49e9afddcc59.png
  total files: 1
```

The generated name changes on every run; connection headers depend on the runtime's
default settings. Port 8312 is arbitrary and must be free.

Four results are worth reading. **Type checking did not look at the declaration**: in the
third attempt the request declared the type as an image, but because the content was
text, the upload was rejected with 415. **Both limit layers worked independently**: in the
fourth attempt the rejection happened at the declared length, and in the fifth — where the
length was never declared — it happened at the stream counter. The stage field in the
response says which layer engaged. **No rejected upload was written to disk**: close to
two hundred kilobytes of data were sent, yet the store holds exactly one file, and it is
the valid cover.

#### Summary

- The size limit consists of two layers: the declared length enables an early, cheap
  rejection, while the stream counter enforces the limit when the declaration is wrong or
  missing.
- If an over-limit request is cut off instantly, the written response never reaches the
  other side; the rejection is communicated by draining the incoming bytes up to their own
  upper bound.
- The file type is determined from the signature at the start of the body, not from the
  declared header or the extension, and the accepted types are counted with an allowlist.
- The store directory is kept separate from static serving's document root; uploaded
  content is served only after its record is looked up, through the application's own
  endpoint.
- The name written to disk is generated on the server and its extension comes from the
  detected type; the sent name is stored only as metadata to be displayed.

#### Next Step

This lesson brought out the first persistent data the server writes to disk, and it also
made a gap visible: because records live in memory, they are lost when the server
restarts. In a real setup, those records live in a database, the cover files live in an
object store, and search lives in a separate service. The application is no longer a
single process running on its own; it needs other services alongside it. The course's last
lesson takes up the question of bringing these dependencies up on the developer's machine:
in what order do the services start, how does the application know they are ready, and how
does the same environment get built identically on two machines?

### Local Development Environment

Source: https://academia.sh/en/courses/server-fundamentals/local-development-environment

Bringing dependent services up on the developer's machine: how startup order forms a chain, how a readiness check is built from a connection attempt and backoff, why a fixed wait is a guess, and how the same environment is reproduced on two machines through version pinning and the seed data rule.

The previous lesson brought out the first persistent data the server writes to disk, and
it noted that keeping records in memory was a gap. In a real setup, those records live in
a database, and search lives in a separate service. The library lending application is no
longer a process running on its own: it needs **dependent services** alongside it.

This creates a new problem on the developer's machine. Running the application first
requires running other programs, those programs have their own setup work, and it is not
obvious when the application can trust them. This lesson answers three questions: in what
order do the services start, how is a service's readiness recognized, and how does the
same environment get built identically on two separate machines?

#### Dependencies Form a Chain

The local environment's decisions are collected in a single file. This file works as an
**environment manifest**: ports, schema version, the runtime's minimum version, and the
seed data sets.

```json
{
  "runtime": { "name": "node", "min_major_version": 22 },
  "schema_version": 3,
  "seed_set": "small",
  "seed_rows": { "small": 150000, "large": 1000000 },
  "ports": { "database": 8431, "search": 8432, "app": 8433 }
}
```

The first dependency is the service holding the catalog data. The detail in its behavior
is central to this lesson: it opens its port immediately, but it does not accept queries
until the schema and the seed data have been written. It also exposes a **health
endpoint** that reports its own state.

```js
// database.mjs — service holding the catalog data; refuses queries until ready
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";
import { readFileSync, rmSync } from "node:fs";

const env = JSON.parse(readFileSync("environment.json", "utf8"));
const set = process.env.SEED_SET ?? env.seed_set;
const rows = env.seed_rows[set];
const port = env.ports.database;

rmSync("library.db", { force: true });
const db = new DatabaseSync("library.db");
let ready = false;

const json = (response, status, body) => {
  const text = JSON.stringify(body);
  response.setHeader("Content-Type", "application/json; charset=utf-8");
  response.setHeader("Content-Length", Buffer.byteLength(text));
  response.writeHead(status).end(text);
};

createServer((request, response) => {
  response.sendDate = false;
  const address = new URL(request.url, "http://local");
  if (address.pathname === "/health")
    return json(response, ready ? 200 : 503, { ready, schema: env.schema_version });
  if (!ready) return json(response, 503, { error: "not_ready" });
  if (address.pathname === "/books") {
    const author = address.searchParams.get("author") ?? "Author 42";
    const records = db.prepare(
      "SELECT isbn, name, author FROM book WHERE author = ? ORDER BY isbn").all(author);
    return json(response, 200, { count: records.length, books: records });
  }
  return json(response, 404, { error: "not_found" });
}).listen(port, "127.0.0.1", () => {
  console.log(`database :${port} opened (not ready)`);
  // Setup work runs in chunks after the port opens: this way a not-yet-ready server
  // does not leave requests unanswered, and instead returns a response reporting its state.
  const started = Date.now();
  db.exec("CREATE TABLE book (isbn TEXT PRIMARY KEY, name TEXT NOT NULL, author TEXT NOT NULL)");
  const insert = db.prepare("INSERT INTO book (isbn, name, author) VALUES (?, ?, ?)");
  db.exec("BEGIN");
  let written = 0;
  const writeChunk = () => {
    for (const end = Math.min(written + 10000, rows); written < end; written++)
      insert.run(`978-${String(written).padStart(10, "0")}`,
               `Book ${written}`, `Author ${written % 500}`);
    if (written < rows) return setImmediate(writeChunk);
    db.exec("COMMIT");
    db.exec("CREATE INDEX ix_author ON book(author)");
    ready = true;
    console.log(`database ready: ${set} seed, ${rows} rows, ${Date.now() - started} ms`);
  };
  setImmediate(writeChunk);
});
```

The second dependency is the search service, and what forms the chain is the way it
starts up: it builds its index by reading from the database, so it cannot start at all if
the database is not ready.

```js
// search.mjs — second HTTP service; reads its index from the database at startup
import { createServer } from "node:http";
import { readFileSync } from "node:fs";

const env = JSON.parse(readFileSync("environment.json", "utf8"));
const { database, search } = env.ports;

const response = await fetch(`http://127.0.0.1:${database}/books?author=Author%2042`)
  .catch((e) => { console.error(`search: could not reach the database (${e.cause?.code ?? e.name})`);
                  process.exit(1); });
if (!response.ok) { console.error(`search: database returned ${response.status}, index could not be built`);
                 process.exit(1); }

const { books } = await response.json();
const index = new Map(books.map((b) => [b.name.toLowerCase(), b.isbn]));

createServer((request, reply) => {
  reply.sendDate = false;
  const term = new URL(request.url, "http://local").searchParams.get("q")?.toLowerCase() ?? "";
  const found = [...index].filter(([name]) => name.includes(term)).map(([name, isbn]) => ({ name, isbn }));
  const body = JSON.stringify({ indexSize: index.size, found });
  reply.setHeader("Content-Type", "application/json; charset=utf-8");
  reply.setHeader("Content-Length", Buffer.byteLength(body));
  reply.writeHead(200).end(body);
}).listen(search, "127.0.0.1", () => console.log(`search :${search} ready, index ${index.size} records`));
```

The chain's last link is the lending application, which uses both of them.

```js
// app.mjs — lending application; uses both dependent services
import { createServer } from "node:http";
import { readFileSync } from "node:fs";

const env = JSON.parse(readFileSync("environment.json", "utf8"));
const { database, search, app } = env.ports;

createServer(async (request, reply) => {
  reply.sendDate = false;
  const q = new URL(request.url, "http://local").searchParams.get("q") ?? "book 42";
  let status = 200, body;
  try {
    const a = await fetch(`http://127.0.0.1:${search}/?q=${encodeURIComponent(q)}`).then((r) => r.json());
    const v = await fetch(`http://127.0.0.1:${database}/health`).then((r) => r.json());
    body = { query: q, matches: a.found.length, indexSize: a.indexSize, database: v };
  } catch (e) {
    status = 503;
    body = { error: "dependent_service_unavailable", detail: e.cause?.code ?? e.name };
  }
  const text = JSON.stringify(body);
  reply.setHeader("Content-Type", "application/json; charset=utf-8");
  reply.setHeader("Content-Length", Buffer.byteLength(text));
  reply.writeHead(status).end(text);
}).listen(app, "127.0.0.1", () => console.log(`app :${app} ready`));
```

Having the catalog data live in a process we wrote ourselves is meant to make the
lesson's subject visible; the behavior stays the same once a real database server takes
its place. The setup work done at startup stays in the same place too: checking the file
format, repairing the log, opening indexes.

#### Being Ready Is Different from Having Started

Starting a process and that process being able to do work are two separate events, and
there is a window between them. A **readiness check** is a gate that waits out this
window: it asks the dependent service by trying it, waits on a negative answer, and tries
again. The wait doubles on every failed attempt — the **exponential backoff** introduced
in the Application Architecture course applies here as well: the first attempts come
close together, later ones spread out, so a service that starts fast is caught right
away, and a service that starts slowly is not polled needlessly.

```js
// wait.mjs — readiness check. Usage: node wait.mjs <port|health> <port-number> [name]
import { connect } from "node:net";
const [mode, port, name = "service"] = process.argv.slice(2);

const isPortOpen = (p) => new Promise((resolve) => {
  const s = connect({ port: Number(p), host: "127.0.0.1" });
  s.once("connect", () => { s.destroy(); resolve(true); });
  s.once("error", () => resolve(false));
});
const isHealthy = async (p) =>
  fetch(`http://127.0.0.1:${p}/health`).then((r) => r.status === 200).catch(() => false);

const check = mode === "port" ? isPortOpen : isHealthy;
const started = Date.now();
let delay = 20;        // first wait, ms; doubles on every failed attempt
let attempt = 0;
while (Date.now() - started < 5000) {
  attempt++;
  if (await check(port)) {
    console.log(`  ${name}: ready (${mode} mode, ${attempt} attempts, ${Date.now() - started} ms)`);
    process.exit(0);
  }
  await new Promise((c) => setTimeout(c, delay));
  delay = Math.min(delay * 2, 500);
}
console.error(`  ${name}: not ready within 5 s`);
process.exit(1);
```

The difference between the two modes is the measurement's subject. The **port check**
asks only part of the question: is something listening at this address? The **health
check** asks the real question: can this service do work? The startup script is written
so it can compare four separate gates.

```bash
#!/usr/bin/env bash
# bring-up.sh — brings dependent services up in order.
# Usage: ./bring-up.sh <none|fixed|port|health>;  SEED_SET=small|large
mode="${1:-health}"; set="${SEED_SET:-small}"
pids=(); cleanup() { kill "${pids[@]}" 2>/dev/null; wait 2>/dev/null; }; trap cleanup EXIT
echo "== gate=$mode  seed=$set =="

SEED_SET="$set" node database.mjs & pids+=($!)
case "$mode" in
  none)   ;;                                                # no gate
  fixed)  sleep 0.5 ;;                                       # fixed wait: a guess
  port)   node wait.mjs port   8431 database || exit 1 ;;   # is it accepting connections
  health) node wait.mjs health 8431 database || exit 1 ;;   # is it able to do work
esac

node search.mjs & pids+=($!)
node wait.mjs port 8432 search || { echo "  -> chain broken: search did not come up"; exit 1; }

node app.mjs & pids+=($!)
node wait.mjs port 8433 app > /dev/null
printf 'request -> '
curl -sS --max-time 5 -w ' (HTTP %{http_code})\n' "http://127.0.0.1:8433/?q=book%2042"
```

#### Measurement: Four Gates

```bash
for k in none fixed port health; do ./bring-up.sh $k; echo; done
```

```
== gate=none  seed=small ==
database :8431 opened (not ready)
search: database returned 503, index could not be built
database ready: small seed, 150000 rows, 133 ms
  search: not ready within 5 s
  -> chain broken: search did not come up

== gate=fixed  seed=small ==
database :8431 opened (not ready)
database ready: small seed, 150000 rows, 132 ms
search :8432 ready, index 300 records
  search: ready (port mode, 3 attempts, 65 ms)
app :8433 ready
request -> {"query":"book 42","matches":3,"indexSize":300,"database":{"ready":true,"schema":3}} (HTTP 200)

== gate=port  seed=small ==
database :8431 opened (not ready)
  database: ready (port mode, 2 attempts, 22 ms)
search: database returned 503, index could not be built
database ready: small seed, 150000 rows, 136 ms
  search: not ready within 5 s
  -> chain broken: search did not come up

== gate=health  seed=small ==
database :8431 opened (not ready)
database ready: small seed, 150000 rows, 136 ms
  database: ready (health mode, 4 attempts, 176 ms)
search :8432 ready, index 300 records
  search: ready (port mode, 2 attempts, 22 ms)
app :8433 ready
request -> {"query":"book 42","matches":3,"indexSize":300,"database":{"ready":true,"schema":3}} (HTTP 200)
```

The durations are machine-dependent, and the order of the lines written by the three
background processes also changes on every run. What does not change is which gate forms
the chain.

**With no gate**, the database's port had already opened by the time the search service
made its request, but the seed data was still being written, so the response was a 503
and the index could never be built. The process exits, and nothing after it is ever set
up. Startup order is not a preference, it is a requirement.

**The port check** produces the most instructive line. The gate passed in 22
milliseconds, on the second attempt — because the port really was open. But the database
was still writing its seed data at that moment and returned `503` to search. The chain
breaks again. A listening port does not mean a working service.

**The health check** does the same job in 176 milliseconds and four attempts: the gate
waits for the state the service reports about itself. The chain forms, and the
application produces a response using both of its dependencies.

#### A Fixed Wait Is a Guess

The fixed wait worked with the small seed set. This shows that the guess happened to
hold, not that it was correct. When the same script runs with a larger seed set, the
guess breaks down.

```bash
for k in fixed health; do SEED_SET=large ./bring-up.sh $k; echo; done
```

```
== gate=fixed  seed=large ==
database :8431 opened (not ready)
search: database returned 503, index could not be built
database ready: large seed, 1000000 rows, 880 ms
  search: not ready within 5 s
  -> chain broken: search did not come up

== gate=health  seed=large ==
database :8431 opened (not ready)
database ready: large seed, 1000000 rows, 881 ms
  database: ready (health mode, 7 attempts, 1178 ms)
search :8432 ready, index 2000 records
  search: ready (port mode, 3 attempts, 64 ms)
app :8433 ready
request -> {"query":"book 42","matches":23,"indexSize":2000,"database":{"ready":true,"schema":3}} (HTTP 200)
```

The half-second wait is not enough, because setup took 880 milliseconds. The health
check, in the same script with nothing changed, waits 1178 milliseconds and passes. A
fixed wait is wrong in both directions: when it is too short it breaks the chain, when it
is too long it wastes time on every startup. The check, instead, ties the wait to a
measurement.

#### The Same Environment on Two Machines

Forming the chain is not enough; the same chain has to produce the same result on a
different machine. Two things break this: different runtime versions, and seed data that
comes out different on every setup.

The first is met with **version pinning**: the minimum version written in the manifest is
compared against the version actually running at startup, and a mismatch is never passed
over silently. The second is met with a rule: **seed data is input, not something
generated**. Identifiers, dates, and relationships are written as part of the seed; they
are not taken from the moment the code runs or from randomness.

```js
// seed.mjs — writes seed data. Usage: node seed.mjs <file> <fixed|free>
import { DatabaseSync } from "node:sqlite";
import { rmSync } from "node:fs";

const [file, rule = "fixed"] = process.argv.slice(2);
rmSync(file, { force: true });
const db = new DatabaseSync(file);
db.exec(`CREATE TABLE member (id INTEGER PRIMARY KEY, name TEXT NOT NULL, registered TEXT NOT NULL);
         CREATE TABLE loan (id INTEGER PRIMARY KEY, member INTEGER NOT NULL, isbn TEXT NOT NULL)`);

const MEMBERS = ["Alice Kane", "Ben Ortiz", "Clara Diaz", "Derek Voss"];
const ISBNS = ["978-0000000001", "978-0000000002", "978-0000000003"];
const insertMember = db.prepare("INSERT INTO member (id, name, registered) VALUES (?, ?, ?)");
const insertLoan = db.prepare("INSERT INTO loan (id, member, isbn) VALUES (?, ?, ?)");

MEMBERS.forEach((name, i) => {
  // fixed rule: the date is part of the seed. free rule: it comes from the moment it runs.
  const registered = rule === "fixed" ? `2024-01-0${i + 1}` : new Date().toISOString();
  insertMember.run(i + 1, name, registered);
});
ISBNS.forEach((isbn, i) => {
  const member = rule === "fixed"
    ? (i % MEMBERS.length) + 1
    : 1 + Math.floor(Math.random() * MEMBERS.length);
  insertLoan.run(i + 1, member, isbn);
});
db.close();
```

Whether the rule holds can be measured: the same seed is written twice, and the data
content's **checksum** is compared. The checksum looks at the table content, not the file
layout.

```js
// verify.mjs — checks the runtime version against the declaration, writes the seed checksum
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";

const env = JSON.parse(readFileSync("environment.json", "utf8"));
const majorVersion = Number(process.versions.node.split(".")[0]);
const fit = majorVersion >= env.runtime.min_major_version;
console.log(`runtime: ${env.runtime.name} ${majorVersion}` +
  ` (declared minimum ${env.runtime.min_major_version}) -> ${fit ? "fit" : "NOT FIT"}`);
if (!fit) process.exit(1);

const checksum = (file) =>
  execFileSync("sqlite3", [file, ".sha3sum"], { encoding: "utf8" }).trim().slice(0, 16);
for (const file of process.argv.slice(2)) console.log(`  ${file}: ${checksum(file)}`);
```

```bash
for rule in fixed free; do
  for n in 1 2; do node seed.mjs "$rule-$n.db" "$rule"; done
done
node verify.mjs fixed-1.db fixed-2.db free-1.db free-2.db
```

```
runtime: node 24 (declared minimum 22) -> fit
  fixed-1.db: 193ad8ccf56b86f6
  fixed-2.db: 193ad8ccf56b86f6
  free-1.db: c5541004e4a7551c
  free-2.db: 178c62eb7f6888bc
```

The checksums are written truncated, and the two values the free rule produces are
different on every run; the runtime line also depends on the runtime of the machine that
executes it. The result to read is this: the fixed rule produced the same checksum across
two setups, the free rule did not. The same distinction holds between two machines. In an
environment built with the free rule, the sentence "it works on my machine" turns into a
claim that cannot be verified, because the data on the two machines is not the same.

#### Summary

- Dependent services form a chain: each link requires the one before it to be ready; with
  no gate in place, the first link's startup delay breaks the whole chain.
- A port being open does not mean being ready; in the measurement the port check passed
  in 22 milliseconds, and while the service was still doing its setup work at that
  moment, it returned `503` to the dependent service.
- The readiness check looks at the state the service reports about itself, and after a
  failed attempt it doubles the wait and retries.
- A fixed wait is a guess: when the seed data grew, the same half second was not enough,
  while the health check waited exactly as long as it needed to, unchanged.
- Building the same environment on two machines rests on two rules: comparing the runtime
  version against the manifest, and having seed data come from the seed itself rather
  than the moment it runs; the second is verified by checksum equality.

#### Course Wrap-Up

This course built a request's journey through the server side, start to finish. The
question asked at the beginning was "what does the side facing the client take on"; the
answer gathered into three responsibilities — persistence, business rules, integration —
and measurement showed why these responsibilities cannot be delegated to the client. From
there, "the server" stopped being a single program: the web server, the application
server, and proxying split into separate roles, and the request lifecycle and the
middleware chain gave the ordering within those roles.

The second part brought the application itself up: reading configuration from the
environment, separating secrets, structured logging, and a common error layer. The third
part took up what the application gives back to the outside: serving a file with a
validator and a cache directive, rendering a view from a template, accepting an incoming
file within limits, and bringing the services next to it up locally.

One line of reasoning repeated throughout the course: **no value coming from the client
counts as a decision unless it has passed through the server's own check.** A declared
length, a declared content type, a sent file name, a forwarded client address — all of
these are claims. The server's job is to meet these claims with its own measurement.

Up to this point, what the server gave back to the outside was either a file or a
document: content meant for a person to read. The library service's real consumers,
though, are programs — the application in the browser, terminals at the branches,
reporting jobs. Where programs connect to each other, what determines things is not
format but **contract**: which resources exist, what they are named, which fields they
carry, and what happens to the other side when a field is removed? The next course — Web
API Design — builds this contract. Its first topic separates the different API styles
that answer the same job — resource-based, remote-procedure-call-based, and query-based
approaches — and measures the trade-offs between them.

