Koa Request Observatory

Build a Koa API that reveals middleware flow with logs, timing, and JSON errors.

Introduction

30 Second Summary

A result can look correct while the work behind it stays invisible. When something goes wrong, that hidden journey makes the problem harder to explain.

In this project, you will build a local user API with Koa that exposes how each request moves through async middleware. Its responses pair with terminal logs to make the full request journey visible.

What You'll Build

A single endpoint call produces a JSON response while the terminal reveals the request entering each middleware layer before returning in reverse order.

By the end of this project, you'll have:

  • A local user API with a visible health response. You can look up Raju by ID. You can create another user from a JSON request.
  • A middleware observatory that prints the downstream request path. It prints the upstream return path in reverse order. The response shows its timing through the X-Response-Time header.
  • A consistent JSON error contract that covers invalid input. Missing users receive structured responses. Unsupported methods use the same response format. Unknown routes use it too.
  • Secret Mission: Add a diagnostic endpoint that returns its own middleware trace.

Are there any prerequisites?

The setup checks for Node.js 20 or newer on your Mac. Basic JavaScript syntax plus Visual Studio Code are all you need to begin.

Before We Start

Before any hands-on work, lock in the purpose of your local Koa Request Observatory. This first version keeps users in an in-memory users array so the lesson stays focused on downstream and upstream middleware flow.

Prepare the Local Koa Project

Your Koa Request Observatory depends on a compatible local runtime. Version mismatches can hide the middleware concepts beneath setup errors.

This step confirms the tools already available on your Mac. It also pins every project dependency to an exact version.

In this step, get ready to:
  • Verify the local toolchain.
  • Create the project manifest.
  • Install the pinned dependencies.
Verify your local tools

Node.js runs your API. The router package requires Node.js v20 or newer.

The npm command installs your JavaScript packages. The curl command sends requests to the API you build later.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal into Spotlight.
  • Press Enter to open macOS Terminal.
  • Check all three tools by running these commands:
node --version
npm --version
curl --version

What Do These Checks Prove?

  • The first command prints the installed Node.js version.
  • The second command confirms that npm is available.
  • The third command confirms that curl can send local HTTP requests.

Your Node.js result determines which setup path applies to your Mac.

  • Choose the tab that matches the result from the first command.

✔️ Node.js Meets the Requirement

Your runtime clears the project requirement. You are ready to create the project files.

  • Confirm that the Node.js result starts with v20 or a higher major version.
  • Confirm that npm printed a version number.
  • Confirm that curl printed its version details.

ⓧ I See an Older Version

The installed Node.js version cannot run the pinned router package. Updating Node.js gives the whole project a compatible runtime.

  • Visit the official Node.js download page.
  • Download Node.js v24.21.0 LTS for macOS.
  • Run the downloaded installer.
  • Follow the installer prompts to complete the update.
  • Quit macOS Terminal after the installer finishes.
  • Return to Terminal through Spotlight.
  • Check the updated toolchain by running these commands:
node --version
npm --version
curl --version

What Should You See?

Node.js should now print v24.21.0 or another compatible version. The other two commands should also print version details.

ⓧ Command Not Found

Terminal cannot currently find the Node.js executable. Installing the current LTS release adds the runtime needed by this project.

  • Visit the official Node.js download page.
  • Download Node.js v24.21.0 LTS for macOS.
  • Run the downloaded installer.
  • Follow the installer prompts to complete the installation.
  • Quit macOS Terminal after the installer finishes.
  • Return to Terminal through Spotlight.
  • Check the installed toolchain by running these commands:
node --version
npm --version
curl --version

What Should You See?

Node.js should print v24.21.0 or another compatible version. The other two commands should also print version details.

Missing npm or curl?

Help me diagnose a missing local tool before I continue.

Create the project manifest

A package manifest gives the project its name. It also records the runtime requirement plus the dependency versions.

  • Switch back to macOS Terminal.
  • Create the koa-request-observatory folder on your Desktop by running these commands:
cd ~/Desktop
mkdir koa-request-observatory
ls
cd koa-request-observatory

What Do These Commands Do?

  • The first command moves Terminal to your Desktop.
  • The second command creates the koa-request-observatory folder.
  • The ls command lists the Desktop contents so you can confirm the folder exists.
  • The final command moves Terminal into the new folder.

You should see koa-request-observatory in the Desktop listing. That folder now has a fixed location for every project file.

