Koa 304,877 ships no router, so the port starts with npm 2,036 i koa @koa/router koa-bodyparser. From there it is mechanical, because Bookshelf keeps its rules in services and its HTTP concerns in thin middleware.
| Express | Koa | Note |
|---|---|---|
| express.json() | bodyParser() | fills ctx.request.body |
| res.status(201).json(x) | ctx.status = 201; ctx.body = x | assignment, not a call |
| req.user, req.validBody | ctx.state.user, ctx.state.body | the per-request scratchpad |
| (err, req, res, next) | try/catch around await next() | registered first, not last |
req.params and req.query become ctx.params and ctx.query, still strings. requireRole, validate and createBook port with three lines changed each: they read from ctx, write to ctx.state, and throw where the Express versions called next(err).
const problems = async (ctx, next) => {
try {
await next();
if (ctx.status === 404 && !ctx.body) throw new HttpError(404, 'not_found', 'No such route');
} catch (err) { // every refusal in the app lands here
ctx.status = err.status ?? 500;
ctx.body = { error: { code: err.code ?? 'internal', message: err.message,
details: err.details } };
}
};
const router = new Router({ prefix: '/api/v1/books' });
router.get('/', validate({ query: listQuery }), (ctx) => {
const { limit, page } = ctx.state.query;
const { rows, total } = store.list({ limit, offset: (page - 1) * limit });
ctx.body = { data: rows, page: { limit, page, total, pages: Math.ceil(total / limit) } };
}); // no res, no return: just assign ctx.body
router.post('/', requireRole('editor'), validate({ body: newBook }), createBook);
new Koa().use(problems).use(bodyParser())
.use(router.routes()).use(router.allowedMethods()).listen(4350, '127.0.0.1');The responses are byte-for-byte what Express.js has produced all along, which is the point:
200 {"data":[{"id":"bk_1","title":"Solaris",...},{...}],"page":{"limit":2,"page":1,
"total":3,"pages":2}} # GET /books?limit=2
422 {"error":{"code":"validation_failed","message":"Request validation failed","details":
[{"in":"params","field":"id","message":"not a bk id"}]}} # GET /books/42
403 {"error":{"code":"forbidden","message":"Role editor required"}} # POST, no token
404 {"error":{"code":"not_found","message":"No such route"}} # GET /nopeTwo traps. use(a).use(b) is chainable but order still decides everything: problems must be first, or a throw escapes to Koa's default handler. And a 404 never reaches a catch, because no route matching is not an error in Koa — it is the absence of a body, which is why problems inspects ctx.status on the way back up.