Middleware

Transform requests and responses using the Koa-style onion model.

MiiaJS uses a Koa-style onion model for middleware. A single compose() function replaces the need for separate interceptors, pipes, and filters.

Middleware signature

type Middleware = (
  ctx: RequestContext,
  next: () => Promise<void>,
) => Promise<void>

Every middleware must call await next() to pass control to the next middleware, or skip it to short-circuit the pipeline.

Onion model

Middleware wraps around the handler, executing code before and after:

Request -> [MW1 before] -> [MW2 before] -> [Handler] -> [MW2 after] -> [MW1 after] -> Response
const timing: Middleware = async (ctx, next) => {
  const start = Date.now()
  await next()
  const duration = Date.now() - start
  ctx.res.header('X-Response-Time', `${duration}ms`)
}

Global middleware

Apply middleware to all routes with app.use():

import { Miia, cors } from '@miiajs/core'

const app = new Miia()
  .use(cors())
  .use(timing)
  .register(AppModule)

Global middleware executes in the order it's registered.

Global middleware is pre-route: it wraps the entire dispatch, including router.match(). This means it runs on every request, including unmatched routes that return 404. NotFoundException bubbles up through the onion, so middleware can observe it via try { await next() } catch:

const requestLogger: Middleware = async (ctx, next) => {
  const start = Date.now()
  try {
    await next()
  } finally {
    const status = ctx.res.getStatus()
    console.log(`${ctx.req.method} ${new URL(ctx.req.url).pathname} ${status} (${Date.now() - start}ms)`)
  }
}

This is why CORS, request loggers, and request-id middleware set headers that appear on 404 responses - the header was written before router.match() threw.

ctx.params is {} inside global middleware beforeawait next() - the router hasn't matched yet. After await next() on a matched route it contains the route params.

Early termination

A global middleware that does not call next() short-circuits the dispatch - router.match() never runs, the handler never runs, and the response is built from ctx.res:

const rateLimiter: Middleware = async (ctx, next) => {
  if (await isRateLimited(ctx.req)) {
    ctx.res.status(429).json({ error: 'Too Many Requests' })
    return // no next() → router and handler skipped entirely
  }
  await next()
}

This is the standard Koa pattern - the response is assembled from the ResponseBuilder exactly as if the handler had returned null.

Controller middleware

Apply middleware to all routes in a controller with the @Use() decorator:

import { Controller, Use } from '@miiajs/core'

@Controller('/api')
@Use(loggingMiddleware, authMiddleware)
class ApiController {
  // All methods get loggingMiddleware and authMiddleware
}

Route middleware

Apply middleware to a specific route:

@Controller('/items')
class ItemController {
  @Post('/')
  @Use(validateMiddleware)
  async create(ctx: RequestContext) {
    return await ctx.json()
  }
}

Execution order

Global middleware (app.use())
  -> Global guards (app.useGuard())
  -> Controller middleware (@Use on class)
  -> Route guards (@UseGuard on method)
  -> Route middleware (@Use on method)
  -> Validation (@ValidateBody, etc.)
  -> Route handler

Built-in middleware

CORS

import { cors } from '@miiajs/core'

app.use(cors({
  origin: ['https://example.com', 'https://app.example.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400,
}))

Options:

OptionTypeDescription
originstring | string[] | ((origin: string) => boolean)Allowed origins
methodsstring[]Allowed HTTP methods
allowedHeadersstring[]Allowed request headers
exposedHeadersstring[]Headers exposed to the browser
credentialsbooleanAllow credentials
maxAgenumberPreflight cache duration (seconds)

Writing custom middleware

import type { Middleware, RequestContext } from '@miiajs/core'

// Extend RequestContext with custom properties via declaration merging
declare module '@miiajs/core' {
  interface RequestContext {
    user?: { id: string; role: string }
  }
}

const auth: Middleware = async (ctx, next) => {
  const token = ctx.req.headers.get('authorization')
  if (!token) {
    throw new UnauthorizedException()
  }
  ctx.user = await verifyToken(token)
  await next()
}

const errorHandler: Middleware = async (ctx, next) => {
  try {
    await next()
  } catch (error) {
    // Custom error handling
    ctx.res.status(500).json({ error: 'Something went wrong' })
  }
}

compose()

The compose() function combines multiple middleware into one:

import { compose } from '@miiajs/core'

const combined = compose([auth, logging, timing])

app.use(combined)