Visual Studio Code treats an opened folder as a workspace. Its Explorer keeps the manifest visible while you build the API.

  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code into Spotlight.
  • Press Enter to open Visual Studio Code.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the koa-request-observatory folder on your Desktop.
  • Click Open.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

The Explorer sidebar should now show koa-request-observatory as the open folder.

  • Select the new-file icon in the Explorer sidebar.
  • Enter package.json as the file name.
  • Press Enter to create the file.
  • Define the project manifest by copying this code into package.json:
{
  "name": "koa-request-observatory",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=20"
  },
  "dependencies": {
    "@koa/bodyparser": "6.1.0",
    "@koa/router": "15.7.0",
    "koa": "3.2.1"
  }
}

What Does This Manifest Define?

  • The name field identifies the local project.
  • The type field makes JavaScript files load as ES modules.
  • The engines field records Node.js 20 as the minimum major version.
  • The dependencies object pins the Koa packages to exact versions.
  • Save package.json by pressing Cmd+S.
  • Check the Explorer sidebar for the saved package.json file.

The saved file should appear directly inside koa-request-observatory.

Seeing a JSON Error?

  • Check that every property name uses double quotes.
  • Check that each property except the final one in an object ends with a comma.
  • Confirm that the file is named package.json.

Help me compare my package manifest with the required JSON structure.

✔️ Awesome, I've got everything!

Your manifest is saved with the project name, runtime requirement, module setting, and pinned dependencies.

ⓧ I'd like to double check the full code

Compare your entire package.json file with this reference.

{
  "name": "koa-request-observatory",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=20"
  },
  "dependencies": {
    "@koa/bodyparser": "6.1.0",
    "@koa/router": "15.7.0",
    "koa": "3.2.1"
  }
}

What Should Match?

Every key plus every value should match this reference exactly. Exact dependency versions keep the project environment consistent.

Install and verify dependencies

The manifest now lists every package the API needs. Installing it places those dependencies in the local node_modules folder.

The installation also creates package-lock.json. That lock file records the resolved dependency tree for future installs.

  • Switch back to macOS Terminal.
  • Install the project dependencies by running:
npm install

What Does This Command Do?

  • The command reads the dependency list from package.json.
  • It downloads the pinned packages into node_modules.
  • It records the resolved dependency tree in package-lock.json.
  • Wait for the Terminal prompt to return.
  • Check the Visual Studio Code Explorer for node_modules.
  • Check the Explorer for package-lock.json.

The two new entries confirm that npm installed the dependency tree for this project.

Did the Installation Fail?

  • Confirm that Terminal is inside the koa-request-observatory folder.
  • Confirm that package.json is saved before repeating the installation.
  • Compare the dependency names plus versions with the full-code reference above.

Help me troubleshoot why npm cannot install the pinned Koa dependencies.

Before you run the final check, consider whether npm will report the exact versions from your manifest.

  • Verify the complete local environment by running these commands:
node --version
npm --version
curl --version
npm ls koa @koa/router @koa/bodyparser

What Does the Final Check Prove?

  • Node.js confirms that the runtime meets the project requirement.
  • The next two results confirm that npm plus curl are available.
  • The final result prints the installed versions of the three direct Koa dependencies.

You should see Node.js v20 or newer. The dependency tree should list koa@3.2.1, @koa/router@15.7.0, plus @koa/bodyparser@6.1.0.

Your local Koa environment is ready. Every package now matches the version used throughout the project.

Your runtime is verified plus your dependencies are pinned. Next, you will turn this empty workspace into a running in-memory user API.

Build the In-Memory User API

The pinned packages from the previous step give you a stable Koa base. Now you can turn that base into a local API that answers real requests.

A working API gives you an early result to test. An unmatched URL will also reveal the response gap that remains.

In this step, get ready to:
  • Create the Koa server foundation with an in-memory user.
  • Implement health, user lookup, and user creation routes.
  • Compare successful JSON responses with the unmatched-route response.
Create the server foundation

A Koa application starts with an application instance plus a router. The users array keeps data in process memory until the server stops.

  • In the Visual Studio Code window from earlier, use the new-file control in the Explorer sidebar to create server.js beside package.json.
  • Build the server foundation by pasting this code into server.js:
import Koa from 'koa';
import Router from '@koa/router';
import { bodyParser } from '@koa/bodyparser';

const app = new Koa();
const router = new Router();
const port = 3000;

const users = [{ id: 1, name: 'Raju' }];

What Does This Foundation Do?

  • The imports bring the Koa application, router, and body parser into this ES module.
  • The app instance owns the middleware stack.
  • The router instance connects request paths to handlers.
  • The users array begins with Raju at ID 1.
  • Save server.js.
  • Confirm the Explorer sidebar lists server.js beside package.json.

