Appearance
Handle URLs
In the hello-koa project, we handle HTTP requests by returning the same HTML for any URL. While this is straightforward, testing with a browser shows that any input URL will return the same webpage.
Typically, we should call different handler functions for different URLs to return varied results. For example:
javascript
app.use(async (ctx, next) => {
if (ctx.request.path === '/') {
ctx.response.body = 'index page';
} else {
await next();
}
});
Using this approach works but feels a bit clunky.
We should have middleware that centrally handles URLs and calls different handler functions based on the URL, allowing us to focus on writing handlers for each URL.
@koa/router
To handle URLs, we need to introduce the @koa/router
middleware to manage URL mapping.
Let's copy the previous hello-koa project and rename it to url-koa.
First, install the dependency with the command npm install @koa/router
, and then modify app.mjs to use @koa/router for URL handling:
javascript
import Koa from 'koa';
import Router from '@koa/router';
const app = new Koa();
const router = new Router();
// log url:
app.use(async (ctx, next) => {
console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);
await next();
});
// parse request.body:
app.use(bodyParser());
// route: handle /
router.get('/', async (ctx, next) => {
ctx.response.type = 'text/html';
ctx.response.body = '<h1>Index Page</h1>';
});
// route: handle /hello/:name
router.get('/hello/:name', async (ctx, next) => {
let s = ctx.params.name;
ctx.response.type = 'text/html';
ctx.response.body = `<h1>Hello, ${s}</h1>`;
});
// use router:
app.use(router.routes());
app.listen(3000);
console.log('app started at port 3000...');
We use router.get('/path', async fn)
to register a GET request. The path can include variables like /hello/:name
, which can be accessed through ctx.params.name
.
Now we can test different URLs:
Input homepage: http://localhost:3000/
Input: http://localhost:3000/hello/Bob
Handle POST Requests
We handle GET requests with router.get('/path', async fn)
. To handle POST requests, use router.post('/path', async fn)
.
When dealing with POST requests, we typically send a form or JSON as the request body, but neither Node.js's native request object nor Koa's request object provides body parsing functionality.
Thus, we need to introduce another middleware to parse the raw request, binding the parsed parameters to ctx.request.body
. @koa/bodyparser
is used for this purpose.
Install it with npm install @koa/bodyparser
, then modify app.mjs to import @koa/bodyparser
:
javascript
import { bodyParser } from '@koa/bodyparser';
app.use(bodyParser());
This middleware must be registered to the app object before the router due to the importance of middleware order.
Now we can handle POST requests. Create a simple login form:
javascript
router.get('/', async (ctx, next) => {
ctx.response.type = 'text/html';
ctx.response.body = `
<h1>Index Page</h1>
<form action="/signin" method="post">
<p>Name: <input name="name" value="koa"></p>
<p>Password: <input name="password" type="password"></p>
<p><button type="submit">Submit</button></p>
</form>
`;
});
router.post('/signin', async (ctx, next) => {
let name = ctx.request.body.name || '';
let password = ctx.request.body.password || '';
console.log(`try signin: ${name}, password: ${password}`);
if (name === 'koa' && password === '12345') {
ctx.response.type = 'text/html';
ctx.response.body = `<h1>Welcome, ${name}!</h1>`;
} else {
ctx.response.type = 'text/html';
ctx.response.body = '<h1>Signin failed!</h1><p><a href="/">Retry</a></p>';
}
});
Note that we use let name = ctx.request.body.name || ''
to retrieve the name field from the form, defaulting to ''
if it doesn't exist.
Similar methods can be used for PUT, DELETE, and HEAD requests.
Refactoring
Now that we can handle different URLs, the app.mjs still feels somewhat cluttered. As we add more URLs, the file will continue to grow.
It would be ideal to centralize URL handling functions into a dedicated JS file or files, allowing app.mjs to automatically import all URL handler functions. This separation clarifies logic and keeps app.mjs manageable. The structure should look like this:
url2-koa/
├── controller/
│ ├── hello.mjs <-- handles /hello/:name
│ └── signin.mjs <-- handles /signin
├── app.mjs
├── package-lock.json
└── package.json
We will copy url-koa and rename it to url2-koa for this refactor.
First, create signin.mjs in the controller directory:
javascript
// GET /
async function index(ctx, next) {
ctx.response.type = 'text/html';
ctx.response.body = `
<h1>Index Page</h1>
<form action="/signin" method="post">
<p>Name: <input name="name" value="koa"></p>
<p>Password: <input name="password" type="password"></p>
<p><button type="submit">Submit</button></p>
</form>
`;
}
// POST /signin
async function signin(ctx, next) {
let name = ctx.request.body.name || '';
let password = ctx.request.body.password || '';
console.log(`try signin: ${name}, password: ${password}`);
if (name === 'koa' && password === '12345') {
ctx.response.type = 'text/html';
ctx.response.body = `<h1>Welcome, ${name}!</h1>`;
} else {
ctx.response.type = 'text/html';
ctx.response.body = '<h1>Signin failed!</h1><p><a href="/">Retry</a></p>';
}
}
// Export handlers:
export default {
'GET /': index,
'POST /signin': signin
};
Similarly, create hello.mjs to export a single URL handler:
javascript
async function hello(ctx, next) {
let s = ctx.params.name;
ctx.response.type = 'text/html';
ctx.response.body = `<h1>Hello, ${s}</h1>`;
}
export default {
'GET /hello/:name': hello
};
Now, modify app.mjs to automatically scan the controller directory, importing and registering each URL:
javascript
// Scan controller directory:
const dirname = path.dirname(fileURLToPath(import.meta.url));
console.log(`scan dir ${dirname}...`);
let files = readdirSync(path.join(dirname, 'controller')).filter(f => f.endsWith('.mjs'));
for (let file of files) {
console.log(`import controller/${file}...`);
let { default: mapping } = await import(`./controller/${file}`);
for (let url in mapping) {
if (url.startsWith('GET ')) {
let p = url.substring(4);
router.get(p, mapping[url]);
console.log(`mapping: GET ${p}`);
} else if (url.startsWith('POST ')) {
let p = url.substring(5);
router.post(p, mapping[url]);
console.log(`mapping: POST ${p}`);
} else {
console.warn(`invalid mapping: ${url}`);
}
}
}
Controller Middleware
Finally, extract the scanning of the controller directory and the router creation code from app.mjs into a simple middleware named controller.mjs:
javascript
// controller.mjs:
async function scan(router, controllerDir) {
const dirname = path.dirname(fileURLToPath(import.meta.url));
console.log(`scan dir ${dirname}...`);
let files = readdirSync(path.join(dirname, controllerDir)).filter(f => f.endsWith('.mjs'));
for (let file of files) {
console.log(`import controller/${file}...`);
let { default: mapping } = await import(`./${controllerDir}/${file}`);
for (let url in mapping) {
if (url.startsWith('GET ')) {
let p = url.substring(4);
router.get(p, mapping[url]);
console.log(`mapping: GET ${p}`);
} else if (url.startsWith('POST ')) {
let p = url.substring(5);
router.post(p, mapping[url]);
console.log(`mapping: POST ${p}`);
} else {
console.warn(`invalid mapping: ${url}`);
}
}
}
}
// Default scan directory is controller:
export default async function (controllerDir = 'controller') {
const router = new Router();
await scan(router, controllerDir);
return router.routes();
}
Now, app.mjs is simplified:
javascript
import controller from './controller.mjs';
...
app.use(await controller());
...
With this refactoring, the url2-koa project has excellent modularity, keeping URL handling functions grouped in the controller directory. As we add more features, app.mjs
remains unchanged.
Koa Request Handling Flow
Finally, let's summarize the process of how Koa handles an HTTP request:
│
│
▼
┌─────────────────────┐
│log: │
│async(ctx,next) {...}│
└─────────────────────┘
│
▼
┌─────────────────────┐
│bodyParser() │
└─────────────────────┘ GET / ┌─────────────────────┐
│ ┌──────────────────▶│async(ctx,next) {...}│
▼ │ └─────────────────────┘
┌─────────────────────┐ │ POST /signin ┌─────────────────────┐
│router.routes() ├───┼──────────────────▶│async(ctx,next) {...}│
└─────────────────────┘ │ └─────────────────────┘
│ GET /hello/:name ┌─────────────────────┐
└──────────────────▶│async(ctx,next) {...}│
└─────────────────────┘
An HTTP request is processed sequentially by a series of registered Koa middleware, starting with the log function and passing the request to the next middleware using await next()
. Next, the body parser processes the request, and finally, the router handles it. Within the router, it determines which async function processes the request based on the registered HTTP method and path. If no URL matches, a 404 response is returned. This overview captures the clear and comprehensible workflow of a Koa-based web app.