programming · Level 3
Node.js: build and test a small API
Make request boundaries, failures and tests visible.

Start with the essentials
The short answer
A small HTTP API needs a clear request contract, validated input and responses that clients can interpret consistently. This workbook builds a local fictional course catalogue with Node.js built-in modules, then tests its normal and failure paths. It covers GET and HEAD, configuration and bounded shutdown. It does not implement authentication, writes, a database or public hosting.
What you will learn
- Define an HTTP route and response contract before coding.
- Validate query fields without permissive numeric coercion.
- Separate a pure catalogue function from the transport handler.
- Return consistent JSON errors and correct UTF-8 byte lengths.
- Test GET, HEAD, method rejection and failure isolation.
- Validate startup configuration and describe shutdown limits.
Who it is for
Learners comfortable with JavaScript functions, arrays and modules who want to build their first small server.
Before you start
- Understand JavaScript functions, objects, arrays and promises.
- Be able to save text files and run terminal commands.
- Complete the browser events and state course or equivalent introductory JavaScript practice.
Read a sample · Chapter 01 of 06
Specify the service before opening a port
An API is an agreement between callers and a service. Start with observable behaviour: accepted requests, response shapes and failures. A working port is only the beginning of that agreement.
The fictional catalogue in this lab contains three teaching records: HTML basics, Node APIs and Café datasets. These are invented examples, not the live Trust Agent catalogue or Mickai product internals. Keeping the fixture small lets you predict every result before making a request. You can then tell whether a failure comes from selecting data, routing a request or serialising a response.
A client sends a method, a request target and headers. A response has a status, headers and sometimes a body. The route identifies the resource being requested; query fields narrow a selection. In this contract, GET retrieves JSON and HEAD returns the corresponding response headers without sending its body. The same URL should not silently change meaning because a caller misspells a parameter or supplies it twice.
Scroll sideways to see every column.
| Request | Meaning | Response |
|---|---|---|
| GET /health | Process can handle this route | 200; status is ok |
| GET /api/courses | Select all three fixture records | 200; count and items |
| GET /api/courses?level=3&q=api | Intersect level and title matching | 200; the node record |
| HEAD /api/courses | Inspect representation headers | 200; no response body |
| POST /api/courses | Unsupported operation on a known route | 405; Allow: GET, HEAD |
| GET /missing | Unknown route | 404; stable error object |
The catalogue accepts only level and q, each at most once. An absent level means any level; a supplied level must be one digit from 1 to 4. The text query defaults to an empty string, which matches every title. It is trimmed and matched without regard to case, but accent folding, spelling correction and relevance ranking are not implemented. A valid search with no matches returns an empty list with status 200, because the request was understood.
This service is deliberately read-only. There is no POST handler, request-body parser, account, database or remote model call. That scope makes the transport boundary teachable without pretending to solve authentication, data persistence or concurrent writes. If you later add a write route, define body limits, permissions, validation, conflict behaviour and retry semantics as a separate change with separate tests.
Use Node.js 24 for the exercise; the recorded run used version 24.15.0 on Windows 11. Create an empty practice folder and save the five files shown in the next chapters as UTF-8 text with their exact .mjs names. That extension makes the examples ES modules. No package installation or package.json is needed. Run commands from the practice folder; do not paste server code into the browser console.
node --version
Files to create:
catalogue.mjs
api.mjs
config.mjs
server.mjs
api.test.mjsTry it yourself · Activity 01
10 minPredict the contract
Before coding, classify six requests against the table.
- Predict a valid level-4 search, a level of 3x and a repeated level field.
- Predict a POST to the known catalogue route and a GET to an unknown route.
- State whether an empty result is the same as a server failure.
Which behaviour would a caller be unable to infer from a plain empty array?
Worked answer
Level 4 is accepted and returns count 0 with an empty items array. The malformed and repeated fields return 400. POST to the known route returns 405 with the permitted methods; the unknown route returns 404. A successful empty selection is 200, not 500.
Read a sample · Chapter 02 of 06
Keep catalogue rules separate from HTTP
The first file has no socket or response object. It takes parsed query fields and returns matching records, or raises an input error. This makes the rules straightforward to test before the server exists.
Save the following complete file as catalogue.mjs. The array and each record are frozen so a returned record cannot be altered accidentally during the lesson. Filtering returns a new array, so removing an item from that result does not remove it from the catalogue. This is enough for the shallow fixture objects shown here; Object.freeze is not a recursive immutability system for arbitrary nested data.
// Invented teaching records, not the live Trust Agent catalogue.
const courses = Object.freeze([
{ id: 'html', title: 'HTML basics', level: 1 },
{ id: 'node', title: 'Node APIs', level: 3 },
{ id: 'cafe', title: 'Café datasets', level: 3 },
].map(Object.freeze));
export class InputError extends Error {}
export function findCourses(params) {
const allowed = new Set(['level', 'q']);
for (const key of params.keys()) {
if (!allowed.has(key) || params.getAll(key).length !== 1) {
throw new InputError('Unknown or repeated query field');
}
}
const level = params.get('level');
if (level !== null && !/^[1-4]$/.test(level)) {
throw new InputError('level must be a digit from 1 to 4');
}
const raw = params.get('q') ?? '';
if (raw.length > 80) {
throw new InputError('q must be at most 80 code units');
}
const query = raw.trim().toLowerCase();
return courses.filter((course) =>
(level === null || course.level === Number(level)) &&
course.title.toLowerCase().includes(query)
);
}The allowed-field set rejects unknown names, including plausible typos such as levle. Counting getAll values rejects repeated parameters rather than choosing an undocumented first or last value. That avoids a caller, cache and handler applying different interpretations to the same request. In a larger API, repeated fields might be a supported list format, but the contract would need to say so. Here they are rejected even when both values are identical.
Validation happens before conversion. Number can convert several strings into a number, while parseInt can accept a valid prefix followed by unwanted text. Neither fact makes those strings part of this API. The anchored regular expression admits precisely one of the four declared digits. Only then does the selector convert the validated value for comparison with the numeric fixture level. A missing parameter and an empty parameter are intentionally different cases.
The raw q length is checked before trimming. Eighty JavaScript UTF-16 code units is an explicit lab limit, not eighty visible characters, words or bytes. Some symbols use two code units and some visible characters contain multiple Unicode code points. The limit is easy to inspect, but it is not a general human-text policy. The later request-target limit is separate and includes percent-encoded bytes represented in the incoming target string.
The final predicate combines level and title conditions with AND. A query for level 1 and API matches nothing: the Node record meets the title condition but has level 3. Lowercasing permits CAFÉ to match Café datasets; CAFE without the accent does not match it. Do not add a transformation simply to make a test pass. Decide whether accent-insensitive matching is actually wanted, add representative examples, and update the contract if it is.
Try it yourself · Activity 02
15 minMake ambiguous input visible
Use small URLSearchParams values to explore the selector.
- Try level=3x, level=01 and level=3&level=3.
- Compare an absent q, q= and q containing three spaces.
- Compare CAFÉ with CAFE and explain the difference.
Would silently accepting an unknown parameter conceal a broken client?
Worked answer
All three level examples fail validation. The absent, empty and whitespace-only text queries select all records because trimming produces an empty string. CAFÉ matches the accented fixture after lowercasing; CAFE does not. The selector promises case-insensitive substring matching, not accent removal.
Read a sample · Chapter 03 of 06
Translate the contract into HTTP responses
The transport layer converts an incoming request into a catalogue call and translates the result back into a response. Keep those translations explicit so errors remain understandable at the client boundary.
Save the complete next file as api.mjs. createApi returns a server without starting it. That separation lets tests choose an unused port and clean up afterwards. The optional select argument is a synchronous testing seam: production lab code uses findCourses, while a test can inject a function that throws. It is not a plugin protocol or a promise-based database adapter.
import { createServer } from 'node:http';
import { findCourses, InputError } from './catalogue.mjs';
function reply(req, res, status, payload, extra = {}) {
const body = JSON.stringify(payload) + '\n';
res.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(body),
'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
'connection': 'close',
...extra,
});
res.end(req.method === 'HEAD' ? undefined : body);
}
export function createApi(select = findCourses) {
return createServer({
maxHeaderSize: 8192,
headersTimeout: 5000,
requestTimeout: 5000,
}, (req, res) => {
const fail = (status, code, message, extra) =>
reply(req, res, status, { error: { code, message } }, extra);
if (!req.url || req.url.length > 2048) {
return fail(414, 'target_too_long', 'Target exceeds limit');
}
if (!req.url.startsWith('/') || req.url.startsWith('//')) {
return fail(400, 'bad_target', 'Use a local request path');
}
let url;
try {
url = new URL(req.url, 'http://127.0.0.1');
} catch {
return fail(400, 'bad_target', 'Target could not be parsed');
}
if (url.origin !== 'http://127.0.0.1' || url.hash) {
return fail(400, 'bad_target', 'Use a local path without a fragment');
}
const known = ['/health', '/api/courses'].includes(url.pathname);
if (!known) return fail(404, 'not_found', 'Route not found');
if (!['GET', 'HEAD'].includes(req.method)) {
return fail(405, 'method_not_allowed', 'Use GET or HEAD', {
allow: 'GET, HEAD',
});
}
if (req.headers['transfer-encoding'] ||
Number(req.headers['content-length'] || 0) !== 0) {
return fail(400, 'body_not_supported', 'No request body');
}
try {
if (url.pathname === '/health') {
if ([...url.searchParams].length) {
throw new InputError('health accepts no query fields');
}
return reply(req, res, 200, { status: 'ok' });
}
const items = select(url.searchParams);
return reply(req, res, 200, { count: items.length, items });
} catch (error) {
if (error instanceof InputError) {
return fail(400, 'invalid_query', error.message);
}
return fail(500, 'internal_error', 'Request could not complete');
}
});
}The reply helper serialises once and measures the resulting UTF-8 byte length. A JavaScript string length is not generally its network byte length: the accented fixture makes that difference observable. The same computed representation length can be sent for HEAD while res.end receives no body. Keeping that decision in one helper covers successful and error responses consistently. JSON content type and stable error fields help clients interpret the result instead of guessing from displayed text.
Routing uses a fixed local base when parsing the request target, rather than constructing a destination from the request Host header. Parsing a URL does not fetch it. The target guard rejects an absolute or network-path target; the parsed-origin check also covers backslash normalisation. A parsing exception becomes a 400 response instead of escaping the callback. Fragments are outside this lab contract and are rejected when present. The URL parser may normalise path segments; the route comparison operates on the parsed pathname.
The order of checks is part of observable behaviour. Unknown paths return 404 before method checking. Known paths accept GET or HEAD and reject other methods with 405 and an Allow header. Non-empty or transfer-encoded request bodies are unsupported. The health route accepts no query fields. Catalogue validation errors are recognised by their class and become 400 responses. An unexpected selector error becomes 500 without exposing its message or stack to the caller.
The fixed fixture bounds the response size and work performed. Header and request timeouts are set explicitly, and an oversized request target is rejected. These are limited safeguards, not a load test or a complete public-service defence. The underlying HTTP parser can reject malformed traffic before this handler runs, so not every possible failure will use this JSON error shape. Connection: close is a deliberate teaching simplification; this lab does not evaluate the performance trade-off of connection reuse.
Try it yourself · Activity 03
20 minTrace a failure through the boundary
Read the handler from the top without running it first.
- Trace GET /api/courses?level=3x and identify the throwing function.
- Trace POST /missing and compare it with POST /api/courses.
- Explain why a hidden internal message should not become a public error response.
If you moved method checking earlier, which test expectations would change?
Worked answer
The known catalogue route accepts GET, then findCourses raises InputError for 3x and the handler returns 400 invalid_query. POST /missing returns 404 because routing precedes method checking; POST /api/courses returns 405. Unexpected failures use a generic message because internal details are not part of the caller contract. Operators still need a separate, appropriately limited diagnostic channel in a real deployment.
Read a sample · Chapter 04 of 06
Start locally with checked configuration
Creating a server and listening on a port are different steps. A startup file owns configuration and process lifecycle; the API module remains importable by tests without opening a permanent listener.
Save config.mjs below. Missing PORT selects 3000. A present value must use the declared decimal syntax and fall within 1 to 65535. Empty text, whitespace, exponential notation and zero are not accepted. The operating system may still refuse an otherwise valid port because another process uses it or because permissions differ. Configuration validation cannot predict every bind failure.
export function readPort(value) {
if (value === undefined) return 3000;
if (!/^[1-9][0-9]{0,4}$/.test(value)) {
throw new Error('PORT must be an integer from 1 to 65535');
}
const port = Number(value);
if (port > 65535) throw new Error('PORT must be at most 65535');
return port;
}Now save server.mjs. It binds to the IPv4 loopback address rather than all network interfaces. The startup message is printed only once listening succeeds. A startup error writes its code and sets a failing process exit code. The code reads only PORT; there is no reason to print the whole environment, which can contain unrelated private configuration.
import { createApi } from './api.mjs';
import { readPort } from './config.mjs';
const port = readPort(process.env.PORT);
const server = createApi();
server.on('error', (error) => {
console.error('Server could not start:', error.code);
process.exitCode = 1;
});
server.listen(port, '127.0.0.1', () => {
console.log(`Local API: http://127.0.0.1:${port}`);
});
let stopping = false;
function stop() {
if (stopping) return;
stopping = true;
const deadline = setTimeout(() => {
process.exitCode = 1;
server.closeAllConnections();
}, 2000);
deadline.unref();
server.close(() => clearTimeout(deadline));
}
process.on('SIGINT', stop);
process.on('SIGTERM', stop);Run node server.mjs in one terminal. In a second terminal, request the health and catalogue routes. On Windows, curl.exe selects the curl executable explicitly; elsewhere use curl if that is its command name. Keep the URL quoted so the shell does not interpret query punctuation. The catalogue response for level 3 and q=api has count 1 and the node record. A request with level=3x receives status 400 rather than an empty successful result.
node server.mjs
# In a second terminal on Windows:
curl.exe -i "http://127.0.0.1:3000/health"
curl.exe -i "http://127.0.0.1:3000/api/courses?level=3&q=api"
curl.exe -i "http://127.0.0.1:3000/api/courses?level=3x"Press Ctrl+C in the server terminal to request shutdown. The handler first stops accepting new connections and lets current work finish. After a two-second deadline it closes remaining HTTP connections and marks the exit unsuccessful. The second signal does not start another shutdown sequence. The timer is unreferenced so it cannot keep an otherwise finished process alive. This is bounded connection draining for a small lab, not evidence that external work was committed or that every signal behaves identically on every platform.
A blocking synchronous computation can prevent JavaScript callbacks and timers from running. Consequently, the shutdown timer and request timeout are not CPU time budgets for an arbitrary future selector. This fixture only scans three records. A larger service would need to revisit work bounds, cancellation, worker isolation, monitoring and deployment shutdown semantics instead of assuming that the same numbers make it safe.
Try it yourself · Activity 04
15 minWrite a startup and stop checklist
Prepare instructions another learner can follow.
- Record the expected Node version and all five filenames.
- Describe a valid PORT setting and two invalid settings.
- Explain how to recognise a bind error and how to stop the process.
Why is a printed listening message stronger evidence than merely running the command?
Worked answer
Record the actually installed Node version and .mjs filenames, run tests first, then start the server and wait for its listening message. For example PORT=3001 is valid, while an empty setting and 3e3 are rejected. A bind failure prints a code such as EADDRINUSE; choose an available port rather than killing an unrelated process. Ctrl+C initiates the documented drain sequence.
Read a sample · Chapter 05 of 06
Test results and failures through real requests
Tests turn the written contract into repeatable evidence. Use both direct function tests and HTTP requests: each observes a different boundary, and neither should be mistaken for proof about public hosting.
Save api.test.mjs exactly as shown. The first tests isolate selection and configuration. The HTTP test listens on port 0, allowing the operating system to choose a free ephemeral port. It waits for the listening event before sending requests and registers cleanup so the server closes when the test completes. The network traffic stays on localhost. Node built-ins supply assertions, the runner and fetch; the lab installs no test framework.
import test from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { createApi } from './api.mjs';
import { findCourses, InputError } from './catalogue.mjs';
import { readPort } from './config.mjs';
test('filters intersect without altering the catalogue', () => {
const items = findCourses(new URLSearchParams('level=3&q=API'));
assert.deepEqual(items.map((c) => c.id), ['node']);
items.pop();
assert.equal(findCourses(new URLSearchParams()).length, 3);
});
test('invalid and repeated fields fail explicitly', () => {
for (const query of ['level=3x', 'level=', 'level=1&level=3',
'admin=true', 'q=a&q=b', 'q=' + 'x'.repeat(81)]) {
assert.throws(() => findCourses(new URLSearchParams(query)),
InputError);
}
});
test('configuration accepts only the declared port grammar', () => {
assert.equal(readPort(undefined), 3000);
assert.equal(readPort('65535'), 65535);
for (const value of ['', '0', '-1', '3e3', '3000x', '65536']) {
assert.throws(() => readPort(value));
}
});
test('HTTP contract on an ephemeral loopback port', async (t) => {
const server = createApi();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
t.after(() => new Promise((resolve) => server.close(resolve)));
const base = `http://127.0.0.1:${server.address().port}`;
const response = await fetch(base + '/api/courses?level=3');
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type'), /application\/json/);
const text = await response.text();
assert.equal(Number(response.headers.get('content-length')),
Buffer.byteLength(text));
assert.equal(JSON.parse(text).count, 2);
const head = await fetch(base + '/api/courses?level=3', {
method: 'HEAD',
});
assert.equal(head.status, 200);
assert.equal(await head.text(), '');
const bad = await fetch(base + '/api/courses?level=3x');
assert.equal(bad.status, 400);
assert.equal((await bad.json()).error.code, 'invalid_query');
const post = await fetch(base + '/api/courses', { method: 'POST' });
assert.equal(post.status, 405);
assert.equal(post.headers.get('allow'), 'GET, HEAD');
await post.text();
});node --test api.test.mjs
Expected summary for the printed file:
tests 4
pass 4
fail 0Read each assertion as a claim. Status 200 alone does not establish the right body. A count check alone does not prove byte length or content type. The accented catalogue response tests the UTF-8 length boundary, while the HEAD request tests absence of a response body. The malformed query checks both its status and stable error code. The method test checks Allow as well as 405. Consuming response bodies and closing the server keeps the test lifecycle visible.
The printed tests cover representative cases; the release also ran a broader local matrix against the same files. Extend your own suite deliberately. Useful cases include unknown routes, unsupported health parameters, duplicate q fields, an eighty-one-code-unit query, a malformed request target and an injected unexpected selector failure. A server must remain able to answer health after a rejected request or a handled selector failure. Avoid relying only on the happy path.
A test can pass for the wrong reason. To check sensitivity, temporarily remove the duplicate-field rejection and rerun the printed tests. The repeated-field assertion should now fail. Restore the original before continuing. Change one condition at a time so the failure is attributable. This small mutation demonstrates that a specific behaviour is observed; it does not measure coverage of every route, timing interaction or real-world failure.
Separate correctness from performance and operations. These tests do not prove throughput, resilience under overload, TLS configuration, browser cross-origin access or an authentication policy. A green run is evidence about the stated inputs in the stated environment. Keep the command, runtime version and results together, and record skipped checks rather than turning absence of evidence into a success claim.
Try it yourself · Activity 05
15 minAdd one regression with a reason
Choose a failure absent from the printed HTTP test.
- Add a request with repeated q fields and expect 400 invalid_query.
- Verify that /health still succeeds after the rejection.
- Try a temporary duplicate-field mutation, observe a failed assertion, and restore the file.
Which defect would your test miss if it checked only that the connection completed?
Worked answer
The added request should receive 400 and the stable invalid_query code, then a separate health request should return 200 with status ok. Removing the duplicate check makes the repeated-field test fail because the selector accepts an ambiguous query. Restoring the check should return the suite to green. Keep the mutation out of the finished files.
Read a sample · Chapter 06 of 06
Make an honest handover and choose the next boundary
A usable handover states what was implemented, how it was checked and where its assurances end. Treat the small API as an inspectable starting point rather than a public service ready to expose.
Record the route table, query grammar, fixture source and response examples alongside the files. Include the exact runtime used, the startup and test commands, and the expected error behaviour. Explain that health is a liveness-style check for this process and route, not proof that a future database, model or dependency is ready. A service with dependencies may need separate readiness checks and a defined response when a dependency is unavailable.
For a future database-backed version, preserve the boundary between query interpretation and data access. Validate public fields before building a query, use the database interface correctly, and define deterministic ordering plus bounded result sizes. A query that returns three fixture records today can accidentally become an unbounded export after the data source changes. Pagination, permissions and cancellation need their own acceptance criteria; they cannot be inferred from this small selector.
Scroll sideways to see every column.
| Next change | New question | Useful evidence |
|---|---|---|
| Add a POST route | Who may write, and how large can input be? | Validation, permission and body-limit tests |
| Use a database | How are queries bounded and conflicts handled? | Integration and failure-recovery tests |
| Call a model | What bounds cost, latency and retries? | Timeout, cancellation and budget tests |
| Expose publicly | What handles TLS, access, abuse and operations? | Deployment-specific checks and monitoring |
| Add a browser client | Which origins and methods are actually needed? | Explicit cross-origin tests, not wildcard guessing |
The lab sends no CORS permission header and implements no authentication. Those are separate design decisions. A request being reachable from one process does not imply that every browser page may read it, and a cross-origin permission is not a substitute for access control. Keep development on loopback while deciding the actual client and data requirements. Do not add a blanket permissive header simply to silence a browser error without understanding the intended caller.
The connection-close policy simplifies test cleanup but may increase connection overhead compared with reuse. The two-second shutdown deadline and five-second receipt timeouts are teaching choices, not values measured against a service objective. Before changing them, name the failure you are trying to control and test the corresponding behaviour. A timeout on receiving a request does not automatically bound a downstream operation or interrupt synchronous CPU work.
Your final deliverable is five runnable files plus a short runbook and test evidence. Keep the original failing-input fixtures: they explain the contract more precisely than an unsupported claim that the API is robust. If you use an AI coding assistant to extend the service, ask it to preserve the acceptance tests and explain each new dependency. Review the generated changes and rerun the tests before treating the extension as complete.
Try it yourself · Activity 06
15 minWrite the release note
Summarise the implemented service for a teammate.
- State the implemented routes, accepted query fields and localhost binding.
- Record the actual runtime, commands and passed tests.
- List at least four capabilities that remain unimplemented.
Could another learner distinguish a tested fact from an intended future feature?
Worked answer
A sound note says that GET and HEAD serve a three-record fictional catalogue and a health route on 127.0.0.1; level and q are validated and errors are structured. It records the observed Node version and test command. It explicitly leaves out public hosting, authentication, writes, persistence, load testing and dependency readiness. It does not claim that the fixture service exposes Mickai internals.
Keep learning
The complete workbook
Create five small ES module files, run a service on loopback and exercise it with a built-in test runner. Separate catalogue rules from HTTP handling, reject ambiguous queries, distinguish client errors from server failures, and explain what the lab cannot establish about a production service.
- 01Specify the service before opening a portRead here · 1 exercise
An API is an agreement between callers and a service. Start with observable behaviour: accepted requests, response shapes and failures. A working port is only the beginning of that agreement.
- 02Keep catalogue rules separate from HTTPRead here · 1 exercise
The first file has no socket or response object. It takes parsed query fields and returns matching records, or raises an input error. This makes the rules straightforward to test before the server exists.
- 03Translate the contract into HTTP responsesRead here · 1 exercise
The transport layer converts an incoming request into a catalogue call and translates the result back into a response. Keep those translations explicit so errors remain understandable at the client boundary.
- 04Start locally with checked configurationRead here · 1 exercise
Creating a server and listening on a port are different steps. A startup file owns configuration and process lifecycle; the API module remains importable by tests without opening a permanent listener.
- 05Test results and failures through real requestsRead here · 1 exercise
Tests turn the written contract into repeatable evidence. Use both direct function tests and HTTP requests: each observes a different boundary, and neither should be mistaken for proof about public hosting.
- 06Make an honest handover and choose the next boundaryRead here · 1 exercise
A usable handover states what was implemented, how it was checked and where its assurances end. Treat the small API as an inspectable starting point rather than a public service ready to expose.
Also inside: a 8-point checklist, a glossary of 8 terms and 10 questions and answers to test yourself. 6 hands-on exercises, each with a worked answer at the back where the workbook gives one.
No login, no card, no account. Before the download we ask you to follow Mickai (two quick links). Free to download and use for personal learning, study groups and inside your own team. Please do not resell the workbooks or republish them as your own. Link people to trust-agent.ai instead.
Test yourself
Questions and answers
Does the lab expose the real course catalogue?
No. It serves three invented teaching records from a local fixture.
Why reject level=3x instead of calling parseInt?
The contract accepts one digit from 1 to 4. Prefix parsing would silently accept text outside that grammar.
Why reject a repeated parameter?
This contract requires at most one value per field; it avoids choosing an undocumented first or last interpretation.
Does CAFE match Café?
No. Matching ignores case but does not remove accents or perform fuzzy search.
Why use Buffer.byteLength?
Content-Length describes encoded bytes, not JavaScript UTF-16 code units. Non-ASCII text makes that difference visible.
Does HEAD send the JSON body?
No. The helper computes representation headers but ends the HEAD response without sending its body.
Does every malformed HTTP request receive the JSON error format?
No. The underlying HTTP parser can reject traffic before the application handler runs.
Does the shutdown timer interrupt CPU-heavy JavaScript?
No. A blocked event loop cannot run the timer callback; work bounds and isolation require separate design.
Why listen on port zero in tests?
It lets the operating system select an available port and reduces clashes between local test runs.
Does a passing test suite make this production-ready?
No. The checked fixture behaviour does not establish public hosting, access control, persistence, load tolerance or operational readiness.
When you have finished
Get your certificate of completion
Type your name and download a certificate for this workbook as a PDF, ready to print or to add to LinkedIn. It is made on your own device, so your name is never sent to us. It is a self-declared certificate, not an accredited qualification.
Learn the language
Key terms
- Route
- A mapping from a request path and method to behaviour.
- Query parameter
- A named value carried after the question mark in a URL.
- Contract
- The inputs, outputs and failure behaviour a caller may rely on.
- Loopback
- A local network interface used to communicate within the same machine.
- Ephemeral port
- A port selected by the operating system when the server listens on port zero.
- UTF-8 byte length
- The number of bytes used to encode text as UTF-8, which can differ from string length.
6 of the workbook's 8 terms. The complete glossary is in the workbook.
Follow the evidence
Sources and checks
Facts last checked: .
Examples in this workbook were run on: Node.js 24.15.0 on Windows 11, built-in modules only. Four printed learner tests and 149 targeted checks; HTTP requests used ephemeral localhost ports. No database, model call or public deployment. (2026-09-27).
These workbooks use AI assistance. See how the workbooks are made.
- HTTP server and response APIsNode.js documentation
- Built-in test runnerNode.js documentation
- URL and URLSearchParams, Node.js 24.15.0Node.js documentation
- Process environment and lifecycleNode.js documentation
Created by Mickarle Wagstaff-Irons - Micky Irons with the Mickai team. Published by Mickai LTD. Last updated 27 September 2026.
NextKeep going
Where to go next
Recommended for you
Git for AI builders: commits, branches and pull requests
Learn Git from your first commit to a reviewed pull request, with safe habits for AI-written code: read every diff, commit small, undo safely and keep secrets out.
Recommended for you
Design a repeatable evaluation suite for an AI application
Build an offline AI evaluation harness with test cases, a rubric, regression comparisons, uncertainty checks and a reproducible release record.