Cannot Find server.js?

Check that the file sits directly inside koa-request-observatory. A nested copy will not share the nearby package.json configuration.

Ask for help checking the file location.

Build and test the routes

Routing matches an HTTP method plus a path to one handler. Each handler receives a Koa Context that carries request data and the response body.

  • In server.js, place your cursor on the empty line below the users array.
  • Create the first runnable server slice by pasting this code:
app.use(bodyParser());

router.get('/health', (ctx) => {
  ctx.body = { status: 'ok' };
});

app.use(router.routes());
app.use(router.allowedMethods({ throw: true }));

app.listen(port);
console.log(`Koa Request Observatory listening on http://localhost:${port}`);

How Does the First Route Work?

  • The bodyParser() middleware prepares supported request bodies for later routes.
  • The GET /health handler assigns an object to ctx.body. Koa serializes that object as JSON.
  • The router middleware dispatches matching routes.
  • The server listens on port 3000.
  • Save server.js.
  • In the macOS Terminal from earlier, start the API by running this command:
node server.js

What Does This Command Do?

Node.js loads server.js as an ES module. The process stays active so Koa can accept requests on port 3000.

You will see Koa Request Observatory listening on http://localhost:3000. The Terminal tab remains occupied while the server runs.

Server Not Starting?

  • Confirm the Terminal is inside the koa-request-observatory folder.
  • Check that server.js sits beside the existing package.json file.
  • Stop any other local process using port 3000.

Help me diagnose why the Koa server does not start.

  • Create a second Terminal tab for request commands.
  • Call the health route from the second tab by running this command:
curl -i http://localhost:3000/health

What Does the Health Request Show?

The -i flag includes response headers. You should see status 200 plus {"status":"ok"} in the response body.

Your first Koa route is live. You now have a concrete response to build on.

  • Return to the Terminal tab running the server.
  • Press Ctrl+C to stop the server before editing it.
  • In server.js, find app.use(router.routes()).
  • Insert the user lookup route directly above that line by pasting this code:
router.get('/users/:id', (ctx) => {
  const id = Number(ctx.params.id);

  if (!Number.isInteger(id)) {
    ctx.throw(400, 'User ID must be an integer');
  }

  const user = users.find((candidate) => candidate.id === id);

  if (!user) {
    ctx.throw(404, 'User not found');
  }

  ctx.body = user;
});

How Does User Lookup Work?

  • The :id route parameter becomes available through ctx.params.id.
  • The handler converts the parameter to a number before checking the array.
  • The find() call returns the user whose ID matches the request.
  • The handler throws a status 404 error when no user matches.
  • Save server.js.
  • Restart the server in its Terminal tab by running this command:
node server.js

Why Restart the Server?

The running process loaded the earlier version of server.js. Restarting loads the new lookup route into the router.

  • Call the user lookup route from the request Terminal tab by running this command:
curl -i http://localhost:3000/users/1

What Does the Lookup Prove?

You should see status 200 plus a JSON object containing ID 1 and the name Raju. The route parameter now selects data from the in-memory array.

  • Return to the Terminal tab running the server.
  • Press Ctrl+C to stop the server before the next edit.
  • In server.js, find app.use(router.routes()) again.
  • Insert the user creation route directly above that line by pasting this code:
router.post('/users', (ctx) => {
  const name = ctx.request.body?.name;

  if (typeof name !== 'string' || !name.trim()) {
    ctx.throw(400, 'Name is required');
  }

  const user = {
    id: users[users.length - 1].id + 1,
    name: name.trim()
  };

  users.push(user);
  ctx.status = 201;
  ctx.body = user;
});

How Does User Creation Work?

  • The body parser exposes submitted JSON through ctx.request.body.
  • The validation rejects a missing or empty name value.
  • The new user receives the next numeric ID.
  • The handler stores the user in memory before returning status 201.
  • Save server.js.
  • Restart the server in its Terminal tab by running this command:
node server.js

What Does This Restart Load?

The new process loads all three routes. It also recreates the users array with Raju as its first record.

  • Create a second in-memory user from the request Terminal tab by running this command:
curl -i -H "Content-Type: application/json" -d '{"name":"Raju"}' http://localhost:3000/users

What Does the POST Request Prove?

The header identifies the submitted data as JSON. The request body supplies the name field that the route reads.

You should see status 201 plus {"id":2,"name":"Raju"}. The second user now remains available until this server process stops.

