Decorator-based controllers and routing utilities for TypeScript applications built with Koa. The package includes request-state mapping, AJV validation, request logging, error handling, and OpenTracing middleware.
pnpm add @tng/koa-controller koa koa-bodyparser
pnpm add -D typescript @types/koa @types/koa-bodyparser @types/koa-routerThe package targets Node.js 18 or later and uses legacy TypeScript decorators. Enable decorator support in your tsconfig.json:
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"experimentalDecorators": true,
"esModuleInterop": true
}
}Create and export a controller. Controller methods receive ctx.state as their first argument and the Koa context as their second argument. Their resolved return value becomes ctx.body.
// src/controllers/user.ts
import { controller, get } from '@tng/koa-controller'
@controller('/api/users')
export class UserController {
@get('/:id')
async getUser(state, ctx) {
return {
id: ctx.params.id,
}
}
}Load the exported controllers and mount the router:
// src/app.ts
import Koa from 'koa'
import bodyParser from 'koa-bodyparser'
import {
errorHandlerMW,
getRouterAsync,
loggerMW,
} from '@tng/koa-controller'
const app = new Koa()
app.use(loggerMW({ logger: console }))
app.use(errorHandlerMW({ logger: console }))
app.use(bodyParser())
const router = await getRouterAsync({
files: 'src/controllers/**/*.[jt]s',
})
app.use(router.routes())
app.use(router.allowedMethods())
app.listen(4000)Use getRouterAsync for ESM controllers. For CommonJS projects whose controller files can be loaded with require(), use getRouterSync:
const Koa = require('koa')
const { getRouterSync } = require('@tng/koa-controller')
const app = new Koa()
const router = getRouterSync({
files: 'src/controllers/**/*.[jt]s',
})
app.use(router.routes())
app.use(router.allowedMethods())
app.listen(4000)Controllers must be exported from their modules so that the file loader can discover them.
When running TypeScript source files directly, use a TypeScript runtime/loader that supports decorators and dynamic imports. In a compiled deployment, point files at the emitted JavaScript instead, for example dist/controllers/**/*.js.
Marks a class as a controller and optionally gives all of its routes a prefix. A class without decorated route methods is ignored, and a decorated method is ignored unless its class has controller metadata.
@controller('/api')
export class HealthController {
@get('/health')
async health() {
return { status: 'ok' }
}
}A controller class is instantiated once when the router is built. Do not keep request-specific mutable state on the instance because it is shared by concurrent requests.
Registers a controller method for an HTTP verb and path. The same method can have more than one route decorator.
@controller('/sessions')
export class SessionController {
@request('post', '/')
async create(state, ctx) {
return { created: true }
}
}Controller methods must be asynchronous/thenable. Their resolved result is assigned to ctx.body.
Shortcuts for @request('get', pathname) and @request('post', pathname).
Middleware decorators can be applied to a controller class or an individual method:
@controller('/api')
@before(async ctx => {
ctx.set('x-controller', 'example')
})
export class ExampleController {
@get('/items')
@middleware(async (ctx, next) => {
const startedAt = Date.now()
await next()
ctx.set('x-duration', String(Date.now() - startedAt))
})
@after(async ctx => {
ctx.set('x-handler-complete', 'true')
})
async list() {
return []
}
}@middleware((ctx, next) => Promise<void>)registers standard Koa middleware.@before((ctx) => Promise<void>)runs before the next middleware.@after((ctx) => Promise<void>)runs after downstream middleware completes successfully.
Within the controller and method scopes, @before callbacks and the entry phase of @middleware run from top to bottom in the order they are declared. The complete execution sequence is:
- Controller-level
@beforecallbacks and@middlewareentry phases, in declaration order - Method-level
@beforecallbacks and@middlewareentry phases, in declaration order - Controller method
- Method-level
@aftercallbacks, from top to bottom - Method-level
@middlewarecode afterawait next(), in reverse order - Controller-level
@aftercallbacks, from top to bottom - Controller-level
@middlewarecode afterawait next(), in reverse order
This follows Koa's onion model while preserving top-to-bottom decorator order within each scope.
getRouterAsync(options) dynamically imports every matching module and works with ESM controller files. getRouterSync(options) uses require() and is intended for CommonJS-loadable(CJS) files.
const router = await getRouterAsync({
cwd: process.cwd(),
files: 'src/controllers/**/*.[jt]s',
prefix: '/v1',
})Options:
| Option | Default | Description |
|---|---|---|
cwd |
process.cwd() |
Base directory used when resolving the glob and controller files. |
files |
api/**/*.[jt]s |
Glob pattern used to discover controller modules. |
prefix |
'' |
Prefix passed to the underlying Koa router. |
logger |
undefined |
Accepted for API compatibility; controller discovery currently uses the koa:controller debug namespace. |
For dependency injection or tests, use loadRouter to provide constructors directly:
const router = loadRouter({
prefix: '/v1',
controllerConstructors: [UserController],
})Set DEBUG=koa:controller to inspect controller discovery and route registration.
Without a map, @state() replaces ctx.state with a shallow merge of ctx.query, ctx.request.body, and ctx.params. Later sources take precedence, so path parameters override body values and body values override query values.
With a map, only the selected values are copied. Every source path is resolved from the Koa context with lodash.get:
@controller('/users')
export class UserController {
@get('/:id')
@state({
userId: ['params.id'],
name: ['query.name'],
token: ['headers.authorization'],
})
async getUserInfo(state) {
return state
}
}Each mapping value is an array of context paths. When multiple paths are supplied, they are evaluated from left to right and later values overwrite earlier ones.
Validation is powered by AJV. The shared default AJV instance enables coerceTypes and useDefaults, so validation can modify the validated data.
Validates the complete Koa context before invoking the controller method. Invalid input throws an HTTP 400 error containing the first AJV validation message.
@validate({
type: 'object',
required: ['query'],
properties: {
query: {
type: 'object',
required: ['id'],
properties: {
id: { type: 'integer' },
},
},
},
})Validates only ctx.state. Place @state() above @validateState() when the state should be built from the incoming request first:
@controller('/users')
export class UserController {
@post('/')
@state()
@validateState({
type: 'object',
required: ['name'],
properties: {
name: { type: 'string', minLength: 1 },
age: { type: 'integer', minimum: 0 },
},
})
async create(state) {
return state
}
}Both decorators accept a custom AJV instance:
@validateState(schema, { ajv })AJV performs runtime validation; it does not automatically infer the controller method parameter type. Define the state type explicitly and use AJV's JSONSchemaType<T> to keep the schema consistent with that type. JSONSchemaType<T> requires strictNullChecks to be enabled in TypeScript.
import type { JSONSchemaType } from 'ajv'
import {
controller,
get,
state,
validateState,
} from '@tng/koa-controller'
interface GetUserState {
userId: string
}
const getUserStateSchema: JSONSchemaType<GetUserState> = {
type: 'object',
required: ['userId'],
properties: {
userId: { type: 'string' },
},
additionalProperties: false,
}
@controller('/users')
export class UserController {
@get('/:id')
@state({
userId: ['params.id'],
})
@validateState(getUserStateSchema)
async getUser(state: GetUserState) {
const { userId } = state
return { userId }
}
}In this pattern, @state builds the value, @validateState verifies it at runtime, and GetUserState provides autocomplete and compile-time checks inside the controller method.
Logs one structured object after every request. The object contains status, method, routerName, duration, url, and userAgent.
app.use(loggerMW({
logger,
inject: async (data, ctx) => ({
...data,
traceId: ctx.traceId,
}),
}))Set ctx.skipLogger = true to suppress the request log. If inject throws, the middleware ignores that error and logs the base request data.
Catches downstream errors and responds with:
{
"error": "error message"
}The middleware uses error.status when present and defaults to HTTP 500. Errors with status 500 or greater are sent to the optional logger.
app.use(errorHandlerMW({ logger }))Register loggerMW before errorHandlerMW if the request logger should observe the final error status.
Creates an OpenTracing span for every HTTP request, extracts an upstream span context from the request headers, and records the HTTP method, URL, and response status.
app.use(traceMW(tracer))Pass zipkinHeaderEnable: true to fall back to Zipkin header extraction when the standard HTTP header format has no parent trace.
Creates an isolated AsyncLocalStorage store for every request. Register it before traceMW to make the current span and trace ID available from the store:
import { AsyncLocalStorage } from 'node:async_hooks'
import type { Span } from 'opentracing'
import { alsMW, traceMW } from '@tng/koa-controller'
interface RequestStore {
span?: Span
traceId?: string
}
const als = new AsyncLocalStorage<RequestStore>()
app.use(alsMW(als))
app.use(traceMW(tracer, { als }))pnpm install
pnpm lint
pnpm test
pnpm test:coverage
pnpm buildThe build produces both ESM (lib/*.js) and CommonJS (lib/*.cjs) entry points, plus TypeScript declarations.
MIT