POST Request Rejected?

  • Confirm the header contains Content-Type: application/json.
  • Check that the submitted object contains a non-empty name property.
  • Confirm app.use(bodyParser()) appears before the routes.

Help me debug the rejected JSON request.

Your completed server.js should now match one of these outcomes.

✔️ Awesome, I've got everything

  • Keep the server running in its Terminal tab.
  • Continue to the final response checks.

ⓧ I'd like to double check the full code

import Koa from 'koa';
import Router from '@koa/router';
import { bodyParser } from '@koa/bodyparser';

const app = new Koa();
const router = new Router();
const port = 3000;

const users = [{ id: 1, name: 'Raju' }];

app.use(bodyParser());

router.get('/health', (ctx) => {
  ctx.body = { status: 'ok' };
});

router.get('/users/:id', (ctx) => {
  const id = Number(ctx.params.id);

  if (!Number.isInteger(id)) {
    ctx.throw(400, 'User ID must be an integer');
  }

  const user = users.find((candidate) => candidate.id === id);

  if (!user) {
    ctx.throw(404, 'User not found');
  }

  ctx.body = user;
});

router.post('/users', (ctx) => {
  const name = ctx.request.body?.name;

  if (typeof name !== 'string' || !name.trim()) {
    ctx.throw(400, 'Name is required');
  }

  const user = {
    id: users[users.length - 1].id + 1,
    name: name.trim()
  };

  users.push(user);
  ctx.status = 201;
  ctx.body = user;
});

app.use(router.routes());
app.use(router.allowedMethods({ throw: true }));

app.listen(port);
console.log(`Koa Request Observatory listening on http://localhost:${port}`);
Probe the unfinished edge

The successful routes prove that request dispatch works. One unresolved case is the response produced when no route matches.

Before you run these checks, do you think all three responses will use the same JSON shape?

  • Compare the health, user lookup, and unmatched-route responses from the request Terminal tab by running these commands:
curl -i http://localhost:3000/health
curl -i http://localhost:3000/users/1
curl -i http://localhost:3000/unknown

What Do the Checks Reveal?

The health route returns its status object. The user route returns Raju as JSON.

The unmatched URL returns status 404 with the plain Not Found response. This visible mismatch is the intended shortfall in the current API.

Requests Not Reaching the API?

  • Confirm the server Terminal tab still displays the running process.
  • Restart the server if the process stopped after an earlier edit.
  • Check that every request targets port 3000.

Help me troubleshoot the local Koa requests.

You now have a working Koa API with health, lookup, and creation routes. Next, you will make the middleware journey visible in the Terminal.

Expose the Middleware Cascade

Your in-memory Koa API now returns JSON from each user route. The terminal still cannot show the middleware cascade behind those responses.

A logger will expose the request's path through the stack. A timer will attach the elapsed duration to the response.

In this step, get ready to:
  • Add logger middleware around await next().
  • Add timer middleware that measures the request lifecycle.
  • Trace a complete request through both middleware layers.
Add logger middleware

Koa runs middleware in registration order. await next() passes control to the middleware registered after the current function.

When downstream work finishes, the current function resumes after await next(). The logger uses both positions to reveal the request's full journey.

  • Stop the running server by pressing Control-C in its Terminal window.
  • In Visual Studio Code, switch back to server.js from earlier.
  • Find the bodyParser() registration below the users array.
  • Paste the logger middleware directly above that registration by adding this code:
app.use(async (ctx, next) => {
  console.log(`[logger] down ${ctx.method} ${ctx.path}`);

  try {
    await next();
    console.log(`[logger] up ${ctx.status} ${ctx.method} ${ctx.path}`);
  } catch (error) {
    console.log(
      `[logger] up error ${error.status || 500} ${ctx.method} ${ctx.path}`
    );
    throw error;
  }
});

What does this middleware do?

  • The first console.log() records the request before downstream middleware runs.
  • The await next() expression hands control to the rest of the stack.
  • The second log records the response status when control returns upstream.
  • The catch branch records a thrown error before passing it back upstream.

The server process keeps its Terminal window busy while it listens for requests. You can send requests from the Terminal window you used for curl earlier.

  • Save server.js.
  • Restart the API in the server Terminal window by running this command:
node server.js

What starts here?

This starts server.js with Node.js. You should see the listening message for http://localhost:3000.

  • Return to the Terminal window you used for curl earlier.

Before you send the request, where do you expect the logger to resume after the route creates its response?

  • Call the user lookup route by running this command:
curl -i http://localhost:3000/users/1

What does this request show?

The -i flag includes response headers in the output. The request targets the existing /users/1 route.

Your curl Terminal shows Raju's JSON response. The server Terminal shows [logger] down before [logger] up.

You have just made one middleware layer reveal both halves of its work.

Missing the logger lines?

  • Confirm that the logger middleware appears above the bodyParser() registration.
  • Confirm that you saved server.js before restarting the server.
  • Look for the logger output in the Terminal window running the server.

Help me fix missing Koa logger output after adding middleware to server.js.

Add timer middleware

The timer starts before downstream work begins. Its finally block measures the return journey after the route responds.

Using finally ensures the timer completes when downstream middleware throws. That keeps X-Response-Time attached to the response path.

  • In Visual Studio Code, return to server.js.
  • Find the bodyParser() registration beneath the logger middleware.
  • Place your cursor directly above the body parser registration.
  • Paste this timer middleware below the logger middleware:
app.use(async (ctx, next) => {
  const start = Date.now();
  console.log(`[timer] down ${ctx.method} ${ctx.path}`);

  try {
    await next();
  } finally {
    const duration = Date.now() - start;
    ctx.set('X-Response-Time', `${duration}ms`);
    console.log(`[timer] up ${duration}ms ${ctx.method} ${ctx.path}`);
  }
});

How does the timer work?

  • The start value records the time when the request enters the timer.
  • The await next() expression pauses the timer while downstream middleware handles the request.
  • The duration value measures the elapsed time when control returns.
  • The ctx.set() call adds the elapsed duration to the X-Response-Time response header.
  • Save server.js.

✔️ Awesome, I've got everything!

  • Confirm that server.js is saved.

ⓧ I'd like to double check the full code

  • Compare your server.js file with this reference:
import Koa from 'koa';
import Router from '@koa/router';
import { bodyParser } from '@koa/bodyparser';

const app = new Koa();
const router = new Router();
const port = 3000;

const users = [{ id: 1, name: 'Raju' }];

app.use(async (ctx, next) => {
  console.log(`[logger] down ${ctx.method} ${ctx.path}`);

  try {
    await next();
    console.log(`[logger] up ${ctx.status} ${ctx.method} ${ctx.path}`);
  } catch (error) {
    console.log(
      `[logger] up error ${error.status || 500} ${ctx.method} ${ctx.path}`
    );
    throw error;
  }
});

app.use(async (ctx, next) => {
  const start = Date.now();
  console.log(`[timer] down ${ctx.method} ${ctx.path}`);

  try {
    await next();
  } finally {
    const duration = Date.now() - start;
    ctx.set('X-Response-Time', `${duration}ms`);
    console.log(`[timer] up ${duration}ms ${ctx.method} ${ctx.path}`);
  }
});

app.use(bodyParser());

router.get('/health', (ctx) => {
  ctx.body = { status: 'ok' };
});

router.get('/users/:id', (ctx) => {
  const id = Number(ctx.params.id);

  if (!Number.isInteger(id)) {
    ctx.throw(400, 'User ID must be an integer');
  }

  const user = users.find((candidate) => candidate.id === id);

  if (!user) {
    ctx.throw(404, 'User not found');
  }

  ctx.body = user;
});

router.post('/users', (ctx) => {
  const name = ctx.request.body?.name;

  if (typeof name !== 'string' || !name.trim()) {
    ctx.throw(400, 'Name is required');
  }

  const user = {
    id: users[users.length - 1].id + 1,
    name: name.trim()
  };

  users.push(user);
  ctx.status = 201;
  ctx.body = user;
});

app.use(router.routes());
app.use(router.allowedMethods({ throw: true }));

app.listen(port);
console.log(`Koa Request Observatory listening on http://localhost:${port}`);

What should match?

The logger appears before the timer. Both middleware functions appear before bodyParser() and the router registrations.

Trace one complete request

Both middleware functions now wrap the same route. This final test shows how registration order controls the request's return path.

  • Stop the existing server by pressing Control-C in its Terminal window.
  • Restart the API with the saved timer middleware by running this command:
node server.js

Why restart the server?

Restarting loads the saved logger and timer middleware into the running Node.js process. The API begins listening again at http://localhost:3000.

  • Return to the Terminal window you used for curl earlier.

Before you send the request, which middleware do you expect to finish first after the route sets the response?

  • Trace the user lookup request by running this command:
curl -i http://localhost:3000/users/1

What does the final check reveal?

The request exercises the logger and timer around the existing user route. Including the response headers exposes the timing value set during the upstream journey.

Your curl output includes X-Response-Time with a duration measured in milliseconds. It also includes Raju's JSON response.

The first server log line contains [logger] down. The second contains [timer] down.

The third server log line contains [timer] up. The fourth contains [logger] up.

Why does the order reverse?

The logger enters first because it was registered first. It pauses while the timer passes the request to the route.

The timer sits closest to the route. It resumes first as the response travels upstream.

The logger resumes last because its paused call wrapped every downstream layer. That nesting creates Koa's cascade.

Missing the header or expected order?

  • Confirm that the timer middleware appears below the logger middleware.
  • Confirm that both middleware functions appear above bodyParser().
  • Save server.js before restarting the API.

Help me debug a missing X-Response-Time header or incorrect Koa middleware log order.

You have made Koa's downstream and upstream cascade visible through logs and response timing. Next, you will make failed requests follow the same observable JSON contract.

Add Centralized Errors and JSON 404s

Your API now exposes Koa's middleware cascade in Terminal. However, an unknown path still returns the plain Not Found response.

Centralized error middleware gives thrown failures one JSON contract. A final fallback gives unmatched paths the same treatment.

In this step, get ready to:
  • Wrap the complete middleware stack with centralized error handling.
  • Return a JSON response for every unmatched path.
  • Prove the consistent response contract across every request outcome.
Wrap the stack with error middleware

An outer middleware can catch errors only from code reached through its await next(). Placing it before the logger makes the complete downstream stack part of one error boundary.

The boundary returns messages from expected client errors. It replaces unexpected server details with Internal Server Error.

  • Stop the running API in its Terminal window by pressing Control-C.
  • Switch back to server.js in Visual Studio Code.
  • Find the logger middleware containing [logger] down.
  • Paste this error middleware directly above the logger middleware:
app.use(async (ctx, next) => {
  console.log(`[error] down ${ctx.method} ${ctx.path}`);

  try {
    await next();
    console.log(`[error] up ${ctx.status} ${ctx.method} ${ctx.path}`);
  } catch (error) {
    const status = Number.isInteger(error.status) ? error.status : 500;

    ctx.status = status;
    ctx.body = {
      error: status >= 500 ? 'Internal Server Error' : error.message
    };

    console.log(`[error] caught ${status} ${ctx.method} ${ctx.path}`);
  }
});

What does this middleware do?

  • The opening log records when a request enters the outer error boundary.
  • The await next() line lets the request continue through every downstream middleware function.
  • The catch block uses the thrown error's status when it contains an integer.
  • The fallback status 500 protects the response from exposing unexpected server details.
  • Save server.js.
  • Restart the API in its Terminal window by running this command:
node server.js

What does this command do?

The node server.js command starts the updated API. The listener message confirms that the server is ready for requests.

You should see Koa Request Observatory listening on http://localhost:3000 in the server Terminal.

Server not restarting?

  • Confirm that the previous server process stopped before you run the start command again.
  • Check the new error middleware for missing braces if Terminal reports a syntax problem.
  • Verify that the error middleware sits above the existing logger middleware.

Help me diagnose why my Koa server does not restart after I added the outer error middleware.

Before you send this request, do you expect the missing-user failure to return plain text or JSON?

  • Test the error boundary from the request Terminal you used earlier by running:
curl -i http://localhost:3000/users/999

What does this request check?

This request reaches the existing user route with an ID that is absent from memory. The -i flag includes the HTTP response headers.

You should see status 404 with a JSON error containing User not found. The response should still include X-Response-Time.

That is the error boundary doing its job. A thrown route error now follows the API's JSON contract.

How does the error unwind?

  • The server Terminal starts with [error] down.
  • The timer prints [timer] up because its finally block runs during the error.
  • The logger prints [logger] up error before it throws the error upstream.
  • The outer boundary finishes with [error] caught.

Still seeing a plain error?

  • Move the error middleware above the logger so it wraps every downstream function.
  • Confirm that the logger still throws the caught error upstream.
  • Check that the error middleware assigns both ctx.status and ctx.body.

Help me troubleshoot why my missing-user response does not use the JSON error contract.

Return JSON for unmatched paths

An unmatched path completes the router without throwing an error. A final app-level middleware handles that remaining request after route dispatch finishes.

The method-handling middleware stays before the fallback. This keeps unsupported methods inside the centralized error path.

  • In server.js, find app.use(router.routes());.
  • Confirm that app.use(router.allowedMethods({ throw: true })); sits immediately below it.
  • Paste this fallback directly below the method-handling middleware:
app.use((ctx) => {
  ctx.status = 404;
  ctx.body = {
    error: 'Route not found',
    path: ctx.path
  };
});

What does this fallback do?

  • The fallback runs only when no earlier route assigns the final response.
  • The ctx.status assignment makes the unmatched-path status explicit.
  • The response body identifies the failure with Route not found.
  • The ctx.path value shows which path failed to match.
  • Save server.js.
  • Stop the running API in its Terminal window by pressing Control-C.
  • Restart the complete API by running this command:
node server.js

What does this command do now?

The command starts the API with the error boundary and fallback in their final positions. The listener message confirms that the updated stack is accepting requests.

Before you call the unknown path again, do you think the request will reach the error middleware's catch block or complete through the fallback?

  • Test the unmatched-path fallback from the request Terminal by running:
curl -i http://localhost:3000/unknown

What does this request prove?

The request has no matching route. It reaches the final fallback without throwing.

The response should use status 404. Its JSON body should contain the error and requested path.

You should also see the X-Response-Time header. The timer still wraps the fallback response.

Unknown path still returning plain text?

  • Place the fallback after both router middleware registrations.
  • Restart the server after saving server.js.
  • Confirm that the fallback assigns an object to ctx.body.

Help me find why my unknown Koa route still returns a plain response.

✔️ Awesome, I've got everything!

Your complete JSON error contract is now saved in server.js.

ⓧ I'd like to double check the full code

Use this complete file as a reference while you compare your middleware order and fallback placement.

import Koa from 'koa';
import Router from '@koa/router';
import { bodyParser } from '@koa/bodyparser';

const app = new Koa();
const router = new Router();
const port = 3000;

const users = [{ id: 1, name: 'Raju' }];

app.use(async (ctx, next) => {
  console.log(`[error] down ${ctx.method} ${ctx.path}`);

  try {
    await next();
    console.log(`[error] up ${ctx.status} ${ctx.method} ${ctx.path}`);
  } catch (error) {
    const status = Number.isInteger(error.status) ? error.status : 500;

    ctx.status = status;
    ctx.body = {
      error: status >= 500 ? 'Internal Server Error' : error.message
    };

    console.log(`[error] caught ${status} ${ctx.method} ${ctx.path}`);
  }
});

app.use(async (ctx, next) => {
  console.log(`[logger] down ${ctx.method} ${ctx.path}`);

  try {
    await next();
    console.log(`[logger] up ${ctx.status} ${ctx.method} ${ctx.path}`);
  } catch (error) {
    console.log(
      `[logger] up error ${error.status || 500} ${ctx.method} ${ctx.path}`
    );
    throw error;
  }
});

app.use(async (ctx, next) => {
  const start = Date.now();
  console.log(`[timer] down ${ctx.method} ${ctx.path}`);

  try {
    await next();
  } finally {
    const duration = Date.now() - start;
    ctx.set('X-Response-Time', `${duration}ms`);
    console.log(`[timer] up ${duration}ms ${ctx.method} ${ctx.path}`);
  }
});

app.use(bodyParser());

router.get('/health', (ctx) => {
  ctx.body = { status: 'ok' };
});

router.get('/users/:id', (ctx) => {
  const id = Number(ctx.params.id);

  if (!Number.isInteger(id)) {
    ctx.throw(400, 'User ID must be an integer');
  }

  const user = users.find((candidate) => candidate.id === id);

  if (!user) {
    ctx.throw(404, 'User not found');
  }

  ctx.body = user;
});

router.post('/users', (ctx) => {
  const name = ctx.request.body?.name;

  if (typeof name !== 'string' || !name.trim()) {
    ctx.throw(400, 'Name is required');
  }

  const user = {
    id: users[users.length - 1].id + 1,
    name: name.trim()
  };

  users.push(user);
  ctx.status = 201;
  ctx.body = user;
});

app.use(router.routes());
app.use(router.allowedMethods({ throw: true }));

app.use((ctx) => {
  ctx.status = 404;
  ctx.body = {
    error: 'Route not found',
    path: ctx.path
  };
});

app.listen(port);
console.log(`Koa Request Observatory listening on http://localhost:${port}`);
Test every response path

One request matrix can now exercise each branch of the response contract. The results show which failures throw into the error boundary and which request reaches the fallback.

Before you run the matrix, which requests do you think will throw into the error middleware? Which request will complete through the final fallback?

  • Run the complete response matrix from the request Terminal using these commands:
curl -i http://localhost:3000/health
curl -i http://localhost:3000/users/not-a-number
curl -i http://localhost:3000/users/999
curl -i -X POST http://localhost:3000/health
curl -i http://localhost:3000/unknown

What should you see?

  • The health request returns status 200 with its JSON status body.
  • The invalid user ID returns status 400 with User ID must be an integer.
  • The missing user returns status 404 with User not found.
  • The unsupported health method returns status 405 with a JSON error body.
  • The unknown path returns status 404 with Route not found.
  • Every response includes X-Response-Time.

The thrown failures skip the logger's ordinary upstream line. The timer still adds its header from the finally block.

You have completed the Koa Request Observatory. Its success paths and failure paths now share a consistent JSON contract.

Seeing an unexpected status or response?

  • Check that the error middleware appears before the logger in server.js.
  • Check that router.allowedMethods({ throw: true }) appears after router.routes().
  • Check that the JSON fallback appears after both router middleware functions.

Help me debug which part of my Koa response contract is producing the wrong status or body.

Secret mission

Trace the Cascade in the Response

Extend the observatory so each request records its complete middleware journey in a shared trace. A new GET /cascade endpoint returns the exact downstream and upstream order in its response.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep the local API running, pause its Node.js process, or delete its files. The Koa Request Observatory runs entirely on your Mac, so there are no cloud resources or ongoing costs.

Resources you used:

  • The Node.js server process currently serving the API at http://localhost:3000 through macOS Terminal.
  • The local koa-request-observatory folder containing package.json, server.js, and the installed project dependencies.

Keep everything running

No cleanup is needed. Choose this option if you want to keep testing the API or experimenting with the cascade trace.

  • Leave the Node.js server process running in macOS Terminal.
  • Keep the koa-request-observatory folder on your Mac.

Your endpoints stay available at http://localhost:3000 while the Terminal process remains active.

Pause - I'll come back to this later

The API occupies its local port while the Node.js process runs. Stopping that process preserves your project files for another session.

  • Return to the macOS Terminal window from earlier.
  • Stop the running server by pressing Ctrl+C.

You will return to your shell prompt. The local API stops accepting requests until you start it again.

  • Restart the API when you return by running this command from the koa-request-observatory folder:
node server.js

What does this command do?

Node.js loads server.js and starts the local Koa server. The process keeps running so it can respond to requests.

You will see Koa Request Observatory listening on http://localhost:3000 when the API is ready again.

Server Does Not Restart?

Confirm that Terminal is inside the koa-request-observatory folder. A process already using the local port can also prevent the server from starting.

Ask for help diagnosing the restart with Help me restart my local Koa API safely..

Delete - I don't want to use this again

Deleting the folder is permanent. Your API code disappears with the folder. Its installed project dependencies disappear too.

  • Copy the koa-request-observatory folder to a safe location if you want a backup.
  • Return to the macOS Terminal window from earlier.
  • Stop the running server by pressing Ctrl+C.

You will return to the shell prompt. The server process is now stopped.

  • Remove the local koa-request-observatory folder by running these commands:
cd ..
rm -rf koa-request-observatory

What Do These Commands Remove?

The first command moves Terminal to the folder above your project. The second command permanently removes the koa-request-observatory folder and everything stored inside it.

  • View the folder that previously contained koa-request-observatory in Finder.

You should no longer see the koa-request-observatory folder there. Your local project has been fully removed.

Still See the Project Folder?

Confirm that the server process stopped before deleting the folder. Also confirm that the Terminal window was running the Koa Request Observatory before you moved to its parent folder.

Ask for a safe path check with Help me verify and delete my Koa project folder..

Nice Work!

Nice Work!

You did it! You built a working Koa Request Observatory with a local user API.

What you learned:

  • Built a local user API with a health check at GET /health. Added user lookup through the GET /users/:id route parameter. Added user creation through POST /users with a JSON request body. Used @koa/router for method-aware routing. Used @koa/bodyparser to expose parsed input.
  • Made the middleware cascade visible with paired downstream logs. Used await next() to reveal upstream resumption. Added response timing through the X-Response-Time header.
  • Built centralized error handling that returns JSON failures. Used HTTP status codes to keep each failure predictable. Covered invalid input with 400 responses. Covered missing users with 404 responses. Added method handling for unsupported methods. Added a JSON fallback for unknown routes.
  • Secret Mission: Extended the API with the diagnostic endpoint at GET /cascade. Its response reports every middleware marker in execution order.

Ready to quiz yourself?